8.3 KiB
ecs-observable
Entity-Component-System with an observable-style API for TypeScript. Built for games and simulations.
Table of Contents
Installation
npm install ecs-observable
Concepts
| Concept | Purpose |
|---|---|
| Entity | Opaque number handle. Has no data of its own. |
| Component | Plain object attached to an entity. Defined via defineComponent(). |
| Query | Filter that finds entities matching a component signature. |
| Relationship | Directed edge between two entities (e.g. ChildOf, Targeting). |
| World | Central container that stores entities, components, and relationships. |
Quick Start
import { World, defineComponent, query } from "ecs-observable";
// 1. Define components
const Position = defineComponent("position", { x: 0, y: 0 });
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
// 2. Create world
const world = new World();
// 3. Spawn entities and add components
const player = world.spawn();
world.add(player, Position, { x: 100, y: 200 });
world.add(player, Velocity, { vx: 2, vy: 0 });
const npc = world.spawn();
world.add(npc, Position, { x: 300, y: 150 });
// 4. Iterate with queries
for (const e of world.query(query(Position, Velocity))) {
const pos = world.get(e, Position);
const vel = world.get(e, Velocity);
pos.x += vel.vx;
pos.y += vel.vy;
}
API
World
const world = new World();
Entity Lifecycle
// Create
const e = world.spawn();
// Check
world.isAlive(e); // true
// Destroy (removes all components and relationships)
world.destroy(e);
world.isAlive(e); // false
// Count
world.entityCount; // number of live entities
Destroyed slots are recycled (with a generation bump) so stale entity handles do not match new ones.
Components
Define a component with a name and defaults. The defaults provide the TypeScript shape and initial values.
const Health = defineComponent("health", { current: 100, max: 100 });
type Health = typeof Health.type; // → { current: number; max: number }
CRUD
const e = world.spawn();
// Add — returns a mutable reference initialized from defaults
const h = world.add(e, Health, { current: 50 }); // override defaults
h.current; // 50
// Get — returns the same mutable reference (throws if missing)
world.get(e, Health).current = 75;
// Try-get — safe access
const val = world.tryGet(e, Health); // Health | undefined
// Has — check presence
world.has(e, Health); // true
// Set — bulk replace (marks dirty automatically)
world.set(e, Health, { current: 90, max: 90 });
// Remove
world.remove(e, Health);
Get returns a live mutable reference — no defensive copies. Mutations are visible immediately.
Queries
Create filters with query(). Chain .without() to exclude components.
const Position = defineComponent("position", { x: 0, y: 0 });
const Velocity = defineComponent("velocity", { vx: 0, vy: 0 });
const Dead = defineComponent("dead", { timestamp: 0 });
// Entities with Position AND Velocity
query(Position, Velocity)
// Entities with Position but NOT Dead
query(Position).without(Dead)
Synchronous iteration
for (const e of world.query(query(Position, Velocity))) {
const pos = world.get(e, Position);
const vel = world.get(e, Velocity);
// update pos from vel...
}
Efficient: iterates the smallest component store and cross-checks the others.
Observable Queries
Subscribe to get live diffs when the result set changes.
world.observe(query(Position, Velocity)).subscribe(update => {
// update.added — Entity[]
// update.removed — Entity[]
// update.changed — Entity[] (only after flush, see below)
for (const e of update.added) { /* e now matches */ }
for (const e of update.removed) { /* e no longer matches */ }
});
Common pattern — maintain a rendering list:
const movers = new Set<number>();
world.observe(query(Position, Velocity)).subscribe(update => {
for (const e of update.added) movers.add(e);
for (const e of update.removed) movers.delete(e);
});
Subscriptions are seeded on creation: existing matches are tracked without emitting spurious added events.
Change Tracking
For observable queries to emit changed, you must explicitly mark dirty and flush.
const log: QueryUpdate[] = [];
world.observe(query(Position)).subscribe(u => log.push(u));
const e = world.spawn();
world.add(e, Position);
// Mutate and flush
world.get(e, Position).x = 99;
world.markDirty(e, Position);
world.flush();
// → log contains { added: [], removed: [], changed: [e] }
world.set() marks dirty for you, so markDirty is only needed after direct mutation via world.get().
Flush is batched — call it once per frame after all systems have run.
Relationships
Directed edges between entities. A source can target at most one entity per relationship type.
const ChildOf = defineRelationship("childOf");
const Parent = defineRelationship("parent");
const parent = world.spawn();
const child = world.spawn();
// Create
world.relate(child, ChildOf, parent);
// Read
world.getRelated(child, ChildOf); // → parent
world.getRelatedTo(parent, ChildOf); // → [child]
// Replace
world.relate(child, ChildOf, otherParent); // old edge removed automatically
// Remove
world.unrelate(child, ChildOf);
Destroying an entity cleans up all its edges bidirectionally.
Observable relationships
world.observeRelated(ChildOf).subscribe(update => {
// update.added — { source: Entity; target: Entity }[]
// update.removed — { source: Entity; target: Entity }[]
for (const { source, target } of update.added) {
console.log("new child relationship");
}
});
Like query observers, these are seeded with current edges on subscription.
Events
The global event stream gives full visibility into everything happening in the world.
world.events$.subscribe(ev => {
switch (ev.type) {
case "spawned":
case "destroyed":
// ev.entity
break;
case "componentAdded":
case "componentRemoved":
case "componentChanged":
// ev.entity, ev.component
break;
case "relationshipAdded":
case "relationshipRemoved":
// ev.source, ev.target, ev.relationship
break;
}
});
Events fire immediately on mutation (synchronous), before flush().
Serialization
// Export
const snapshot = world.toJSON();
const json = JSON.stringify(snapshot);
// Import — must supply the known component/relationship definitions
const components: ComponentDef<any>[] = [Position, Velocity, Health];
const relationships: RelationshipDef[] = [ChildOf];
const loaded = World.fromJSON(JSON.parse(json), components, relationships);
Snapshots use stable string IDs ("e0", "e1", …). Bare entities (no components) are preserved. Holes from destroyed entities are collapsed.
Round-trip example:
const world = new World();
const e = world.spawn();
world.add(e, Position, { x: 42, y: 99 });
world.add(e, Health, { current: 75, max: 100 });
const snap = world.toJSON();
const world2 = World.fromJSON(snap, [Position, Health]);
world2.entityCount; // 1
const e2 = [...world2.query(query(Position))][0];
world2.get(e2, Position); // { x: 42, y: 99 }
world2.get(e2, Health); // { current: 75, max: 100 }
TypeScript Inference
Components infer their type from the defaults object — no separate type declaration needed.
const Inventory = defineComponent("inventory", {
items: [] as string[],
gold: 0,
});
// world.add returns the typed component
const inv = world.add(e, Inventory, { gold: 42 });
inv.items.push("sword"); // ✅ string[]
inv.gold = 100; // ✅ number
// world.get also returns typed
world.get(e, Inventory).items; // string[]