# ecs-observable Entity-Component-System with an observable-style API for TypeScript. Built for games and simulations. ## Table of Contents - [Installation](#installation) - [Concepts](#concepts) - [Quick Start](#quick-start) - [API](#api) - [World](#world) - [Components](#components) - [Singleton Components](#singleton-components) - [Queries](#queries) - [Observable Queries](#observable-queries) - [Change Tracking](#change-tracking) - [Relationships](#relationships) - [Events](#events) - [Serialization](#serialization) - [Commands](#commands) - [Behaviour Trees](#behaviour-trees) - [TypeScript Inference](#typescript-inference) ## Installation ```bash 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 ```ts 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 ```ts const world = new World(); ``` #### Entity Lifecycle ```ts // 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. ```ts const Health = defineComponent("health", { current: 100, max: 100 }); type Health = typeof Health.type; // → { current: number; max: number } ``` #### CRUD ```ts 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. --- ### Singleton Components For global state (score, board, config) that doesn't need per-entity tracking. A single internal entity is created lazily and reused for all singleton components. ```ts const Score = defineComponent("score", { points: 0, level: 1 }); // Add (auto-creates the backing entity on first call) world.addSingleton(Score); // Get / set / check — no entity argument needed world.getSingleton(Score).points += 100; world.hasSingleton(Score); // true world.setSingleton(Score, { points: 0, level: 2 }); // Try-get const s = world.tryGetSingleton(Score); // Score | undefined // Mark dirty for change tracking world.markDirtySingleton(Score); // Remove (destroys the backing entity if it becomes bare) world.removeSingleton(Score); ``` --- ### Queries Create filters with `query()`. Chain `.without()` to exclude components. ```ts 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 ```ts 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. ```ts 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: ```ts const movers = new Set(); 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. ```ts 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. ```ts const ChildOf = defineRelationship("childOf"); const Parent = defineRelationship("parent"); ``` ```ts 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 ```ts 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. ```ts 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 ```ts // Export const snapshot = world.toJSON(); const json = JSON.stringify(snapshot); // Import — must supply the known component/relationship definitions const components: ComponentDef[] = [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:** ```ts 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 } ``` --- ### Commands Decouple input from game logic. Define command components, register handlers, spawn command entities from input — the `CommandQueue` drains and dispatches them each frame. ```ts import { CommandQueue } from "ecs-observable/commands"; const MoveLeft = defineComponent("moveLeft", {}); const MoveRight = defineComponent("moveRight", {}); const queue = new CommandQueue(world); // Register handlers queue.handle(MoveLeft, () => { player.x -= 1; }); queue.handle(MoveRight, () => { player.x += 1; }); // Input → spawn command entities onKey("ArrowLeft", () => { const cmd = world.spawn(); world.add(cmd, MoveLeft); }); // Each frame — drain and dispatch queue.execute(); ``` Command entities are automatically destroyed after processing if they become bare. --- ### Behaviour Trees Behaviour trees control game flow by composing tasks into a tree. Each node in the tree is an ECS entity with a `Task` component. Parent-child relationships are `ChildOf` edges. This means you can query, observe, and serialize the tree just like any other ECS data. `buildTree()` takes a declarative definition and materializes it into entities, returning a fully-wired `TaskRunner`. ```ts import { buildTree, Cancel } from "ecs-observable/bt"; ``` #### Leaf patterns **One-shot** — just return. Implicit success. ```ts { kind: "leaf", run: () => { doWork(); } } ``` **Fail** — throw any error. ```ts { kind: "leaf", run: () => { throw new Error("bad"); } } ``` **Cancel** — throw the `Cancel` symbol. ```ts { kind: "leaf", run: () => { throw Cancel; } } ``` **Ongoing** — generator function. Each `yield` suspends until next tick. The yielded value is the delay in ms (or nothing for next frame). Completion = success. ```ts { kind: "leaf", *run() { while (true) { const dt: number = yield; // delta time from runner.tick(dt) timer.accumulator += dt; if (timer.accumulator >= timer.interval) { // ... act ... } } } } ``` #### Composite nodes ```ts { kind: "sequential", children: [a, b, c] } // left-to-right, all must succeed { kind: "selector", children: [a, b, c] } // left-to-right, first success wins { kind: "parallel", children: [a, b, c] } // all at once, all must succeed { kind: "random", children: [a, b, c] } // pick one child each activation { kind: "repeat", child: a } // decorator — re-run child forever ``` #### Full example ```ts const runner = buildTree(world, { kind: "parallel", children: [ { kind: "leaf", *run() { while (true) { const dt: number = yield; updatePhysics(dt); } }, }, { kind: "repeat", child: { kind: "sequential", children: [ { kind: "leaf", run: () => { handleInput(); } }, { kind: "leaf", run: () => { render(); } }, ], }, }, ], }); // Kick off runner.schedule((runner as any).root); // Game loop setInterval(() => runner.tick(16), 16); ``` --- ## TypeScript Inference Components infer their type from the defaults object — no separate type declaration needed. ```ts 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[] ```