first commit
This commit is contained in:
91
docs/gaming/games/ecs/README.md
Normal file
91
docs/gaming/games/ecs/README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# @suaveplan/ecs - Archetype-based Entity Component System with Uint32 bitset queries, phase-ordered system scheduler, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation
|
||||
|
||||
Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component schemas, structured errors, and OpenTelemetry instrumentation. Universal — runs identically in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [archetype](./src/archetype/archetype.md) — An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with th…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [bitset](./src/bitset/bitset.md) — `BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [component-registry](./src/component-registry/component-registry.md) — `ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit position…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [entity-pool](./src/entity-pool/entity-pool.md) — `EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free…
|
||||
- [errors](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [ecs-error](./src/errors/ecs-error.md) — `EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from…
|
||||
- [observability](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [otel](./src/observability/otel.md) — This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [query](./src/query/query.md) — `Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluat…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [schema](./src/schema/schema.md) — `defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [system](./src/system/system.md) — A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates com…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
- [world](./src/world/world.md) — `World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, que…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/ecs
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position);
|
||||
world.addComponent(player, Velocity);
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`bitset/`](./src/bitset/bitset.md) — `Uint32Array`-backed bitset primitive.
|
||||
- [`component-registry/`](./src/component-registry/component-registry.md) — stable-ID registry for component schemas.
|
||||
- [`entity-pool/`](./src/entity-pool/entity-pool.md) — entity-ID allocator with recycling and generations.
|
||||
- [`archetype/`](./src/archetype/archetype.md) — column-oriented storage with O(1) swap-remove.
|
||||
- [`query/`](./src/query/query.md) — bitset-driven query compiler.
|
||||
- [`system/`](./src/system/system.md) — phase-priority system scheduler.
|
||||
- [`schema/`](./src/schema/schema.md) — `defineComponent` factory with optional Zod validator.
|
||||
- [`world/`](./src/world/world.md) — central container; spawn/despawn, components, queries, systems, snapshot/restore.
|
||||
- [`errors/`](./src/errors/ecs-error.md) — `EcsError` and the `ECS_CODES` registry.
|
||||
- [`observability/`](./src/observability/otel.md) — OTel counters and histograms.
|
||||
- [`types/`](./src/types/ecs.types.md) — every public type alias and interface.
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/ecs
|
||||
bunx turbo test:coverage --filter=@suaveplan/ecs # 100% statement+branch+function+line
|
||||
bun run docs:check --filter=@suaveplan/ecs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Suaveplan Team
|
||||
|
||||
80
docs/gaming/games/ecs/src/archetype/archetype.md
Normal file
80
docs/gaming/games/ecs/src/archetype/archetype.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Archetype
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
An `Archetype` represents the unique combination of component types shared by a group of entities. All entities with the same component set live in a single archetype, whose storage is column-oriented (Struct of Arrays). When an entity's component set changes, the world swap-removes it from its current archetype and inserts it into the destination archetype, keeping iteration over any single component cache-friendly and O(1).
|
||||
|
||||
This class is internal-but-exported so advanced consumers (debug overlays, profilers, custom system runners) can inspect or iterate dense entity arrays without going through the world's higher-level API.
|
||||
|
||||
## Features
|
||||
|
||||
- Mask-keyed identity (`mask.toKey()`) plus a sorted component-ID list.
|
||||
- One dense `ComponentData[]` per component, addressed by entity slot index.
|
||||
- O(1) `removeEntity` via swap-with-tail; dense packing is invariant across mutations.
|
||||
- Returns the removed entity's full column map for cheap migration to the destination archetype.
|
||||
- `setComponent` merges partial updates without re-allocating the slot.
|
||||
- `hasComponent`, `hasEntity`, `getEntities`, `getComponentIds` accessors for read-only inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
mask.set(1);
|
||||
const arch = new Archetype(mask, [0, 1]);
|
||||
|
||||
arch.addEntity(7, new Map([
|
||||
[0, { x: 0, y: 0 }],
|
||||
[1, { dx: 1, dy: 0 }],
|
||||
]));
|
||||
|
||||
console.log(arch.count); // 1
|
||||
console.log(arch.getComponent(7, 0)); // { x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { Archetype, BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(32);
|
||||
mask.set(0);
|
||||
const arch = new Archetype(mask, [0]);
|
||||
|
||||
for (const id of [1, 2, 3]) {
|
||||
arch.addEntity(id, new Map([[0, { v: id }]]));
|
||||
}
|
||||
|
||||
// Migration to a new archetype: pull the removed entity's data and forward it.
|
||||
const removed = arch.removeEntity(2);
|
||||
const targetArch = new Archetype(mask, [0]);
|
||||
if (removed) targetArch.addEntity(2, removed);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Archetype(mask, componentIds)` | Build an archetype with the given component-ID set. |
|
||||
| `mask` | The bitset of owned component IDs. |
|
||||
| `id` | `mask.toKey()` — stable archetype identity. |
|
||||
| `count` | Number of entities currently in this archetype. |
|
||||
| `addEntity(entity, data)` | Append an entity with its initial column values. |
|
||||
| `removeEntity(entity)` | Swap-remove and return the entity's full column map (or `undefined`). |
|
||||
| `getComponent(entity, componentId)` | Read a single column value. |
|
||||
| `setComponent(entity, componentId, data)` | Merge a partial update; returns `false` on missing entity/column. |
|
||||
| `hasEntity(entity)` / `hasComponent(componentId)` | Membership checks. |
|
||||
| `getEntities()` / `getComponentIds()` | Read-only snapshots of the storage order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Component data is stored in parallel arrays — one array per component-ID. Entity slot `n` maps to position `n` in every array, so a system iterating one archetype reads dense memory.
|
||||
- Swap-remove is the classic dense-vector trick: copy the tail value into the removed slot then pop. This keeps the entities array contiguous, but means the entity order is **not stable** across removals — callers needing stable order must impose it externally.
|
||||
- `setComponent` performs an object spread merge rather than assigning by reference. This costs an allocation per mutation but keeps the stored value immutable from the caller's perspective.
|
||||
- `removeEntity` collects every column value into a `Map` before destroying the slot. The world uses this map to seed the destination archetype during component-add / component-remove transitions.
|
||||
82
docs/gaming/games/ecs/src/bitset/bitset.md
Normal file
82
docs/gaming/games/ecs/src/bitset/bitset.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# BitSet
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BitSet` is the foundational mask primitive used by archetypes, queries, and the component registry inside `@suaveplan/ecs`. Storing component membership as bits packed into `Uint32Array` words gives the engine cache-friendly storage and O(words) algebra for subset, superset, and intersection tests — the hot paths driving every archetype-graph transition.
|
||||
|
||||
The class is intentionally narrow: it offers only the operators needed by the engine itself (`set`, `unset`, `has`, `containsAll`, `intersects`, `equals`, `count`, `clone`, `clear`, `toKey`, iterator). It is universal — no `node:*`, browser, or React dependency — so it runs identically in Bun, Node, and the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- `Uint32Array`-backed storage; one word per 32 bits.
|
||||
- O(words) `containsAll` and `intersects`, safe across mismatched sizes.
|
||||
- Population count (`count`) via the SWAR Hamming-weight trick.
|
||||
- Sorted-ascending iteration through `Symbol.iterator`.
|
||||
- Stable serialised key from `toKey()` used as the archetype-graph map key.
|
||||
- Defensive `set` / `unset` guards: out-of-range bit indices throw `RangeError`; the constructor rejects non-positive sizes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const mask = new BitSet(64);
|
||||
mask.set(0);
|
||||
mask.set(5);
|
||||
mask.set(32);
|
||||
|
||||
console.log(mask.has(5)); // true
|
||||
console.log([...mask]); // [0, 5, 32]
|
||||
console.log(mask.toKey()); // "0,5,32"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BitSet } from "@suaveplan/ecs";
|
||||
|
||||
const include = new BitSet(128);
|
||||
const archetype = new BitSet(128);
|
||||
include.set(3);
|
||||
include.set(7);
|
||||
for (const cid of [3, 7, 11, 18]) archetype.set(cid);
|
||||
|
||||
if (archetype.containsAll(include)) {
|
||||
// Archetype has every required component — yield its entities.
|
||||
}
|
||||
|
||||
const exclude = new BitSet(128);
|
||||
exclude.set(11);
|
||||
if (archetype.intersects(exclude)) {
|
||||
// Skip — archetype contains a banned component.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new BitSet(size)` | Allocate a bitset of `size` bits. Throws `RangeError` for non-positive integer sizes. |
|
||||
| `size` | Read-only declared bit-width. |
|
||||
| `set(bit)` / `unset(bit)` | Flip a single bit. `RangeError` on out-of-range index. |
|
||||
| `has(bit)` | Test a bit. Returns `false` for out-of-range indices. |
|
||||
| `containsAll(other)` | `true` when every bit set in `other` is also set here. Safe across mismatched word counts. |
|
||||
| `intersects(other)` | `true` when at least one bit is set in both bitsets. |
|
||||
| `equals(other)` | Structural word-by-word equality. |
|
||||
| `count()` | Population count via SWAR. |
|
||||
| `clone()` | Deep copy with the same size. |
|
||||
| `clear()` | Reset every bit to zero. |
|
||||
| `toKey()` | Comma-separated string of set indices, ascending. |
|
||||
| `[Symbol.iterator]()` | Yield set indices in ascending order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Words are accessed with `>>> 5` (divide by 32) and `& 31` (mod 32). Using bitwise math keeps everything in the 32-bit-integer fast path.
|
||||
- `containsAll` walks each word in lock-step and treats trailing words of `other` as zero when `this` is shorter — this lets the world build mask-only diffs between archetypes without forcing both sides to the same allocation size.
|
||||
- `count` is the classic 5-step parallel bit-population trick, fast and branch-free.
|
||||
- `toKey` produces a stable, comparable string used as the archetype-graph map key. It is O(size) but only paid once per archetype-create event.
|
||||
- The iterator is a generator; `for (const bit of set)` consumes it lazily.
|
||||
@@ -0,0 +1,77 @@
|
||||
# ComponentRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ComponentRegistry` translates `ComponentSchema` references into stable integer IDs. These IDs are used as bit positions in archetype bitsets, and registering a schema is the first step before it can be added to any entity. The registry enforces three contracts: identity-stable registration (same schema → same ID), name-uniqueness (no two schemas may share a `name`), and a hard cap on how many distinct types can be registered (`maxComponents`, default 256, matching the bitset's typical 8-word allocation).
|
||||
|
||||
The registry is owned by `World` but exposed publicly so editors, profilers, and serialisation layers can iterate every registered type.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `register` — passing the same schema reference returns the same ID.
|
||||
- Throws structured `EcsError` with stable codes for every failure mode (`COMPONENT_NAME_CONFLICT`, `COMPONENT_LIMIT_REACHED`, `COMPONENT_NOT_REGISTERED`).
|
||||
- Bi-directional lookup: schema → ID, ID → schema, name → schema.
|
||||
- `maxComponents` cap configured at construction; validates positive integer.
|
||||
- `count`, `has`, and `schemas()` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ComponentRegistry, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const registry = new ComponentRegistry();
|
||||
const positionId = registry.register(Position);
|
||||
|
||||
console.log(positionId); // 0
|
||||
console.log(registry.register(Position) === positionId); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ComponentRegistry,
|
||||
defineComponent,
|
||||
ECS_CODES,
|
||||
EcsError,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const registry = new ComponentRegistry(4);
|
||||
registry.register(defineComponent("A", {}));
|
||||
registry.register(defineComponent("B", {}));
|
||||
registry.register(defineComponent("C", {}));
|
||||
registry.register(defineComponent("D", {}));
|
||||
|
||||
try {
|
||||
registry.register(defineComponent("E", {}));
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.COMPONENT_LIMIT_REACHED) {
|
||||
console.warn("raise maxComponents in WorldConfig");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new ComponentRegistry(maxComponents?)` | Default `maxComponents` is 256. Rejects non-positive integers. |
|
||||
| `register(schema)` | Return stable ID. Throws `ECS.COMPONENT_NAME_CONFLICT` / `ECS.COMPONENT_LIMIT_REACHED`. |
|
||||
| `getId(schema)` | Return ID, or throw `ECS.COMPONENT_NOT_REGISTERED`. |
|
||||
| `getSchema(id)` / `getSchemaByName(name)` | Lookups; return `undefined` when missing. |
|
||||
| `has(schema)` | Boolean membership. |
|
||||
| `count` | Number of registered schemas. |
|
||||
| `schemas()` | Read-only snapshot in registration order. |
|
||||
| `maxComponents` | Hard cap configured at construction. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Three maps back the registry — schema → id, id → schema, name → schema. The slight memory overhead pays off in O(1) lookups across all three directions.
|
||||
- IDs are dense and monotonically increasing starting at `0`. They are intentionally not stable across processes: the same schema registered second in process A and first in process B yields different IDs. Persistence layers use `schema.name`, never the ID.
|
||||
- Name uniqueness is enforced via the dedicated `nameToSchema` map; this catches the most common bug — two `defineComponent("Position", …)` calls in different modules — at registration time rather than at the first archetype-create.
|
||||
- The `maxComponents` cap is also the bitset size for archetype masks. Exceeding it would mean an archetype's mask runs off the end of its `Uint32Array`, so the limit is hard, not advisory.
|
||||
71
docs/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
71
docs/gaming/games/ecs/src/entity-pool/entity-pool.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# EntityPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EntityPool` is the entity-ID allocator. It hands out positive integer IDs on `allocate()`, recycles freed IDs on `free()`, and tracks a per-ID generation counter so callers can detect stale references to a recycled slot. The pool keeps the live-ID range dense, which is important because every alive entity occupies a bitset slot and dense IDs limit the working-set footprint.
|
||||
|
||||
The pool is owned by `World`; the class is exported only so debug tooling can read its state.
|
||||
|
||||
## Features
|
||||
|
||||
- Recycle-first allocation: freed IDs are returned ahead of fresh ones.
|
||||
- Per-ID generation counter — increments on every allocation, including reuse.
|
||||
- O(1) `allocate`, `free`, `isAlive`, and `generation`.
|
||||
- `peekNextId` / `setNextId` enable snapshot/restore of the underlying counter.
|
||||
- `clear()` resets every counter and free-list back to the initial state.
|
||||
- Defensive `setNextId` — rejects non-positive integers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate(); // 1
|
||||
const b = pool.allocate(); // 2
|
||||
pool.free(a);
|
||||
const recycled = pool.allocate(); // 1 — freed id comes back first
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { EntityPool } from "@suaveplan/ecs";
|
||||
|
||||
const pool = new EntityPool();
|
||||
const a = pool.allocate();
|
||||
const generationAtSpawn = pool.generation(a); // 1
|
||||
|
||||
pool.free(a);
|
||||
pool.allocate(); // recycles `a`
|
||||
const generationNow = pool.generation(a); // 2
|
||||
|
||||
// External handle compares its captured generation against the current one
|
||||
// to detect stale references to a slot that has since been recycled.
|
||||
const handleIsStale = generationAtSpawn !== generationNow;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `allocate()` | Return a fresh or recycled positive integer. Increments the entity's generation. |
|
||||
| `free(entity)` | Move `entity` to the recycled list. Returns `false` for non-alive IDs. |
|
||||
| `isAlive(entity)` | True when `entity` is currently allocated. |
|
||||
| `generation(entity)` | Generation counter. Zero for never-allocated IDs. |
|
||||
| `aliveCount` / `recycledCount` | Inspectable counts. |
|
||||
| `peekNextId` | Next fresh ID that would be assigned. |
|
||||
| `setNextId(id)` | Restore the counter from a snapshot. Rejects non-positive integers. |
|
||||
| `getAlive()` | Read-only snapshot of the live set. |
|
||||
| `clear()` | Reset every counter. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The free-list is a plain `Entity[]` used as a LIFO stack. LIFO maximises cache reuse: the most recently freed slot is the first one re-handed, so it is still likely to be hot.
|
||||
- The generation map is keyed by entity ID, not slot index, because IDs are themselves dense — using the ID as the key keeps the data structure simple.
|
||||
- `peekNextId` returns the counter even after `clear()` (which resets it to `1`). The restore path uses this to rebuild the world's counter before re-spawning entities.
|
||||
- The pool never reuses an ID that was returned by a fresh allocation but never freed — so an `aliveCount` of zero plus `recycledCount` of zero is enough to prove no stale handles exist.
|
||||
83
docs/gaming/games/ecs/src/errors/ecs-error.md
Normal file
83
docs/gaming/games/ecs/src/errors/ecs-error.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# EcsError & ECS_CODES
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`EcsError` is the typed error class for every failure mode raised by `@suaveplan/ecs`. It extends `SuaveplanError` from `@suaveplan/error`, which means every thrown error carries a stable, fully-qualified `code` string and a structured `context` object documenting the inputs at the failure site. Callers can discriminate failures from this package either by `instanceof EcsError` or by inspecting `error.code`.
|
||||
|
||||
`ECS_CODES` is the canonical registry of every code this package raises. The registry is generated through `defineCodes("ECS", [...])` from `@suaveplan/error`, which fully-qualifies the string literals (e.g., `"ECS.ENTITY_NOT_ALIVE"`) and exposes them as a frozen object.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable, fully-qualified codes — `ECS.ENTITY_NOT_ALIVE`, `ECS.COMPONENT_NOT_REGISTERED`, `ECS.COMPONENT_NAME_CONFLICT`, `ECS.COMPONENT_LIMIT_REACHED`, `ECS.COMPONENT_VALIDATION_FAILED`, `ECS.SYSTEM_NAME_CONFLICT`, `ECS.SNAPSHOT_INVALID`.
|
||||
- Every code is exercised by `src/errors/ecs-error.test.ts` from a real production code path — registry-to-throw-site coverage is a test invariant.
|
||||
- Structured `context` field on every error (`{ entity }`, `{ name }`, `{ issues }`, etc.) so the wire-redacted error envelope remains useful for debugging.
|
||||
- Cause chain preserved through `SuaveplanError`'s base support.
|
||||
- Code type `EcsCode` is a literal union narrowable in `switch` statements.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.despawn(entity);
|
||||
|
||||
try {
|
||||
world.addComponent(entity, Position);
|
||||
} catch (err) {
|
||||
if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
|
||||
console.error("entity was already despawned");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ECS_CODES, EcsError } from "@suaveplan/ecs";
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof EcsError)) return false;
|
||||
switch (err.code) {
|
||||
case ECS_CODES.ENTITY_NOT_ALIVE:
|
||||
case ECS_CODES.SNAPSHOT_INVALID:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_VALIDATION_FAILED:
|
||||
return false;
|
||||
case ECS_CODES.COMPONENT_LIMIT_REACHED:
|
||||
case ECS_CODES.COMPONENT_NAME_CONFLICT:
|
||||
case ECS_CODES.COMPONENT_NOT_REGISTERED:
|
||||
case ECS_CODES.SYSTEM_NAME_CONFLICT:
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Description |
|
||||
|---|---|
|
||||
| `EcsError` | `SuaveplanError`-extending class. Constructor: `new EcsError(code, messageKey, options?)`. |
|
||||
| `EcsCode` | Type union over every registered code string. |
|
||||
| `ECS_CODES` | Frozen registry returned by `defineCodes`. |
|
||||
| `ECS_CODES.ENTITY_NOT_ALIVE` | Thrown from `addComponent` / `removeComponent` / `setComponent` for despawned entities. |
|
||||
| `ECS_CODES.COMPONENT_NOT_REGISTERED` | `registry.getId(schema)` on an unknown schema. |
|
||||
| `ECS_CODES.COMPONENT_NAME_CONFLICT` | Two distinct schemas sharing a `name`. |
|
||||
| `ECS_CODES.COMPONENT_LIMIT_REACHED` | `register` past `maxComponents`. |
|
||||
| `ECS_CODES.COMPONENT_VALIDATION_FAILED` | Zod validator rejected merged component data. |
|
||||
| `ECS_CODES.SYSTEM_NAME_CONFLICT` | `addSystem` with a name already registered. |
|
||||
| `ECS_CODES.SNAPSHOT_INVALID` | `restore` payload failed `WorldSnapshotSchema`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes are namespaced under `ECS.` so cross-package error log aggregation can filter by the `ECS.*` prefix.
|
||||
- `EcsError` does not override `Error.prototype.toJSON` — `SuaveplanError` already exposes `toWire` / `toSpanEvent` for serialisation, and we don't extend the contract.
|
||||
- The test suite enumerates `Object.values(ECS_CODES)` and triggers every code through a production call. If a new code is added without a real throw site, the test will fail — this is the registered-but-unthrown drift guard required by the spec-conformance checklist (`AGENTS.md` §13).
|
||||
65
docs/gaming/games/ecs/src/observability/otel.md
Normal file
65
docs/gaming/games/ecs/src/observability/otel.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# OpenTelemetry Instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instruments used by `@suaveplan/ecs`. There are two metrics — `ecs.entity.lifecycle_total` (counter) and `ecs.world.execute_duration_ms` (histogram) — plus the helper functions `recordLifecycle` and `recordExecuteDuration` that the world calls from its mutation paths. Instruments are bound to a single meter (`@suaveplan/ecs`) resolved through the global `MeterProvider` at module load.
|
||||
|
||||
When no SDK is registered (e.g. plain production deploys without an OTel collector), the OTel API returns a no-op meter and recording is free. When tests register an in-memory `MeterProvider` (`tests/setup/polyfills.ts`), the same instruments emit real data points readable through the test exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `Counter` (`ecs.entity.lifecycle_total`) keyed by `op ∈ {spawn, despawn}`.
|
||||
- Single `Histogram` (`ecs.world.execute_duration_ms`) in milliseconds.
|
||||
- Stable meter name `@suaveplan/ecs` for filtering in dashboards.
|
||||
- Helper functions wrap `.add` / `.record` so call sites stay terse.
|
||||
- No SDK dependency at runtime — `@opentelemetry/api` is the only import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordLifecycle, recordExecuteDuration } from "@suaveplan/ecs";
|
||||
|
||||
recordLifecycle("spawn");
|
||||
recordExecuteDuration(0.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
|
||||
import { createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
|
||||
|
||||
const world = createWorld();
|
||||
world.spawn();
|
||||
world.execute(0.016);
|
||||
|
||||
// later
|
||||
const snapshots = await /* meterProvider */;
|
||||
// Inspect the spawn / despawn counter and the execute duration histogram.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `entityLifecycleCounter` | Raw `Counter` instrument. Attribute `op ∈ {spawn, despawn}`. |
|
||||
| `executeDurationHistogram` | Raw `Histogram` instrument; unit `"ms"`. |
|
||||
| `recordLifecycle(op)` | Increment the counter for a spawn or despawn. |
|
||||
| `recordExecuteDuration(durationMs)` | Record a single execute-tick duration. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter is resolved at module load via `metrics.getMeter("@suaveplan/ecs")`. The OTel API guarantees a stable meter reference: when a real SDK is later registered, *previously created* instruments continue to emit through the new provider. This is the documented "no-op-then-bind" pattern.
|
||||
- The histogram unit is `"ms"`, matching `performance.now()`. The world records the wall-clock difference between the start and end of `execute(dt)`.
|
||||
- Counter attributes are keyed by `op`, not by code-path, so dashboards can sum spawn + despawn as a single series or split per-op trivially.
|
||||
- Tests should reset the in-memory exporter between cases (`testInMemoryMetricExporter.reset()`) to avoid cross-test bleed; the package's `world.spec-conformance.test.ts` does this in an `afterEach` hook.
|
||||
- No spans are created here — the histogram is sufficient for measuring execute duration, and span-creation overhead would dominate the metric it's meant to observe. If consumers want per-system spans, they can wrap `execute` in their own tracer.
|
||||
84
docs/gaming/games/ecs/src/query/query.md
Normal file
84
docs/gaming/games/ecs/src/query/query.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Query
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`Query` compiles a declarative `QueryDescriptor` (`include` / optional `exclude` schemas) into bitset masks and evaluates it against the archetype graph. Because archetypes group entities by their exact component set, a query only needs to test each archetype's mask once — every entity inside a matching archetype is automatically a match. The result is O(archetypes) filtering plus O(entities-in-matching-archetypes) collection.
|
||||
|
||||
`defineQuery` is the canonical factory; raw `Query` instances are exposed for advanced consumers building custom system runners.
|
||||
|
||||
## Features
|
||||
|
||||
- Bitset-only match predicate (`containsAll` + `intersects`).
|
||||
- Cached `matchedArchetypes` list, invalidated by `World` whenever archetypes change.
|
||||
- Handles include-only, include + exclude, and empty-exclude descriptors cleanly.
|
||||
- Result is a flat `ReadonlyArray<Entity>` in archetype-discovery order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent, defineQuery, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
world.addComponent(entity, Position);
|
||||
|
||||
const result = world.query(defineQuery([Position]));
|
||||
console.log([...result]); // [1]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineComponent,
|
||||
defineQuery,
|
||||
Query,
|
||||
createWorld,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", {});
|
||||
const Disabled = defineComponent("Disabled", {});
|
||||
|
||||
const world = createWorld();
|
||||
world.registerComponent(Position);
|
||||
world.registerComponent(Disabled);
|
||||
|
||||
// Build a long-lived Query and re-evaluate on every tick.
|
||||
const activeMovers = new Query(
|
||||
defineQuery([Position], [Disabled]),
|
||||
world.registry,
|
||||
);
|
||||
|
||||
function tick() {
|
||||
const entities = activeMovers.evaluate(
|
||||
// archetype graph is exposed indirectly via world.query; advanced
|
||||
// consumers run their own archetype scans through reflection.
|
||||
[],
|
||||
);
|
||||
// ... iterate entities ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new Query(descriptor, registry)` | Compile masks from a descriptor. Throws if descriptor references unregistered schemas. |
|
||||
| `defineQuery(include, exclude?)` | Build a descriptor with optional `exclude`. |
|
||||
| `matches(archetype)` | Predicate: does this archetype's mask satisfy the query? |
|
||||
| `evaluate(archetypes)` | Collect every entity from every matching archetype. Result is cached until `invalidate()`. |
|
||||
| `invalidate()` | Drop the cached match list; the next `evaluate` rebuilds it. |
|
||||
| `includeMask` / `excludeMask` | Read-only masks for debug / inspection. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `excludeMask` is `null` (not an empty `BitSet`) when no exclude is declared, so the predicate skips the `intersects` test entirely in the common case.
|
||||
- Empty `exclude: []` is treated as "no exclude" — the descriptor normalises the difference.
|
||||
- The world owns its `Query` objects; one-off `world.query(descriptor)` calls build an ad-hoc `Query` per call and discard it. Long-lived systems should pin a `Query` instance and call `evaluate` repeatedly.
|
||||
- The cached `matchedArchetypes` list is invalidated whenever the archetype graph mutates (component add/remove that creates a new archetype, or `clear`). Mutations within an existing archetype do not invalidate — entity lists are read fresh on every evaluate.
|
||||
- Iteration order is "every entity in archetype 0, then every entity in archetype 1, …". Within an archetype the order matches insertion order (subject to swap-remove side-effects).
|
||||
65
docs/gaming/games/ecs/src/schema/schema.md
Normal file
65
docs/gaming/games/ecs/src/schema/schema.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# defineComponent
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`defineComponent` is the canonical factory for `ComponentSchema` objects. It produces a deep-frozen descriptor pairing a stable `name`, a frozen `defaults` value, and an optional Zod validator. The descriptor is the unit the registry, world, and systems treat as a component's public identity — passing the same descriptor reference through the API is what proves two call sites mean the same component.
|
||||
|
||||
The optional Zod validator opts the descriptor into runtime validation: `World.addComponent` and `World.setComponent` will reject component data that fails the schema with `ECS.COMPONENT_VALIDATION_FAILED`, capturing the Zod issues in `error.context`.
|
||||
|
||||
## Features
|
||||
|
||||
- Deep-freeze on the descriptor and its `defaults` — strict-mode mutation throws.
|
||||
- Defaults are *copied* on construction, so caller mutations after `defineComponent` don't leak into the descriptor.
|
||||
- Optional `schema?: z.ZodType<T>` validator threaded through to the world's validation step.
|
||||
- specific addition over upstream's `defineComponent` — upstream lacked the Zod hook.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
|
||||
console.log(Position.name); // "Position"
|
||||
console.log(Position.defaults); // { x: 0, y: 0, z: 0 } (frozen)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { defineComponent, createWorld } from "@suaveplan/ecs";
|
||||
|
||||
const Health = defineComponent(
|
||||
"Health",
|
||||
{ current: 100, max: 100 },
|
||||
z.object({
|
||||
current: z.number().nonnegative(),
|
||||
max: z.number().positive(),
|
||||
}),
|
||||
);
|
||||
|
||||
const world = createWorld();
|
||||
const entity = world.spawn();
|
||||
// Throws EcsError(code = ECS.COMPONENT_VALIDATION_FAILED)
|
||||
world.addComponent(entity, Health, { current: -1, max: 100 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Signature | Description |
|
||||
|---|---|
|
||||
| `defineComponent<T>(name, defaults, schema?)` | Build a frozen `ComponentSchema<T>` with optional Zod validator. |
|
||||
| `ComponentSchema.name` | Stable string identity. |
|
||||
| `ComponentSchema.defaults` | Frozen default value, deep-copied. |
|
||||
| `ComponentSchema.schema?` | Optional Zod validator for runtime checks inside `World.addComponent` / `World.setComponent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The defaults clone is shallow (`{ ...defaults }`). For nested mutable references, callers should freeze deeply themselves before passing in. The component schema is identity-stable, but interior mutability of a passed reference is out of scope.
|
||||
- The schema property is added conditionally (omitted from the object when not supplied) so JSON serialisation of a descriptor produces a clean two-key shape when no validator is supplied.
|
||||
- Validation is *not* applied at registration — only at component-add / component-set boundaries. This keeps the registry decoupled from Zod and means a schema can be retrofitted onto an existing component without rewiring registration.
|
||||
- The factory does not generate IDs; the registry assigns those on first registration via `World.registerComponent` or implicitly during `addComponent`.
|
||||
97
docs/gaming/games/ecs/src/system/system.md
Normal file
97
docs/gaming/games/ecs/src/system/system.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# System & SystemScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A *system* is the unit of behaviour in an ECS — a function that runs over every entity matching a query and mutates component state. `defineSystem` is the factory that fills in sensible defaults (`phase: "update"`, `priority: 0`, `enabled: true`) and produces a frozen `SystemDefinition`. `SystemScheduler` is the world's internal sorted registry: it orders systems by phase then priority, lazily resorting only when the set changes.
|
||||
|
||||
Phase ordering is fixed at `preUpdate → update → postUpdate → render`, mirroring the canonical game-loop pipeline. Priority breaks ties within a phase, lowest first.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen-by-default `SystemDefinition`s with sensible defaults.
|
||||
- `enabled: false` lets systems be added but skipped on `execute` — useful for runtime toggling.
|
||||
- Stable phase order: `preUpdate < update < postUpdate < render`.
|
||||
- Insertion order is the implicit tiebreaker when priority is equal (Array#sort is stable in modern engines).
|
||||
- `has(name)` membership check used by the world to enforce unique system names.
|
||||
- `count` / `getAll` accessors for inspection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const pos = world.getComponent(entity, Position)!;
|
||||
const vel = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: pos.x + vel.dx * dt,
|
||||
y: pos.y + vel.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defineSystem, SystemScheduler } from "@suaveplan/ecs";
|
||||
|
||||
const scheduler = new SystemScheduler();
|
||||
scheduler.add(defineSystem({
|
||||
name: "input",
|
||||
phase: "preUpdate",
|
||||
priority: 0,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "physics",
|
||||
priority: 10,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
scheduler.add(defineSystem({
|
||||
name: "gameplay",
|
||||
priority: 5,
|
||||
query: { include: [] },
|
||||
execute: () => undefined,
|
||||
}));
|
||||
|
||||
for (const sys of scheduler.getOrdered()) {
|
||||
// order: input, gameplay, physics
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `defineSystem(options)` | Build a `SystemDefinition` with phase / priority / enabled defaults. |
|
||||
| `SystemScheduler#add(system)` | Register a system. Marks scheduler dirty. |
|
||||
| `SystemScheduler#remove(name)` | Remove by name. Returns `false` for unknowns. |
|
||||
| `SystemScheduler#has(name)` | Membership check. |
|
||||
| `SystemScheduler#getOrdered()` | Read-only execution-ordered snapshot. |
|
||||
| `SystemScheduler#getAll()` | Read-only unordered snapshot. |
|
||||
| `SystemScheduler#count` | Number of registered systems. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getOrdered` sorts lazily — the `sorted` flag clears on add/remove and is re-set after the first sort. The world is therefore free to call it once per tick without paying repeated sort cost when no mutation occurred.
|
||||
- `defineSystem` returns a plain object, not a frozen one — performance-sensitive call sites can mutate fields like `enabled` to gate execution at runtime without rebuilding the definition. The world contract still treats it as immutable.
|
||||
- The `query` field references `ComponentSchema` objects, not IDs. The world resolves them through its registry on `addSystem`, registering any not-yet-known schemas at that point.
|
||||
- The phase order is encoded as a numeric lookup table (`PHASE_ORDER`), which keeps the sort key access branch-free.
|
||||
84
docs/gaming/games/ecs/src/types/ecs.types.md
Normal file
84
docs/gaming/games/ecs/src/types/ecs.types.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# ECS Types
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the canonical home for every type alias and interface in `@suaveplan/ecs`. Keeping the type vocabulary in a single file makes the surface easy to scan and lets consumers import every public shape from `@suaveplan/ecs/types` (or the package root) without learning the internal folder layout. The file is types-only — no runtime exports, no side effects — which keeps the build output empty and tree-shake-safe.
|
||||
|
||||
The types fall into three groups: identity primitives (`Entity`, `ComponentId`, `ComponentData`, `ArchetypeId`), descriptor shapes (`ComponentSchema`, `QueryDescriptor`, `SystemDefinition`), and persistence/event shapes (`EntitySnapshot`, `WorldSnapshot`, `WorldEventMap`, `WorldApi`).
|
||||
|
||||
## Features
|
||||
|
||||
- Branded-style integer aliases (`Entity`, `ComponentId`) for documentation without runtime cost.
|
||||
- `ComponentSchema<T>` is generic over the component data shape so `World.getComponent` infers the right return type from a descriptor reference.
|
||||
- `WorldEventMap` extends `Record<string, unknown>` so the `@suaveplan/events` `EventBus<TMap>` type accepts it directly while still typing each known event payload.
|
||||
- `WorldApi` is the contract `World` implements — systems consume it through `SystemContext.world` to avoid coupling system code to the full `World` class surface.
|
||||
- `SystemPhase` is a string-literal union mirroring the scheduler's fixed phase order.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Entity,
|
||||
ComponentSchema,
|
||||
QueryDescriptor,
|
||||
} from "@suaveplan/ecs";
|
||||
|
||||
interface Position { x: number; y: number }
|
||||
|
||||
const Position: ComponentSchema<Position> = {
|
||||
name: "Position",
|
||||
defaults: Object.freeze({ x: 0, y: 0 }),
|
||||
};
|
||||
|
||||
const query: QueryDescriptor = { include: [Position] };
|
||||
const entity: Entity = 1;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SystemDefinition, SystemContext, WorldEventMap } from "@suaveplan/ecs";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus<WorldEventMap>();
|
||||
bus.on("componentAdded", (payload) => {
|
||||
// payload is fully typed: { entity: number; component: string }
|
||||
console.log(payload.entity, payload.component);
|
||||
});
|
||||
|
||||
function makeSystem(execute: SystemDefinition["execute"]): SystemDefinition {
|
||||
return {
|
||||
name: "custom",
|
||||
phase: "update",
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
query: { include: [] },
|
||||
execute,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `Entity` | Positive integer ID. |
|
||||
| `ComponentId` | Stable integer assigned by `ComponentRegistry`. |
|
||||
| `ComponentData` | `Record<string, unknown>` — generic component payload. |
|
||||
| `ArchetypeId` | Sorted-comma-separated component-ID string. |
|
||||
| `ComponentSchema<T>` | `{ name, defaults, schema? }` descriptor. |
|
||||
| `QueryDescriptor` | `{ include, exclude? }` declarative query. |
|
||||
| `SystemPhase` | `"preUpdate" \| "update" \| "postUpdate" \| "render"`. |
|
||||
| `SystemDefinition` | Frozen system contract used by the scheduler. |
|
||||
| `SystemContext` | Argument passed to `execute({ entities, world, dt })`. |
|
||||
| `SystemExecuteFn` | Type alias for the system execute callback. |
|
||||
| `EntitySnapshot` / `WorldSnapshot` | Persistence wire shapes. |
|
||||
| `WorldApi` | World surface exposed to systems. |
|
||||
| `WorldEventMap` | Single-object event payload map for `world.events`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All types use TypeScript-level branding (`type Entity = number`) rather than full nominal branding (`{ __brand: ... }`). The full-brand approach adds friction (`entity as Entity` everywhere) without much payoff for an integer that originates exclusively inside the package.
|
||||
- `ComponentSchema.schema?` is a `z.ZodType<T>`, optional at the type level so descriptors without a validator skip a property entirely. The world's validation step only fires when the property is defined.
|
||||
- `WorldEventMap`'s `[key: string]: unknown` index signature is required by `@suaveplan/events`' `EventMap = Record<string, unknown>` constraint. Known events keep their precise object shape; extra keys can be defined by consumers without leaking through.
|
||||
- The file deliberately avoids importing from any other file in the package — it is a pure leaf at the bottom of the dep graph. The single external import is `zod` for `z.ZodType<T>` in `ComponentSchema.schema`.
|
||||
108
docs/gaming/games/ecs/src/world/world.md
Normal file
108
docs/gaming/games/ecs/src/world/world.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# World
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`World` is the central container for an ECS instance. It owns the component registry, entity pool, archetype graph, query cache, system scheduler, and the typed `EventBus` that fires entity-lifecycle events. Every public mutator is instrumented with OpenTelemetry — `spawn` / `despawn` increment a counter, `execute` records into a histogram — so production behaviour is observable through the same in-memory `MeterProvider` used in tests.
|
||||
|
||||
The `createWorld(config?)` factory is the canonical entry point. `World` itself is exported so consumers can subclass or interrogate the instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Archetype-graph mutations: `addComponent` / `removeComponent` move entities between archetypes lazily.
|
||||
- Idempotent `addComponent` — no-op when the component already exists.
|
||||
- Component validation via descriptor-attached Zod schemas; throws `ECS.COMPONENT_VALIDATION_FAILED` on rejection.
|
||||
- System scheduling with phase + priority ordering through `SystemScheduler`.
|
||||
- `snapshot` / `restore` round-trip with Zod-validated payloads.
|
||||
- Typed `EventBus<WorldEventMap>` — single-object payloads matching the `@suaveplan/events` shape.
|
||||
- OpenTelemetry counter (`ecs.entity.lifecycle_total`) and histogram (`ecs.world.execute_duration_ms`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent, defineSystem } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 1, dy: 0 });
|
||||
|
||||
const world = createWorld();
|
||||
|
||||
world.addSystem(defineSystem({
|
||||
name: "physics",
|
||||
query: { include: [Position, Velocity] },
|
||||
execute: ({ entities, world, dt }) => {
|
||||
for (const entity of entities) {
|
||||
const p = world.getComponent(entity, Position)!;
|
||||
const v = world.getComponent(entity, Velocity)!;
|
||||
world.setComponent(entity, Position, {
|
||||
x: p.x + v.dx * dt,
|
||||
y: p.y + v.dy * dt,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const player = world.spawn();
|
||||
world.addComponent(player, Position, { x: 0, y: 0 });
|
||||
world.addComponent(player, Velocity, { dx: 5, dy: 0 });
|
||||
world.execute(1 / 60);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWorld, defineComponent } from "@suaveplan/ecs";
|
||||
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
const world = createWorld({ maxComponents: 128 });
|
||||
world.registerComponent(Position);
|
||||
|
||||
world.events.on("entitySpawned", ({ entity }) => {
|
||||
console.log("spawned", entity);
|
||||
});
|
||||
world.events.on("componentAdded", ({ entity, component }) => {
|
||||
console.log(component, "->", entity);
|
||||
});
|
||||
|
||||
const snapshot = world.snapshot();
|
||||
// ... ship snapshot over the wire ...
|
||||
|
||||
const replay = createWorld();
|
||||
replay.registerComponent(Position);
|
||||
replay.restore(snapshot);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `createWorld(config?)` / `new World(config)` | Build a world. `maxComponents` defaults to 256. |
|
||||
| `events` | `EventBus<WorldEventMap>` for entity lifecycle. |
|
||||
| `registry` | Underlying `ComponentRegistry`. |
|
||||
| `registerComponent(schema)` | Pre-register a schema (idempotent). |
|
||||
| `spawn()` | Allocate an entity and emit `entitySpawned`. |
|
||||
| `despawn(entity)` | Despawn and recycle the entity. No-op when unknown. |
|
||||
| `isAlive(entity)` | Boolean liveness check. |
|
||||
| `addComponent(entity, schema, data?)` | Attach a component; validates Zod-attached schemas. |
|
||||
| `removeComponent(entity, schema)` | Detach a component. |
|
||||
| `getComponent(entity, schema)` | Read a component (or `undefined`). |
|
||||
| `setComponent(entity, schema, data)` | Merge partial data; revalidates. |
|
||||
| `hasComponent(entity, schema)` | Membership check. |
|
||||
| `query(descriptor)` | One-off entity query. |
|
||||
| `addSystem(system)` / `removeSystem(name)` | System lifecycle. |
|
||||
| `execute(dt)` | Run every enabled system; records duration histogram. |
|
||||
| `snapshot()` / `restore(snap)` | Persistence pair. `restore` Zod-validates the input. |
|
||||
| `clear()` | Wipe entities + archetypes, keep systems. |
|
||||
| `entityCount` / `archetypeCount` / `getSystems()` | Inspection accessors. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Archetype identity is the bitset key (`mask.toKey()`); `getOrCreateArchetype` deduplicates archetypes through a `Map<string, Archetype>`.
|
||||
- `addComponent` and `removeComponent` go through a swap-remove from the old archetype, mutate the component map, then insert into the new archetype. The destination archetype is materialised lazily.
|
||||
- `setComponent` does **not** change archetype membership — it merges into the existing column slot and re-validates against the descriptor's Zod schema.
|
||||
- `restore` is the only persistence boundary that validates input with Zod (`WorldSnapshotSchema`). Failures throw `ECS.SNAPSHOT_INVALID` with the Zod issues in `context`. Unknown component names in the snapshot are silently skipped, so adding new component types to consumer code does not break older snapshots.
|
||||
- OTel instruments resolve through the global `MeterProvider`. In tests, the package's `tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before any module imports so the instruments bind to it.
|
||||
- The world's `EventBus` uses single-object payloads (`{ entity }`, `{ entity, component }`) consistent with the `@suaveplan/events` contract, rather than upstream's tuple shape.
|
||||
Reference in New Issue
Block a user