21 KiB
Capability Spec Delta: ecs (enterprise-ecs)
Package: @suaveplan/ecs
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time (openspec/AGENTS.md§13).
ADDED 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, ornavigator
Scenario: Runtime dependency allowlist is closed
- WHEN
tests/dep-graph.test.tsinspectspackage.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)returnstruefor set bits andfalsefor unset bits, including bits in higher words
Scenario: containsAll and intersects respect mismatched sizes
- WHEN
a.containsAll(b)is called whereb.size > a.size - THEN it returns
trueonly if every bit set inbis also set ina(extra words inbmust be zero)
Scenario: iterator yields set bits in ascending order
- WHEN a
BitSethas 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
ComponentSchemareference is passed toregistertwice - 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
ComponentSchemaobjects share the samename - THEN the second
registercall throwsEcsErrorwhosecode === "ECS.COMPONENT_NAME_CONFLICT"andcontextincludes the conflicting name
Scenario: exceeding maxComponents throws ECS.COMPONENT_LIMIT_REACHED
- WHEN a registry constructed with
maxComponents: 4accepts a fifth distinct schema - THEN the fifth
registercall throwsEcsErrorwhosecode === "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()returns1, thenfree(1), thenallocate()is called again - THEN the second
allocate()returns1
Scenario: generation increments on every allocation of the same ID
- WHEN entity
1is allocated, freed, and allocated again - THEN
generation(1)returns2
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
falseandrecycledCountdoes 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 ComponentIds. 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<ComponentId, ComponentData> 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 andremoveEntity(2)is called - THEN
getEntities()returns[1, 3]in that order,getComponent(3, ...)still returns the correct data, andcount === 2
Scenario: removeEntity returns the removed component map
- WHEN
removeEntity(2)is called for an entity with components{position, velocity} - THEN it returns a
Mapwhose values are the entity's position and velocity at the moment of removal
Scenario: setComponent partially merges
- WHEN entity
1hasposition = { x: 0, y: 0 }andsetComponent(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 withoutDisabled - 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,updateand equal priority - THEN
getOrdered()returns them in the orderpreUpdate,update,render
Scenario: ties within a phase break by ascending priority
- WHEN two
update-phase systems are added with priorities5and2 - THEN
getOrdered()returns the priority-2system 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
falseandcountis 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<T> whose name, defaults, and optional schema (a z.ZodType<T>) 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.xthrows 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) })andaddComponent(entity, schema, { hp: -1 })is called - THEN it throws
EcsErrorwhosecode === "ECS.COMPONENT_VALIDATION_FAILED"andcontextcontains 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,
Positionis added, andquery({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
EcsErrorwhosecode === "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 callsrestore(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,renderandexecute(0.016)is called - THEN the systems'
executecallbacks fire inpreUpdate → update → renderorder withdt === 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
addSystemthrowsEcsErrorwhosecode === "ECS.SYSTEM_NAME_CONFLICT"andcontext.nameequals 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., negativenextEntityId) - THEN it throws
EcsErrorwhosecode === "ECS.SNAPSHOT_INVALID"with the Zod issues incontext
Scenario: every registered code is reachable
- WHEN
src/errors/ecs-error.test.tsenumeratesECS_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
Worldis constructed with an in-memoryMeterProvider, three systems are registered,execute(0.016)is called five times, and the meter is read - THEN the histogram
ecs.world.execute_duration_mshascount === 5and a finite sum
Scenario: spawn / despawn increments the lifecycle counter
- WHEN an in-memory
MeterProvideris wired and two entities are spawned then one despawned - THEN the counter
ecs.entity.lifecycle_totalhasvalue === 2forop === "spawn"andvalue === 1forop === "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<WorldEventMap> 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
entitySpawnedviaworld.events.on(...)andspawn()returns id1 - THEN the listener fires exactly once with
{ entity: 1 }
Scenario: addComponent emits componentAdded after the component is queryable
- WHEN a
componentAddedlistener queriesworld.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
BitSets of random size up to 128 - THEN
a.containsAll(b)equals the brute-force check that every bit inbis also ina, anda.intersects(b)is symmetric withb.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, equalarchetypeCount, 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"