ecs-observable/USAGE.md

12 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.


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.

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.

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 }

Commands

Decouple input from game logic. Define command components, register handlers, spawn command entities from input — the CommandQueue drains and dispatches them each frame.

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 task entity definition and materializes it into entities, returning a fully-wired TaskRunner. Task definitions are created with factories, and non-task child entities can be mixed in as metadata; the runner ignores those non-task children during execution.

import { defineComponent, entity } from "ecs-observable";
import { buildTree, Cancel, action, wait, parallel, cycle, whilst, sequential } from "ecs-observable/bt";

Task patterns

Action — runs immediately. Normal return = success.

action(() => { doWork(); })

Fail — throw any error.

action(() => { throw new Error("bad"); })

Cancel — throw the Cancel symbol.

action(() => { throw Cancel; })

Wait — starts once and stays running until external code completes it.

wait((world, entity, task) => {
  startAnimation(() => task.succeed());
})

Whilst — runs its child while a condition is true, yielding at tick boundaries between successful iterations.

whilst(
  () => true,
  action((_world, _entity, dt) => {
    timer.accumulator += dt;
    if (timer.accumulator >= timer.interval) {
      // ... act ...
    }
  }),
)

Composite nodes

sequential([a, b, c])  // left-to-right, all must succeed
selector([a, b, c])    // left-to-right, first success wins
parallel([a, b, c])    // all at once, all must succeed
random([a, b, c])      // pick one child each activation
cycle(a)              // scheduling boundary — re-run child on future ticks
whilst(test, a)       // conditional scheduling boundary

Non-task entity children are materialized but ignored by the runner:

const Label = defineComponent("label", { value: "" });

sequential([
  entity(Label, { value: "main sequence" }),
  action(handleInput),
  action(render),
])

Full example

const runner = buildTree(
  world,
  parallel([
    whilst(
      () => true,
      action((_world, _entity, dt) => {
        updatePhysics(dt);
      }),
    ),
    cycle(
      sequential([
        action(() => { handleInput(); }),
        action(() => { render(); }),
      ]),
    ),
  ]),
);

// Kick off
runner.schedule(runner.root!);

// Game loop
setInterval(() => runner.tick(16), 16);

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[]