world
Import from @pierre/ecs/world.
Generic, project-agnostic ECS registry. Owns entity id allocation, component/tag stores, the query engine entry point, template-based spawning, serialization, and opt-in spatial indexing. It has zero imports from consumer code — every component and tag is registered by the consumer after construction.
Consumer-specific behavior (typed getters, domain-specific lookups, cross-world entity transfer) belongs in a subclass owned by the consumer, not in this package.
Responsibilities
Section titled “Responsibilities”- Allocate
EntityIdvalues, recycling destroyed entities’ ids behind a generation counter (see Entity ids). - Register
ComponentDef<T>andTagDefschemas and hold the backingComponentStore<T>/TagStoreinstances. - Expose a typed
query(...)DSL that selects matching entities by archetype signature, with tag filters. - Spawn entities from
EntityTemplateblueprints with optional per-component overrides. - Serialize to / load from a plain JSON payload.
- Opt-in spatial indexing via
enableSpatial(def, structure, options?): any number of indexes, over components of any shape ({ x, y },{ x, y, z }, …), each optionally restricted to entities holding a tag. - Suppress dev-mode
requiresvalidation duringspawn()(components arrive in arbitrary order; full validation runs once per entity after the template has been fully applied).
| Method | Description |
|---|---|
createEntity() |
Create an empty entity and return its EntityId, possibly reusing a destroyed entity’s index with a newer generation. |
destroyEntity(id) |
Immediately remove id from every store and tag it holds and free its index. A no-op unless id is alive, so a stale id never touches the entity now using its index. Not safe to call while iterating a store — use queueDestroy instead. |
queueDestroy(id) |
Enqueue id for destruction on the next flushCommands() call. Deduped per flush; safe to call during iteration. |
queueSpawn(template?, overrides?) |
Reserve an id now and enqueue its creation (+ optional template population) for the next flushCommands(). Returns the id so it can be referenced by other queued commands in the same loop. |
queueAdd(def, id, value) |
Enqueue a component add for the next flushCommands(). Throws now if def is unregistered. |
queueRemove(def, id) |
Enqueue a component remove for the next flushCommands(). |
queueAddTag(def, id) / queueRemoveTag(def, id) |
Tag equivalents. |
flushCommands() |
Apply every queued structural change (spawn / destroy / add / remove / add-tag / remove-tag) in insertion order. Call once per tick, after systems finish iterating. |
isAlive(id) |
Whether id refers to a live entity (created and not yet destroyed). |
entityCount() |
Number of live entities. |
liveEntities() |
Iterate the live entity ids (order unspecified). |
endOfTick() |
End-of-tick convenience: runs flushCommands() then lifecycle.flush() so subscribers see the final entity set in one pass. Prefer over calling both manually. (TickRunner already does this internally.) |
registerComponent(def) |
Register a ComponentDef<T>; returns the store. Throws on duplicate name. |
registerTag(def) |
Register a TagDef; returns the store. Throws on duplicate name. |
getStore(def) |
Typed store lookup by def (throws if unregistered). |
getStoreByName(name) |
Untyped store lookup by string name. |
getTag(def) / getTagByName(name) |
Tag-store equivalents. |
enableSpatial(def, structure, { withTag? }) |
Index the component’s values in structure (any SpatialStructure, e.g. HashGrid2D / HashGrid3D from @pierre/ecs/modules/spatial) and keep it in sync with the store. With withTag, only entities that also hold the tag are indexed, following the tag’s add / remove too. Entities that already qualify are indexed at once. Returns structure with its own type — keep it to query the index. Callable any number of times; each call needs its own structure. |
move(def, id, to) |
Move an indexed component to to: every index on def that holds the entity moves it, then to’s fields are written into the stored value and the change is stamped. No-op if the entity lacks the component; throws if def has no index. |
getColumnStore(def) |
Fast-path accessor for an all-numeric component’s columnar store, exposing column() / slotOf() for zero-allocation hot loops. Throws if the component uses object storage. |
query(...defs) |
Build a typed Query over the given component defs. |
spawn(template, overrides?) |
Create an entity from a template, shallow-merging per-component overrides. |
spawnBatch(entries) |
Spawn many entities at once. Validates all at the end instead of per call. |
use(...plugins) |
Install one or more Plugins, calling each one’s build(world) exactly once. Plugin names must be unique per world. Returns this. |
hasPlugin(name) |
Whether a plugin with name has been installed. |
transferEntity(id, from, componentNames?) |
Copy an entity’s components from another world, preserving its id (index and generation). Throws when this world’s entity at that index is a different one, when the index is retired, or when it is free at a newer generation. Tags are not transferred (application-semantic). Optionally filter to a subset of components. |
clock |
The ChangeClock every registered store stamps added / changed ticks from; advanced by queries with added / changed filters. |
clearAll() |
Empty every component/tag store, the destroy queue, every spatial index, and the lifecycle event queue; reset id allocation so the next entity is 0. Registrations are preserved. Silent by design — no EntityDestroyed storm. Useful for full world resets (level restart, new game). |
toJSON() |
Serialize the registry to { entities, [storeName]: serialized }, where entities is the id-allocation state. |
loadJSON(data) |
In-place load — restores id allocation from entities, clears every registered store, then repopulates each from the payload entry of the same name. Throws on a row whose entity is not live in entities, and on payloads without entities. |
lifecycle |
EventBus<LifecycleEvent> — emits EntityCreated, EntityDestroyed, ComponentAdded, ComponentRemoved, TagAdded, TagRemoved. Queue-based; call lifecycle.flush() to dispatch (typically once per tick). An event is only built while its type has a subscriber, so a handler sees only changes made after it subscribed, and an unobserved world allocates nothing per mutation. destroyEntity on an id that is already dead emits nothing. Subscribers are not preserved across world swaps. |
Using the engine
Section titled “Using the engine”import { simpleComponent } from '@pierre/ecs/component-store';import { HashGrid2D } from '@pierre/ecs/modules/spatial';import { EcsWorld } from '@pierre/ecs/world';
interface Pos { x: number; y: number }const PosDef = simpleComponent<Pos>('pos', { x: 'number', y: 'number' });
const world = new EcsWorld();const positions = world.registerComponent(PosDef);const grid = world.enableSpatial(PosDef, new HashGrid2D());
const id = world.spawn({ name: 'marker', components: { pos: { x: 3, y: 4 } } });world.move(PosDef, id, { x: 5, y: 6 });grid.getAt(5, 6); // Set { id }Entity ids
Section titled “Entity ids”An EntityId is a number packing a 22-bit index (the entity’s slot) and an
8-bit generation (how many times that slot was reused before). A fresh
world hands out 0, 1, 2, …. Destroying an entity frees its index; the next
createEntity / spawn / queueSpawn reuses the most recently freed index
with the generation bumped. Ids stay below 2^30, so they are always small
integers to the JavaScript engine.
- Stale ids stay dead. An id held after its entity is destroyed never
matches the index’s next occupant:
isAliveisfalse, stores miss it,destroyEntity/queueDestroy/queueAdd/movethrough it do nothing. - Limits. At most
ENTITY_INDEX_MAX + 1(4,194,304) entities are alive at once. An index is retired after its 256th use rather than wrapping around, so the world allows about 10⁹ spawns between resets; beyond that,createEntitythrows. - Branded type. A plain
numberis not anEntityId. Ids come from the world (createEntity,spawn, queries, lifecycle events), frompackEntityId(index, generation), or, when decoding untrusted data, fromasEntityId(value, label)/isEntityId(value). AnEntityIdstill reads as a number anywhere (arithmetic, keys, template strings). - Reading ids.
entityIndex(id)/entityGeneration(id)split an id;formatEntityId(id)prints it as5v1for logs. - Save/load.
toJSONwrites the allocation state, generations of free indices included, so ids saved in component values keep their meaning afterloadJSON: live ids resolve, stale ones stay stale, and component-less entities survive the round-trip. clearAllresets allocation. Ids held across a reset may match entities created after it.
Extending for a specific consumer
Section titled “Extending for a specific consumer”The engine is designed to be subclassed. A consumer subclass typically:
- Calls
super()to initialize the engine. - Calls
this.registerComponent(...)for every consumer component, storing the returned store as a typedreadonlyfield. - Calls
this.registerTag(...)for every consumer tag. - Calls
this.enableSpatial(PositionDef, backend, options?)for each spatial index it needs, keeping each returned handle in a typedreadonlyfield. - Adds consumer-specific helpers on top of the generic API.
Invariants
Section titled “Invariants”- A structure backs at most one index.
- Component and tag names must be unique per world.
- Once
enableSpatialhas indexed a component, change an indexed entity’s value only viaworld.move(def, id, to)— a direct write leaves the index believing the old position. A component with no index has nothing to keep current, andmovethrows for it; write it like any other. WithwithTag, entities outside the tag are not indexed, so writing their values directly is fine. loadJSONrestores by name into the components and tags registered on the world, so register the same schemas (in any order) before calling it. Payload keys with no registered store are ignored, not an error.- Liveness (
isAlive/entityCount/liveEntities) is restored from the payload’sentitiesstate onloadJSON, so a component-less entity stays alive across a save/load round-trip. - Inside an
EntityDestroyedlifecycle handler the entity is already gone —isAlive(id)isfalseand its stores no longer hold it. - Do not mutate a store’s structure (add/remove a component or tag, destroy or
spawn an entity) while iterating a
world.query(...)— a swap-removed entity would be silently skipped. In DEV this throws; the fix is to record the change withqueueAdd/queueRemove/queueAddTag/queueRemoveTag/queueDestroy/queueSpawninside the loop andflushCommands()after it. Mutating a component’s values (not its presence) during iteration is safe.
See also
Section titled “See also”- Component Store - typed storage registered on the world.
- Query Builder -
world.query(...)entry point. - Entity Templates -
world.spawn(template, overrides). - Spatial Structure -
world.enableSpatial(def, structure)andworld.move(def, id, to). - Tick - drives the game loop around the world.