# Capability Spec: ecs **Package**: `@suaveplan/ecs` **Status**: ACTIVE **Last delta**: `2026-05-19-enterprise-ecs` > This spec was folded from the `enterprise-ecs` change delta. Every requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. ## Requirements ### Requirement: Universal-tier purity The `@suaveplan/ecs` package SHALL be pure TypeScript that runs identically in Bun, Node, and the browser. It SHALL NOT import any `node:*` module, `process`, `Buffer`, or any browser-only or React-only global. Its runtime `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/opentelemetry`, `tslib`, and `zod`. #### Scenario: Built dist references no Node or browser globals - **WHEN** the built `dist/` is grep'd - **THEN** it MUST NOT reference `node:fs`, `node:path`, `node:crypto`, `node:os`, `process`, `Buffer`, `window`, `document`, or `navigator` #### Scenario: Runtime dependency allowlist is closed - **WHEN** `tests/dep-graph.test.ts` inspects `package.json#dependencies` - **THEN** every key is in the allowlist `{@suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/opentelemetry, tslib, zod}` — extra keys fail **Verified by:** `tests/purity.test.ts::"built dist references no Node or browser globals"` AND `tests/dep-graph.test.ts::"runtime dependencies are restricted to the declared allowlist"` --- ### Requirement: BitSet primitive The package SHALL export a `BitSet` class backed by `Uint32Array` storage. The constructor SHALL accept a positive integer `size` and allocate `Math.ceil(size / 32)` words. The class SHALL provide `set(bit)`, `unset(bit)`, `has(bit)`, `clear()`, `clone()`, `equals(other)`, `count()`, `containsAll(other)`, `intersects(other)`, `toKey()`, and a `Symbol.iterator` that yields set bit indices in ascending order. `containsAll` and `intersects` SHALL be safe against differently sized operands. #### Scenario: set / has / unset are O(1) bit operations - **WHEN** a caller sets and unsets bit indices across multiple words - **THEN** `has(bit)` returns `true` for set bits and `false` for unset bits, including bits in higher words #### Scenario: containsAll and intersects respect mismatched sizes - **WHEN** `a.containsAll(b)` is called where `b.size > a.size` - **THEN** it returns `true` only if every bit set in `b` is also set in `a` (extra words in `b` must be zero) #### Scenario: iterator yields set bits in ascending order - **WHEN** a `BitSet` has bits `{2, 31, 32, 64}` set - **THEN** `[...bitset]` returns `[2, 31, 32, 64]` **Verified by:** `src/bitset/bitset.test.ts::"set, has, and unset propagate across multiple Uint32 words"` AND `src/bitset/bitset.test.ts::"containsAll returns false when other has bits in trailing words this lacks"` AND `src/bitset/bitset.test.ts::"the iterator yields set bit indices in ascending order"` --- ### Requirement: Component registry assigns stable IDs The package SHALL export a `ComponentRegistry` class whose `register(schema)` SHALL assign a stable, monotonically increasing integer ID to a `ComponentSchema` on first registration and SHALL return the same ID on every subsequent registration of the same schema (idempotent by schema identity). `register` SHALL throw `EcsError` with code `ECS.COMPONENT_NAME_CONFLICT` if a schema with a different identity but the same `name` is already registered. `register` SHALL throw `EcsError` with code `ECS.COMPONENT_LIMIT_REACHED` once `maxComponents` distinct schemas have been registered (default 256). `getId(schema)` SHALL throw `EcsError` with code `ECS.COMPONENT_NOT_REGISTERED` when the schema is unknown. #### Scenario: register is idempotent by schema identity - **WHEN** the same `ComponentSchema` reference is passed to `register` twice - **THEN** the second call returns the ID assigned by the first call, and the registry size does not grow #### Scenario: name collision across distinct schemas throws ECS.COMPONENT_NAME_CONFLICT - **WHEN** two distinct `ComponentSchema` objects share the same `name` - **THEN** the second `register` call throws `EcsError` whose `code === "ECS.COMPONENT_NAME_CONFLICT"` and `context` includes the conflicting name #### Scenario: exceeding maxComponents throws ECS.COMPONENT_LIMIT_REACHED - **WHEN** a registry constructed with `maxComponents: 4` accepts a fifth distinct schema - **THEN** the fifth `register` call throws `EcsError` whose `code === "ECS.COMPONENT_LIMIT_REACHED"` **Verified by:** `src/component-registry/component-registry.test.ts::"register returns the same id for the same schema reference"` AND `src/component-registry/component-registry.test.ts::"register throws ECS.COMPONENT_NAME_CONFLICT for a duplicate name"` AND `src/component-registry/component-registry.test.ts::"register throws ECS.COMPONENT_LIMIT_REACHED past maxComponents"` --- ### Requirement: Entity pool recycles IDs with generation counters The package SHALL export an `EntityPool` class whose `allocate()` returns a positive integer entity ID, preferring recycled IDs over fresh ones. `free(entity)` SHALL return the ID to a recycled-list and SHALL return `false` if the entity is not currently alive. Each allocation SHALL bump a per-ID generation counter so callers can detect stale references via `generation(entity)`. `clear()` SHALL reset the pool to its initial state. #### Scenario: a freed ID is recycled on the next allocate - **WHEN** `allocate()` returns `1`, then `free(1)`, then `allocate()` is called again - **THEN** the second `allocate()` returns `1` #### Scenario: generation increments on every allocation of the same ID - **WHEN** entity `1` is allocated, freed, and allocated again - **THEN** `generation(1)` returns `2` #### Scenario: free returns false for a non-alive entity - **WHEN** `free(999)` is called for an entity that was never allocated - **THEN** the call returns `false` and `recycledCount` does not change **Verified by:** `src/entity-pool/entity-pool.test.ts::"allocate prefers a recycled id over a fresh id"` AND `src/entity-pool/entity-pool.test.ts::"generation increments on every allocation of the same id"` AND `src/entity-pool/entity-pool.test.ts::"free returns false for a non-alive entity"` --- ### Requirement: Archetype provides O(1) swap-remove column storage The package SHALL export an `Archetype` class whose constructor accepts a `BitSet` mask and a sorted readonly array of `ComponentId`s. Entities SHALL be stored dense-packed; each component column SHALL be a parallel array indexed by entity position. `removeEntity(entity)` SHALL perform a swap-remove in O(1), maintaining the dense-packing invariant by moving the last entity into the removed slot and popping the tail, and SHALL return a `Map` of the removed entity's column values. `setComponent` SHALL merge a partial update into the existing column value at the entity's index. #### Scenario: swap-remove preserves dense packing - **WHEN** entities `{1, 2, 3}` are in an archetype and `removeEntity(2)` is called - **THEN** `getEntities()` returns `[1, 3]` in that order, `getComponent(3, ...)` still returns the correct data, and `count === 2` #### Scenario: removeEntity returns the removed component map - **WHEN** `removeEntity(2)` is called for an entity with components `{position, velocity}` - **THEN** it returns a `Map` whose values are the entity's position and velocity at the moment of removal #### Scenario: setComponent partially merges - **WHEN** entity `1` has `position = { x: 0, y: 0 }` and `setComponent(1, positionId, { x: 5 })` is called - **THEN** `getComponent(1, positionId)` returns `{ x: 5, y: 0 }` **Verified by:** `src/archetype/archetype.test.ts::"removeEntity swap-removes and preserves dense packing"` AND `src/archetype/archetype.test.ts::"removeEntity returns a map of every column value at the removed slot"` AND `src/archetype/archetype.test.ts::"setComponent merges a partial update into the existing column value"` --- ### Requirement: Query matches archetypes by include / exclude masks The package SHALL export a `Query` class and a `defineQuery(include, exclude?)` factory. A query SHALL match an archetype if and only if (a) the archetype mask `containsAll` of the query's include mask AND (b) the archetype mask does not `intersects` the query's exclude mask (if any). `evaluate(archetypes)` SHALL collect entities from every matching archetype in archetype-discovery order. `invalidate()` SHALL clear the cached match list so the next `evaluate` re-scans. #### Scenario: include-only matches every superset archetype - **WHEN** a query with include `{Position}` runs against archetypes `[{Position}, {Position, Velocity}, {Velocity}]` - **THEN** it returns entities from the first two archetypes only #### Scenario: exclude filters out matching archetypes - **WHEN** a query with include `{Position}` and exclude `{Disabled}` runs against archetypes containing entities with and without `Disabled` - **THEN** entities in `{Position, Disabled}` are excluded from the result #### Scenario: invalidate forces a re-scan after archetype-graph mutation - **WHEN** a query is evaluated, a new matching archetype is created, the query is `invalidate()`-d, and the query is evaluated again - **THEN** the second evaluation includes entities from the new archetype **Verified by:** `src/query/query.test.ts::"include-only matches every superset archetype"` AND `src/query/query.test.ts::"exclude filters out archetypes whose mask intersects the exclude mask"` AND `src/query/query.test.ts::"invalidate clears the cached match list and forces a re-scan"` --- ### Requirement: System scheduler orders by phase then priority The package SHALL export `defineSystem(options)` and `SystemScheduler`. `defineSystem` SHALL default `phase` to `"update"`, `priority` to `0`, and `enabled` to `true`. `SystemScheduler.getOrdered()` SHALL return systems sorted first by `phase` in the order `preUpdate < update < postUpdate < render` and then by ascending `priority`. `remove(name)` SHALL return `false` for unknown names. #### Scenario: phases run in fixed order - **WHEN** three systems are added with phases `render`, `preUpdate`, `update` and equal priority - **THEN** `getOrdered()` returns them in the order `preUpdate`, `update`, `render` #### Scenario: ties within a phase break by ascending priority - **WHEN** two `update`-phase systems are added with priorities `5` and `2` - **THEN** `getOrdered()` returns the priority-`2` system first #### Scenario: remove returns false for unknown system name - **WHEN** `remove("nope")` is called for a name that was never added - **THEN** the call returns `false` and `count` is unchanged **Verified by:** `src/system/system.test.ts::"phases run in fixed order preUpdate update postUpdate render"` AND `src/system/system.test.ts::"ties within a phase break by ascending priority"` AND `src/system/system.test.ts::"remove returns false for unknown system name"` --- ### Requirement: defineComponent factory with optional Zod validator The package SHALL export `defineComponent(name, defaults, schema?)` returning a frozen `ComponentSchema` whose `name`, `defaults`, and optional `schema` (a `z.ZodType`) are stable across calls. The returned object and its `defaults` SHALL be deep-frozen. When a `schema` is supplied, `World.addComponent` and `World.setComponent` SHALL validate the merged-and-final component data through the schema before persisting it, and SHALL throw `EcsError` with code `ECS.COMPONENT_VALIDATION_FAILED` on failure (carrying the Zod issues in `context`). #### Scenario: defineComponent freezes the descriptor and defaults - **WHEN** `defineComponent("Position", { x: 0, y: 0 })` returns a descriptor - **THEN** assigning to `descriptor.defaults.x` throws in strict mode (descriptor and defaults are frozen) #### Scenario: invalid component data throws ECS.COMPONENT_VALIDATION_FAILED - **WHEN** a component is defined with a Zod schema `z.object({ hp: z.number().min(0) })` and `addComponent(entity, schema, { hp: -1 })` is called - **THEN** it throws `EcsError` whose `code === "ECS.COMPONENT_VALIDATION_FAILED"` and `context` contains the Zod issues **Verified by:** `src/schema/schema.test.ts::"defineComponent returns a frozen descriptor with frozen defaults"` AND `src/world/world.test.ts::"addComponent throws ECS.COMPONENT_VALIDATION_FAILED for data that fails the descriptor's Zod schema"` --- ### Requirement: World orchestrates entity, component, query, and system state The package SHALL export `createWorld(config?)` and a `World` class implementing `WorldApi`. `World` SHALL provide `spawn() → Entity`, `despawn(entity)`, `isAlive(entity)`, `registerComponent(schema)`, `addComponent`, `removeComponent`, `getComponent`, `setComponent`, `hasComponent`, `query(descriptor)`, `addSystem`, `removeSystem`, `execute(dt)`, `snapshot()`, `restore(snap)`, `clear()`, `entityCount`, `archetypeCount`, and `getSystems()`. `addComponent` to a not-alive entity SHALL throw `EcsError` with code `ECS.ENTITY_NOT_ALIVE`. `addComponent` SHALL be a no-op when the entity already has the component. `restore(snap)` SHALL invoke `clear()` then rebuild every entity and component from the snapshot — schemas referenced by snapshot component names SHALL be looked up via `getSchemaByName` and skipped silently if not registered. #### Scenario: spawn / addComponent / query reach the entity through its archetype - **WHEN** an entity is spawned, `Position` is added, and `query({include: [Position]})` is called - **THEN** the entity appears in the query result and `hasComponent(entity, Position) === true` #### Scenario: addComponent on a despawned entity throws ECS.ENTITY_NOT_ALIVE - **WHEN** an entity is spawned then despawned, and `addComponent(entity, Position)` is called - **THEN** it throws `EcsError` whose `code === "ECS.ENTITY_NOT_ALIVE"` #### Scenario: snapshot round-trip preserves entities and components - **WHEN** a world is populated, `snapshot()` is called, a new world with the same schemas registered calls `restore(snap)` - **THEN** the new world has the same entity count, the same component data per entity, and the same `nextEntityId` #### Scenario: execute runs systems in scheduler order - **WHEN** three systems are registered across `preUpdate`, `update`, `render` and `execute(0.016)` is called - **THEN** the systems' `execute` callbacks fire in `preUpdate → update → render` order with `dt === 0.016` **Verified by:** `src/world/world.test.ts::"spawn and addComponent place the entity in the matching archetype"` AND `src/world/world.test.ts::"addComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity"` AND `src/world/world.test.ts::"snapshot and restore round-trip preserves entities and components"` AND `src/world/world.test.ts::"execute runs registered systems in scheduler order"` --- ### Requirement: Structured errors with stable codes The package SHALL export `EcsError` extending `SuaveplanError` and an `ECS_CODES` registry. Every code declared in the registry SHALL be thrown from at least one production code path: `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 error SHALL carry a `context` object documenting the relevant inputs (entity id, component name, system name, schema-validation issues, etc.). #### Scenario: addSystem duplicate name throws ECS.SYSTEM_NAME_CONFLICT - **WHEN** two systems are added with the same `name` - **THEN** the second `addSystem` throws `EcsError` whose `code === "ECS.SYSTEM_NAME_CONFLICT"` and `context.name` equals the conflicting name #### Scenario: restore on a malformed snapshot throws ECS.SNAPSHOT_INVALID - **WHEN** `restore(snap)` is called with a snapshot that fails its Zod schema (e.g., negative `nextEntityId`) - **THEN** it throws `EcsError` whose `code === "ECS.SNAPSHOT_INVALID"` with the Zod issues in `context` #### Scenario: every registered code is reachable - **WHEN** `src/errors/ecs-error.test.ts` enumerates `ECS_CODES` - **THEN** for every code there exists at least one test that triggers it from a production path **Verified by:** `src/errors/ecs-error.test.ts::"every ECS_CODES entry is thrown by at least one production code path"` AND `src/world/world.test.ts::"addSystem throws ECS.SYSTEM_NAME_CONFLICT on duplicate name"` AND `src/world/world.test.ts::"restore throws ECS.SNAPSHOT_INVALID for a malformed snapshot"` --- ### Requirement: OTel instrumentation on public entrypoints The package SHALL emit OpenTelemetry signals via `@suaveplan/opentelemetry`: a counter `ecs.entity.lifecycle_total` (attribute `op` in `{spawn, despawn}`), a histogram `ecs.world.execute_duration_ms` recorded on every `execute(dt)` call, and spans on `spawn` / `despawn` / `execute` / `snapshot` / `restore`. Instrumentation SHALL accept an injectable `MeterProvider` / `TracerProvider` from `World` construction options (defaulting to the OTel API global no-op providers), so tests can use a real in-memory `MeterProvider` without mocks. #### Scenario: execute records into the duration histogram - **WHEN** `World` is constructed with an in-memory `MeterProvider`, three systems are registered, `execute(0.016)` is called five times, and the meter is read - **THEN** the histogram `ecs.world.execute_duration_ms` has `count === 5` and a finite sum #### Scenario: spawn / despawn increments the lifecycle counter - **WHEN** an in-memory `MeterProvider` is wired and two entities are spawned then one despawned - **THEN** the counter `ecs.entity.lifecycle_total` has `value === 2` for `op === "spawn"` and `value === 1` for `op === "despawn"` **Verified by:** `src/world/world.spec-conformance.test.ts::"execute records into the ecs.world.execute_duration_ms histogram via an in-memory MeterProvider"` AND `src/world/world.spec-conformance.test.ts::"spawn and despawn increment the ecs.entity.lifecycle_total counter via an in-memory MeterProvider"` --- ### Requirement: EventBus entity lifecycle events with single-object payloads The package SHALL expose `world.events`, an `EventBus` from `@suaveplan/events`. `WorldEventMap` SHALL declare events `entitySpawned`, `entityDespawned`, `componentAdded`, and `componentRemoved`. Every payload SHALL be a single object (not a tuple): `{ entity }` for spawn/despawn; `{ entity, component }` (where `component` is the component schema name) for add/remove. `World.spawn`, `World.despawn`, `World.addComponent`, and `World.removeComponent` SHALL emit the corresponding event after their state mutation, never before. #### Scenario: spawn emits entitySpawned with the new entity id - **WHEN** a caller subscribes to `entitySpawned` via `world.events.on(...)` and `spawn()` returns id `1` - **THEN** the listener fires exactly once with `{ entity: 1 }` #### Scenario: addComponent emits componentAdded after the component is queryable - **WHEN** a `componentAdded` listener queries `world.hasComponent(entity, Position)` from inside the callback - **THEN** the query returns `true` (the event fires after mutation, not before) **Verified by:** `src/world/world.spec-conformance.test.ts::"spawn emits entitySpawned with a single-object payload"` AND `src/world/world.spec-conformance.test.ts::"componentAdded fires after the component is queryable on the entity"` --- ### Requirement: Property-based invariants The package's `tests/property.test.ts` SHALL use `fast-check` to assert invariants that pure-unit tests cannot exhaustively cover: BitSet algebra (`containsAll` is reflexive and matches a brute-force scan; `intersects` is symmetric), query consistency (a query's result equals the brute-force scan of every alive entity filtered by `hasComponent`), entity-count monotonicity across spawn/despawn sequences (final `entityCount === spawns − despawns_of_alive`), and snapshot/restore round-trip equality. #### Scenario: bitset algebra matches brute-force on random masks - **WHEN** fast-check generates 100 pairs of `BitSet`s of random size up to 128 - **THEN** `a.containsAll(b)` equals the brute-force check that every bit in `b` is also in `a`, and `a.intersects(b)` is symmetric with `b.intersects(a)` #### Scenario: query result equals brute-force scan - **WHEN** fast-check generates a random world with up to 64 entities and a random include/exclude descriptor - **THEN** the query result, sorted, equals the brute-force-scan result, sorted #### Scenario: snapshot then restore preserves observable state - **WHEN** fast-check generates a random world, captures `snapshot()`, restores into a fresh world with the same schemas, and compares - **THEN** the new world has equal `entityCount`, equal `archetypeCount`, and equal component data per entity name **Verified by:** `tests/property.test.ts::"bitset algebra matches brute-force on random masks"` AND `tests/property.test.ts::"query result equals brute-force scan on random worlds"` AND `tests/property.test.ts::"snapshot then restore preserves observable state"`