Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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

View File

@@ -0,0 +1,62 @@
{
"name": "@suaveplan/ecs",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BitSet } from "../bitset/bitset.js";
import { Archetype } from "./archetype.js";
function archetypeWith(componentIds: number[], maskSize = 32): Archetype {
const mask = new BitSet(maskSize);
for (const cid of componentIds) mask.set(cid);
return new Archetype(mask, componentIds);
}
describe("Archetype", () => {
it("id matches the mask key", { timeout: 5000 }, () => {
const arch = archetypeWith([1, 3]);
expect(arch.id).toBe("1,3");
});
it("addEntity fills every column with provided data or empty object", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0, 1]);
const data = new Map<number, Record<string, unknown>>([
[0, { x: 5 }],
[1, { dx: 1 }],
]);
arch.addEntity(7, data);
expect(arch.count).toBe(1);
expect(arch.getComponent(7, 0)).toEqual({ x: 5 });
expect(arch.getComponent(7, 1)).toEqual({ dx: 1 });
});
it("addEntity defaults missing column values to empty object", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0, 1]);
arch.addEntity(1, new Map([[0, { x: 1 }]]));
expect(arch.getComponent(1, 1)).toEqual({});
});
it("removeEntity swap-removes and preserves dense packing", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
for (const id of [1, 2, 3]) {
arch.addEntity(id, new Map([[0, { v: id }]]));
}
arch.removeEntity(2);
expect(arch.count).toBe(2);
expect(arch.getEntities()).toEqual([1, 3]);
expect(arch.getComponent(3, 0)).toEqual({ v: 3 });
expect(arch.getComponent(1, 0)).toEqual({ v: 1 });
});
it("removeEntity removing the tail does not move other entries", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
for (const id of [1, 2, 3]) {
arch.addEntity(id, new Map([[0, { v: id }]]));
}
arch.removeEntity(3);
expect(arch.getEntities()).toEqual([1, 2]);
});
it("removeEntity returns a map of every column value at the removed slot", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0, 1]);
arch.addEntity(
1,
new Map([
[0, { x: 1 }],
[1, { dx: 2 }],
])
);
const removed = arch.removeEntity(1);
expect(removed?.get(0)).toEqual({ x: 1 });
expect(removed?.get(1)).toEqual({ dx: 2 });
});
it("removeEntity returns undefined for an unknown entity", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
expect(arch.removeEntity(123)).toBeUndefined();
});
it("setComponent merges a partial update into the existing column value", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
arch.addEntity(1, new Map([[0, { x: 0, y: 0 }]]));
expect(arch.setComponent(1, 0, { x: 5 })).toBe(true);
expect(arch.getComponent(1, 0)).toEqual({ x: 5, y: 0 });
});
it("setComponent returns false for unknown entity or column", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
arch.addEntity(1, new Map([[0, { x: 0 }]]));
expect(arch.setComponent(99, 0, { x: 1 })).toBe(false);
expect(arch.setComponent(1, 99, { x: 1 })).toBe(false);
});
it("hasEntity and hasComponent reflect membership", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
arch.addEntity(1, new Map([[0, {}]]));
expect(arch.hasEntity(1)).toBe(true);
expect(arch.hasEntity(2)).toBe(false);
expect(arch.hasComponent(0)).toBe(true);
expect(arch.hasComponent(1)).toBe(false);
});
it("getComponent on an unknown entity returns undefined", {
timeout: 5000,
}, () => {
const arch = archetypeWith([0]);
expect(arch.getComponent(42, 0)).toBeUndefined();
});
it("getComponentIds returns the registered ids", { timeout: 5000 }, () => {
const arch = archetypeWith([2, 5]);
expect([...arch.getComponentIds()]).toEqual([2, 5]);
});
});

View File

@@ -0,0 +1,131 @@
import type { BitSet } from "../bitset/bitset.js";
import type { ComponentData, ComponentId, Entity } from "../types/ecs.types.js";
/**
* An archetype is a unique combination of component types. All entities with
* the same component set share a single archetype, whose storage is laid out
* column-first (Struct-of-Arrays): one dense array per component.
*
* Removal is O(1) via swap-remove with the trailing entity, and the dense
* packing invariant is preserved across mutations.
*/
export class Archetype {
/** Mask of component IDs that this archetype owns. */
public readonly mask: BitSet;
/** Sorted-component-ID string used to identify and dedupe archetypes. */
public readonly id: string;
private readonly entities: Entity[] = [];
private readonly entityIndex = new Map<Entity, number>();
private readonly columns = new Map<ComponentId, ComponentData[]>();
private readonly componentIds: ReadonlyArray<ComponentId>;
public constructor(mask: BitSet, componentIds: ReadonlyArray<ComponentId>) {
this.mask = mask;
this.id = mask.toKey();
this.componentIds = componentIds;
for (const cid of componentIds) {
this.columns.set(cid, []);
}
}
/** Append an entity with its column values. */
public addEntity(
entity: Entity,
data: ReadonlyMap<ComponentId, ComponentData>
): void {
const index = this.entities.length;
this.entities.push(entity);
this.entityIndex.set(entity, index);
for (const cid of this.componentIds) {
const column = this.columns.get(cid) as ComponentData[];
column.push(data.get(cid) ?? {});
}
}
/**
* Remove `entity` via swap-remove and return its column values.
* Returns `undefined` if the entity is not present.
*/
public removeEntity(
entity: Entity
): Map<ComponentId, ComponentData> | undefined {
const index = this.entityIndex.get(entity);
if (index === undefined) return undefined;
const removed = new Map<ComponentId, ComponentData>();
const lastIndex = this.entities.length - 1;
for (const cid of this.componentIds) {
const column = this.columns.get(cid) as ComponentData[];
removed.set(cid, column[index] as ComponentData);
if (index !== lastIndex) {
column[index] = column[lastIndex] as ComponentData;
}
column.pop();
}
if (index !== lastIndex) {
const lastEntity = this.entities[lastIndex] as Entity;
this.entities[index] = lastEntity;
this.entityIndex.set(lastEntity, index);
}
this.entities.pop();
this.entityIndex.delete(entity);
return removed;
}
/** Look up a single component value for `entity`. */
public getComponent(
entity: Entity,
componentId: ComponentId
): ComponentData | undefined {
const index = this.entityIndex.get(entity);
if (index === undefined) return undefined;
return this.columns.get(componentId)?.[index];
}
/**
* Merge `data` into the existing column value for `entity` / `componentId`.
* Returns `false` if the entity or column is absent.
*/
public setComponent(
entity: Entity,
componentId: ComponentId,
data: ComponentData
): boolean {
const index = this.entityIndex.get(entity);
if (index === undefined) return false;
const column = this.columns.get(componentId);
if (column === undefined) return false;
column[index] = { ...(column[index] as ComponentData), ...data };
return true;
}
/** True when this archetype currently holds `entity`. */
public hasEntity(entity: Entity): boolean {
return this.entityIndex.has(entity);
}
/** Read-only snapshot of the archetype's entity list, in storage order. */
public getEntities(): ReadonlyArray<Entity> {
return this.entities;
}
/** Number of entities currently in this archetype. */
public get count(): number {
return this.entities.length;
}
/** True when this archetype's mask has bit `componentId`. */
public hasComponent(componentId: ComponentId): boolean {
return this.mask.has(componentId);
}
/** Read-only snapshot of the archetype's component-ID list. */
public getComponentIds(): ReadonlyArray<ComponentId> {
return this.componentIds;
}
}

View File

@@ -0,0 +1 @@
export { Archetype } from "./archetype.js";

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

View File

@@ -0,0 +1,18 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { BitSet } from "./bitset.js";
describe("BitSet — spec conformance", () => {
it("iterator yields number indices", { timeout: 5000 }, () => {
const set = new BitSet(8);
set.set(3);
const iter = set[Symbol.iterator]();
expectTypeOf(iter).toEqualTypeOf<Generator<number>>();
expect(iter.next().value).toBe(3);
});
it("size is publicly readable", { timeout: 5000 }, () => {
const set = new BitSet(128);
expectTypeOf(set.size).toEqualTypeOf<number>();
expect(set.size).toBe(128);
});
});

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BitSet } from "./bitset.js";
describe("BitSet", () => {
it("set, has, and unset propagate across multiple Uint32 words", {
timeout: 5000,
}, () => {
const set = new BitSet(96);
for (const bit of [2, 31, 32, 64, 95]) set.set(bit);
for (const bit of [2, 31, 32, 64, 95]) expect(set.has(bit)).toBe(true);
expect(set.has(33)).toBe(false);
set.unset(32);
expect(set.has(32)).toBe(false);
expect(set.has(31)).toBe(true);
expect(set.has(64)).toBe(true);
});
it("out-of-range has returns false rather than throwing", {
timeout: 5000,
}, () => {
const set = new BitSet(8);
expect(set.has(-1)).toBe(false);
expect(set.has(99)).toBe(false);
});
it("out-of-range set and unset throw RangeError", { timeout: 5000 }, () => {
const set = new BitSet(8);
expect(() => set.set(8)).toThrow(RangeError);
expect(() => set.unset(-1)).toThrow(RangeError);
expect(() => set.set(1.5)).toThrow(RangeError);
});
it("constructor rejects non-positive sizes", { timeout: 5000 }, () => {
expect(() => new BitSet(0)).toThrow(RangeError);
expect(() => new BitSet(-1)).toThrow(RangeError);
expect(() => new BitSet(1.5)).toThrow(RangeError);
});
it("containsAll returns false when other has bits in trailing words this lacks", {
timeout: 5000,
}, () => {
const small = new BitSet(32);
const big = new BitSet(96);
big.set(64);
expect(small.containsAll(big)).toBe(false);
big.unset(64);
expect(small.containsAll(big)).toBe(true);
});
it("containsAll matches the brute-force scan", { timeout: 5000 }, () => {
const a = new BitSet(64);
const b = new BitSet(64);
for (const bit of [1, 5, 10, 31, 32, 63]) a.set(bit);
for (const bit of [5, 10, 31]) b.set(bit);
expect(a.containsAll(b)).toBe(true);
b.set(2);
expect(a.containsAll(b)).toBe(false);
});
it("intersects is symmetric and detects shared bits", {
timeout: 5000,
}, () => {
const a = new BitSet(64);
const b = new BitSet(64);
a.set(7);
b.set(7);
expect(a.intersects(b)).toBe(true);
expect(b.intersects(a)).toBe(true);
b.unset(7);
expect(a.intersects(b)).toBe(false);
});
it("the iterator yields set bit indices in ascending order", {
timeout: 5000,
}, () => {
const set = new BitSet(96);
for (const bit of [64, 2, 32, 31]) set.set(bit);
expect([...set]).toEqual([2, 31, 32, 64]);
});
it("toKey produces a stable comma-separated string", {
timeout: 5000,
}, () => {
const set = new BitSet(32);
set.set(3);
set.set(1);
expect(set.toKey()).toBe("1,3");
expect(new BitSet(32).toKey()).toBe("");
});
it("clone produces an independent copy", { timeout: 5000 }, () => {
const a = new BitSet(32);
a.set(5);
const b = a.clone();
b.set(10);
expect(a.has(10)).toBe(false);
expect(b.has(5)).toBe(true);
expect(b.has(10)).toBe(true);
});
it("equals returns true for structurally identical sets including different word lengths", {
timeout: 5000,
}, () => {
const a = new BitSet(32);
const b = new BitSet(64);
a.set(5);
b.set(5);
expect(a.equals(b)).toBe(true);
expect(b.equals(a)).toBe(true);
b.set(33);
expect(a.equals(b)).toBe(false);
expect(b.equals(a)).toBe(false);
});
it("count returns the population of set bits", { timeout: 5000 }, () => {
const set = new BitSet(96);
expect(set.count()).toBe(0);
for (const bit of [0, 31, 32, 63, 64, 95]) set.set(bit);
expect(set.count()).toBe(6);
});
it("clear resets every bit", { timeout: 5000 }, () => {
const set = new BitSet(64);
set.set(5);
set.set(40);
set.clear();
expect(set.count()).toBe(0);
});
});

View File

@@ -0,0 +1,127 @@
/**
* Fixed-size bitset backed by `Uint32Array` storage for fast component-mask
* arithmetic. Used by archetypes and queries for O(words) subset/superset
* checks.
*/
export class BitSet {
private readonly data: Uint32Array;
public readonly size: number;
public constructor(size: number) {
if (!Number.isInteger(size) || size <= 0) {
throw new RangeError(
`BitSet size must be a positive integer, got ${size}`
);
}
this.size = size;
this.data = new Uint32Array(Math.ceil(size / 32));
}
/** Set the bit at `bit` to 1. */
public set(bit: number): void {
this.checkBit(bit);
const index = bit >>> 5;
const offset = bit & 31;
this.data[index] = (this.data[index] as number) | (1 << offset);
}
/** Clear the bit at `bit`. */
public unset(bit: number): void {
this.checkBit(bit);
const index = bit >>> 5;
const offset = bit & 31;
this.data[index] = (this.data[index] as number) & ~(1 << offset);
}
/** Test whether `bit` is set. */
public has(bit: number): boolean {
if (bit < 0 || bit >= this.size) return false;
const index = bit >>> 5;
const offset = bit & 31;
return ((this.data[index] as number) & (1 << offset)) !== 0;
}
/** True when this bitset has every bit set in `other`. */
public containsAll(other: BitSet): boolean {
const shared = Math.min(this.data.length, other.data.length);
for (let i = 0; i < shared; i++) {
const a = this.data[i] as number;
const b = other.data[i] as number;
if ((a & b) >>> 0 !== b) return false;
}
for (let i = shared; i < other.data.length; i++) {
if ((other.data[i] as number) !== 0) return false;
}
return true;
}
/** True when this bitset shares at least one set bit with `other`. */
public intersects(other: BitSet): boolean {
const shared = Math.min(this.data.length, other.data.length);
for (let i = 0; i < shared; i++) {
if (((this.data[i] as number) & (other.data[i] as number)) !== 0) {
return true;
}
}
return false;
}
/** Comma-separated string of set bit indices, ascending. Stable across runs. */
public toKey(): string {
const bits: number[] = [];
for (let i = 0; i < this.size; i++) {
if (this.has(i)) bits.push(i);
}
return bits.join(",");
}
/** Return a new BitSet with the same size and bits. */
public clone(): BitSet {
const copy = new BitSet(this.size);
copy.data.set(this.data);
return copy;
}
/** Structural equality — true when every word matches. */
public equals(other: BitSet): boolean {
const len = Math.max(this.data.length, other.data.length);
for (let i = 0; i < len; i++) {
const a = i < this.data.length ? (this.data[i] as number) : 0;
const b = i < other.data.length ? (other.data[i] as number) : 0;
if (a !== b) return false;
}
return true;
}
/** Population count — total number of set bits. */
public count(): number {
let total = 0;
for (let i = 0; i < this.data.length; i++) {
let v = this.data[i] as number;
v = v - ((v >>> 1) & 0x55555555);
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333);
total += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24;
}
return total;
}
/** Clear every bit. */
public clear(): void {
this.data.fill(0);
}
/** Iterate set bit indices in ascending order. */
public *[Symbol.iterator](): Generator<number> {
for (let i = 0; i < this.size; i++) {
if (this.has(i)) yield i;
}
}
private checkBit(bit: number): void {
if (!Number.isInteger(bit) || bit < 0 || bit >= this.size) {
throw new RangeError(
`bit index ${bit} out of range [0, ${this.size})`
);
}
}
}

View File

@@ -0,0 +1 @@
export { BitSet } from "./bitset.js";

View File

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

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
import { defineComponent } from "../schema/schema.js";
import { ComponentRegistry } from "./component-registry.js";
const Position = defineComponent("Position", { x: 0, y: 0 });
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
describe("ComponentRegistry", () => {
it("register returns the same id for the same schema reference", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
const id1 = reg.register(Position);
const id2 = reg.register(Position);
expect(id1).toBe(id2);
expect(reg.count).toBe(1);
});
it("register assigns dense ascending ids across schemas", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
expect(reg.register(Position)).toBe(0);
expect(reg.register(Velocity)).toBe(1);
expect(reg.count).toBe(2);
});
it("register throws ECS.COMPONENT_NAME_CONFLICT for a duplicate name", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
const dup = defineComponent("Position", { x: 1, y: 1 });
try {
reg.register(dup);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EcsError);
expect((err as EcsError).code).toBe(
ECS_CODES.COMPONENT_NAME_CONFLICT
);
expect((err as EcsError).context?.name).toBe("Position");
}
});
it("register throws ECS.COMPONENT_LIMIT_REACHED past maxComponents", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry(2);
reg.register(Position);
reg.register(Velocity);
const Extra = defineComponent("Extra", { v: 0 });
try {
reg.register(Extra);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EcsError);
expect((err as EcsError).code).toBe(
ECS_CODES.COMPONENT_LIMIT_REACHED
);
}
});
it("getId throws ECS.COMPONENT_NOT_REGISTERED for unknown schemas", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
try {
reg.getId(Position);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EcsError);
expect((err as EcsError).code).toBe(
ECS_CODES.COMPONENT_NOT_REGISTERED
);
}
});
it("getSchema and getSchemaByName resolve registered schemas", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
const id = reg.register(Position);
expect(reg.getSchema(id)).toBe(Position);
expect(reg.getSchemaByName("Position")).toBe(Position);
expect(reg.getSchema(999)).toBeUndefined();
expect(reg.getSchemaByName("Nope")).toBeUndefined();
});
it("has returns true for registered, false for unregistered", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
expect(reg.has(Position)).toBe(false);
reg.register(Position);
expect(reg.has(Position)).toBe(true);
});
it("schemas returns a snapshot in registration order", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
reg.register(Velocity);
expect(reg.schemas()).toEqual([Position, Velocity]);
});
it("constructor rejects non-positive maxComponents", {
timeout: 5000,
}, () => {
expect(() => new ComponentRegistry(0)).toThrow(RangeError);
expect(() => new ComponentRegistry(-1)).toThrow(RangeError);
expect(() => new ComponentRegistry(1.5)).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,105 @@
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
import type {
ComponentData,
ComponentId,
ComponentSchema,
} from "../types/ecs.types.js";
/**
* Stable-ID registry for component schemas. IDs are dense, monotonically
* increasing integers used as bit positions inside archetype bitsets.
*/
export class ComponentRegistry {
private nextId = 0;
private readonly schemaToId = new Map<ComponentSchema, ComponentId>();
private readonly idToSchema = new Map<ComponentId, ComponentSchema>();
private readonly nameToSchema = new Map<string, ComponentSchema>();
/** Hard cap on the number of distinct component types. Default 256. */
public readonly maxComponents: number;
public constructor(maxComponents = 256) {
if (!Number.isInteger(maxComponents) || maxComponents <= 0) {
throw new RangeError(
`maxComponents must be a positive integer, got ${maxComponents}`
);
}
this.maxComponents = maxComponents;
}
/**
* Register a schema and return its ID.
*
* Idempotent by schema identity — passing the same schema reference twice
* yields the same ID. Throws `ECS.COMPONENT_NAME_CONFLICT` when a different
* schema is already registered under the same `name`, and
* `ECS.COMPONENT_LIMIT_REACHED` once `maxComponents` distinct schemas
* have been registered.
*/
public register<T extends ComponentData>(
schema: ComponentSchema<T>
): ComponentId {
const existing = this.schemaToId.get(schema);
if (existing !== undefined) return existing;
if (this.nextId >= this.maxComponents) {
throw new EcsError(
ECS_CODES.COMPONENT_LIMIT_REACHED,
"error.ecs.component_limit_reached",
{ context: { name: schema.name, max: this.maxComponents } }
);
}
if (this.nameToSchema.has(schema.name)) {
throw new EcsError(
ECS_CODES.COMPONENT_NAME_CONFLICT,
"error.ecs.component_name_conflict",
{ context: { name: schema.name } }
);
}
const id = this.nextId++;
this.schemaToId.set(schema, id);
this.idToSchema.set(id, schema);
this.nameToSchema.set(schema.name, schema);
return id;
}
/** Return the ID for a registered schema, or throw `ECS.COMPONENT_NOT_REGISTERED`. */
public getId(schema: ComponentSchema): ComponentId {
const id = this.schemaToId.get(schema);
if (id === undefined) {
throw new EcsError(
ECS_CODES.COMPONENT_NOT_REGISTERED,
"error.ecs.component_not_registered",
{ context: { name: schema.name } }
);
}
return id;
}
/** Lookup schema by ID. Returns `undefined` for unknown IDs. */
public getSchema(id: ComponentId): ComponentSchema | undefined {
return this.idToSchema.get(id);
}
/** Lookup schema by name. Returns `undefined` when no schema is registered. */
public getSchemaByName(name: string): ComponentSchema | undefined {
return this.nameToSchema.get(name);
}
/** True when the schema reference has been registered. */
public has(schema: ComponentSchema): boolean {
return this.schemaToId.has(schema);
}
/** Number of registered schemas. */
public get count(): number {
return this.nextId;
}
/** Snapshot of every registered schema, in registration order. */
public schemas(): ReadonlyArray<ComponentSchema> {
return Array.from(this.schemaToId.keys());
}
}

View File

@@ -0,0 +1 @@
export { ComponentRegistry } from "./component-registry.js";

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

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EntityPool } from "./entity-pool.js";
describe("EntityPool", () => {
it("allocate returns positive fresh ids in ascending order", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
expect(pool.allocate()).toBe(1);
expect(pool.allocate()).toBe(2);
expect(pool.allocate()).toBe(3);
});
it("allocate prefers a recycled id over a fresh id", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
const a = pool.allocate();
const b = pool.allocate();
pool.free(a);
const recycled = pool.allocate();
expect(recycled).toBe(a);
expect(b).toBe(2);
});
it("generation increments on every allocation of the same id", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
const a = pool.allocate();
expect(pool.generation(a)).toBe(1);
pool.free(a);
pool.allocate();
expect(pool.generation(a)).toBe(2);
});
it("free returns false for a non-alive entity", { timeout: 5000 }, () => {
const pool = new EntityPool();
expect(pool.free(999)).toBe(false);
const a = pool.allocate();
expect(pool.free(a)).toBe(true);
expect(pool.free(a)).toBe(false);
});
it("isAlive reflects the alive set", { timeout: 5000 }, () => {
const pool = new EntityPool();
const a = pool.allocate();
expect(pool.isAlive(a)).toBe(true);
pool.free(a);
expect(pool.isAlive(a)).toBe(false);
});
it("generation returns 0 for never-allocated entities", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
expect(pool.generation(42)).toBe(0);
});
it("aliveCount and recycledCount track the pool state", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
pool.allocate();
const b = pool.allocate();
expect(pool.aliveCount).toBe(2);
expect(pool.recycledCount).toBe(0);
pool.free(b);
expect(pool.aliveCount).toBe(1);
expect(pool.recycledCount).toBe(1);
});
it("peekNextId and setNextId round-trip", { timeout: 5000 }, () => {
const pool = new EntityPool();
pool.allocate();
pool.allocate();
expect(pool.peekNextId).toBe(3);
pool.setNextId(100);
expect(pool.allocate()).toBe(100);
});
it("setNextId rejects non-positive integers", { timeout: 5000 }, () => {
const pool = new EntityPool();
expect(() => pool.setNextId(0)).toThrow(RangeError);
expect(() => pool.setNextId(-1)).toThrow(RangeError);
expect(() => pool.setNextId(1.5)).toThrow(RangeError);
});
it("getAlive returns a read-only snapshot of the live set", {
timeout: 5000,
}, () => {
const pool = new EntityPool();
const a = pool.allocate();
const b = pool.allocate();
expect(pool.getAlive().has(a)).toBe(true);
expect(pool.getAlive().has(b)).toBe(true);
});
it("clear resets every internal counter", { timeout: 5000 }, () => {
const pool = new EntityPool();
pool.allocate();
pool.allocate();
pool.clear();
expect(pool.aliveCount).toBe(0);
expect(pool.recycledCount).toBe(0);
expect(pool.peekNextId).toBe(1);
expect(pool.allocate()).toBe(1);
});
});

View File

@@ -0,0 +1,85 @@
import type { Entity } from "../types/ecs.types.js";
/**
* Entity-ID allocator with recycling and per-ID generation counters.
*
* Despawned IDs go onto a free-list and are preferred for the next
* allocation, keeping the live-id range dense. Every allocation bumps a
* generation counter so callers can detect references to a recycled slot.
*/
export class EntityPool {
private nextId = 1;
private readonly recycled: Entity[] = [];
private readonly alive = new Set<Entity>();
private readonly generations = new Map<Entity, number>();
/** Allocate a fresh entity ID, preferring recycled IDs. */
public allocate(): Entity {
let id: Entity;
const recycled = this.recycled.pop();
if (recycled !== undefined) {
id = recycled;
} else {
id = this.nextId++;
}
this.alive.add(id);
this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
return id;
}
/** Return a live entity to the pool. Returns `false` for non-alive IDs. */
public free(entity: Entity): boolean {
if (!this.alive.has(entity)) return false;
this.alive.delete(entity);
this.recycled.push(entity);
return true;
}
/** True when `entity` is currently alive. */
public isAlive(entity: Entity): boolean {
return this.alive.has(entity);
}
/** Generation counter for `entity`. Zero if never allocated. */
public generation(entity: Entity): number {
return this.generations.get(entity) ?? 0;
}
/** Number of alive entities. */
public get aliveCount(): number {
return this.alive.size;
}
/** Number of IDs on the recycled free-list. */
public get recycledCount(): number {
return this.recycled.length;
}
/** Next fresh ID that will be assigned. Used by `World.snapshot`. */
public get peekNextId(): number {
return this.nextId;
}
/** Restore the `nextId` counter from a snapshot. Generations reset. */
public setNextId(id: number): void {
if (!Number.isInteger(id) || id < 1) {
throw new RangeError(
`nextId must be a positive integer, got ${id}`
);
}
this.nextId = id;
}
/** Snapshot of currently alive entities. Read-only view. */
public getAlive(): ReadonlySet<Entity> {
return this.alive;
}
/** Clear all state — alive set, free-list, generations, and counter. */
public clear(): void {
this.alive.clear();
this.recycled.length = 0;
this.generations.clear();
this.nextId = 1;
}
}

View File

@@ -0,0 +1 @@
export { EntityPool } from "./entity-pool.js";

View 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).

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { defineComponent } from "../schema/schema.js";
import { defineSystem } from "../system/system.js";
import { createWorld } from "../world/world.js";
import { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";
const Position = defineComponent("Position", { x: 0, y: 0 });
const Velocity = defineComponent("Velocity", { dx: 0 });
const Health = defineComponent(
"Health",
{ hp: 100 },
z.object({ hp: z.number().min(0) })
);
/**
* Force-trigger every registered `ECS.*` code from a production path so the
* "every code is reachable" guarantee is enforced by the test suite.
*/
const triggers: Record<EcsCode, () => void> = {
[ECS_CODES.ENTITY_NOT_ALIVE]: () => {
const world = createWorld();
const entity = world.spawn();
world.despawn(entity);
world.addComponent(entity, Position);
},
[ECS_CODES.COMPONENT_NOT_REGISTERED]: () => {
const world = createWorld();
world.hasComponent(world.spawn(), Position);
},
[ECS_CODES.COMPONENT_NAME_CONFLICT]: () => {
const world = createWorld();
const PositionA = defineComponent("Position", { x: 0 });
const PositionB = defineComponent("Position", { x: 1 });
world.registerComponent(PositionA);
world.registerComponent(PositionB);
},
[ECS_CODES.COMPONENT_LIMIT_REACHED]: () => {
const world = createWorld({ maxComponents: 1 });
world.registerComponent(Position);
world.registerComponent(Velocity);
},
[ECS_CODES.COMPONENT_VALIDATION_FAILED]: () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Health, { hp: -10 });
},
[ECS_CODES.SYSTEM_NAME_CONFLICT]: () => {
const world = createWorld();
const sys = defineSystem({
name: "tick",
query: { include: [Position] },
execute: () => undefined,
});
world.addSystem(sys);
world.addSystem(sys);
},
[ECS_CODES.SNAPSHOT_INVALID]: () => {
const world = createWorld();
world.restore({ entities: [], nextEntityId: -1 });
},
};
describe("EcsError", () => {
it("every ECS_CODES entry is thrown by at least one production code path", {
timeout: 5000,
}, () => {
for (const code of Object.values(ECS_CODES)) {
let caught: unknown;
try {
triggers[code]();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(EcsError);
expect((caught as EcsError).code).toBe(code);
}
});
it("EcsError carries the supplied context", { timeout: 5000 }, () => {
const err = new EcsError(
ECS_CODES.ENTITY_NOT_ALIVE,
"error.ecs.entity_not_alive",
{ context: { entity: 7 } }
);
expect(err.code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
expect(err.context?.entity).toBe(7);
expect(err.name).toBe("EcsError");
});
it("EcsError preserves the cause chain", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new EcsError(
ECS_CODES.SNAPSHOT_INVALID,
"error.ecs.snapshot_invalid",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,51 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/ecs`.
*
* Codes follow the pattern `ECS.<DOMAIN>` and are resolved via
* `@suaveplan/error`'s `defineCodes` registry. Use the fully-qualified
* strings (e.g. `"ECS.ENTITY_NOT_ALIVE"`) for stable `error.code`
* discrimination without relying on `instanceof`.
*/
export const ECS_CODES = defineCodes("ECS", [
"ENTITY_NOT_ALIVE",
"COMPONENT_NOT_REGISTERED",
"COMPONENT_NAME_CONFLICT",
"COMPONENT_LIMIT_REACHED",
"COMPONENT_VALIDATION_FAILED",
"SYSTEM_NAME_CONFLICT",
"SNAPSHOT_INVALID",
] as const);
export type EcsCode = (typeof ECS_CODES)[keyof typeof ECS_CODES];
/**
* Typed error for all `@suaveplan/ecs` failures.
*
* Extends `SuaveplanError` so callers can discriminate failures from this
* package via the stable `error.code` property or `error instanceof EcsError`.
*
* @example
* ```ts
* import { ECS_CODES, EcsError } from "@suaveplan/ecs";
*
* try {
* world.addComponent(deadEntity, Position);
* } catch (err) {
* if (err instanceof EcsError && err.code === ECS_CODES.ENTITY_NOT_ALIVE) {
* console.error("entity was already despawned");
* }
* }
* ```
*/
export class EcsError extends SuaveplanError<EcsCode> {
public constructor(
code: EcsCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "EcsError";
}
}

View File

@@ -0,0 +1 @@
export { ECS_CODES, type EcsCode, EcsError } from "./ecs-error.js";

View File

@@ -0,0 +1,42 @@
export { Archetype } from "./archetype/archetype.js";
export { BitSet } from "./bitset/bitset.js";
export { ComponentRegistry } from "./component-registry/component-registry.js";
export { EntityPool } from "./entity-pool/entity-pool.js";
export { ECS_CODES, type EcsCode, EcsError } from "./errors/ecs-error.js";
export {
entityLifecycleCounter,
executeDurationHistogram,
recordExecuteDuration,
recordLifecycle,
} from "./observability/otel.js";
export { defineQuery, Query } from "./query/query.js";
export { defineComponent } from "./schema/schema.js";
export {
EntityComponentSnapshotSchema,
EntitySnapshotSchema,
SystemPhaseSchema,
WorldConfigSchema,
WorldSnapshotSchema,
} from "./schemas/world.schemas.js";
export {
type DefineSystemOptions,
defineSystem,
SystemScheduler,
} from "./system/system.js";
export type {
ArchetypeId,
ComponentData,
ComponentId,
ComponentSchema,
Entity,
EntitySnapshot,
QueryDescriptor,
SystemContext,
SystemDefinition,
SystemExecuteFn,
SystemPhase,
WorldApi,
WorldEventMap,
WorldSnapshot,
} from "./types/ecs.types.js";
export { createWorld, World, type WorldConfig } from "./world/world.js";

View File

@@ -0,0 +1,6 @@
export {
entityLifecycleCounter,
executeDurationHistogram,
recordExecuteDuration,
recordLifecycle,
} from "./otel.js";

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

View File

@@ -0,0 +1,38 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/ecs";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered (i.e. production without an
* SDK or tests without the polyfill), the OTel API returns a no-op meter.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for entity-lifecycle events; attribute `op` ∈ `{spawn, despawn}`. */
export const entityLifecycleCounter: Counter = meter.createCounter(
"ecs.entity.lifecycle_total",
{
description:
"Total spawn/despawn events emitted by a World, attribute op ∈ {spawn, despawn}",
}
);
/** Histogram recording `World.execute` duration in milliseconds. */
export const executeDurationHistogram: Histogram = meter.createHistogram(
"ecs.world.execute_duration_ms",
{
description: "Duration of a World.execute tick in milliseconds",
unit: "ms",
}
);
/** Record a spawn or despawn lifecycle event. */
export function recordLifecycle(op: "spawn" | "despawn"): void {
entityLifecycleCounter.add(1, { op });
}
/** Record an execute-tick duration. */
export function recordExecuteDuration(durationMs: number): void {
executeDurationHistogram.record(durationMs);
}

View File

@@ -0,0 +1,4 @@
export {
defineQuery,
Query,
} from "./query.js";

View 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).

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Archetype } from "../archetype/archetype.js";
import { BitSet } from "../bitset/bitset.js";
import { ComponentRegistry } from "../component-registry/component-registry.js";
import { defineComponent } from "../schema/schema.js";
import { defineQuery, Query } from "./query.js";
const Position = defineComponent("Position", { x: 0 });
const Velocity = defineComponent("Velocity", { dx: 0 });
const Disabled = defineComponent("Disabled", { d: true });
function makeArch(
registry: ComponentRegistry,
schemas: (typeof Position)[]
): Archetype {
const mask = new BitSet(registry.maxComponents);
const ids: number[] = [];
for (const s of schemas) {
const id = registry.getId(s);
mask.set(id);
ids.push(id);
}
ids.sort((a, b) => a - b);
return new Archetype(mask, ids);
}
describe("Query", () => {
it("include-only matches every superset archetype", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
reg.register(Velocity);
const arch1 = makeArch(reg, [Position]);
const arch2 = makeArch(reg, [Position, Velocity]);
const arch3 = makeArch(reg, [Velocity]);
for (const id of [10, 11]) arch1.addEntity(id, new Map());
arch2.addEntity(20, new Map());
arch3.addEntity(30, new Map());
const q = new Query(defineQuery([Position]), reg);
const result = q.evaluate([arch1, arch2, arch3]);
expect([...result].sort((a, b) => a - b)).toEqual([10, 11, 20]);
});
it("exclude filters out archetypes whose mask intersects the exclude mask", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
reg.register(Disabled);
const archEnabled = makeArch(reg, [Position]);
const archDisabled = makeArch(reg, [Position, Disabled]);
archEnabled.addEntity(1, new Map());
archDisabled.addEntity(2, new Map());
const q = new Query(defineQuery([Position], [Disabled]), reg);
const result = q.evaluate([archEnabled, archDisabled]);
expect([...result]).toEqual([1]);
});
it("invalidate clears the cached match list and forces a re-scan", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
reg.register(Velocity);
const arch1 = makeArch(reg, [Position]);
arch1.addEntity(1, new Map());
const archetypes: Archetype[] = [arch1];
const q = new Query(defineQuery([Position]), reg);
expect(q.evaluate(archetypes)).toEqual([1]);
const arch2 = makeArch(reg, [Position, Velocity]);
arch2.addEntity(2, new Map());
archetypes.push(arch2);
// Without invalidate, the new archetype is not reconsidered.
expect(q.evaluate(archetypes)).toEqual([1]);
q.invalidate();
expect([...q.evaluate(archetypes)].sort()).toEqual([1, 2]);
});
it("defineQuery omits exclude when not supplied", { timeout: 5000 }, () => {
const descriptor = defineQuery([Position]);
expect(descriptor.exclude).toBeUndefined();
});
it("defineQuery includes exclude when provided", { timeout: 5000 }, () => {
const descriptor = defineQuery([Position], [Disabled]);
expect(descriptor.exclude).toEqual([Disabled]);
});
it("empty exclude array is treated as no exclude", {
timeout: 5000,
}, () => {
const reg = new ComponentRegistry();
reg.register(Position);
const q = new Query({ include: [Position], exclude: [] }, reg);
expect(q.excludeMask).toBeNull();
});
});

View File

@@ -0,0 +1,87 @@
import type { Archetype } from "../archetype/archetype.js";
import { BitSet } from "../bitset/bitset.js";
import type { ComponentRegistry } from "../component-registry/component-registry.js";
import type { Entity, QueryDescriptor } from "../types/ecs.types.js";
/**
* Compiled query matching archetypes by include/exclude masks.
*
* Queries cache the list of matched archetypes between mutations. The world
* calls `invalidate()` whenever the archetype graph changes so the next
* `evaluate` re-scans.
*/
export class Query {
public readonly includeMask: BitSet;
public readonly excludeMask: BitSet | null;
private matchedArchetypes: Archetype[] = [];
private dirty = true;
public constructor(
descriptor: QueryDescriptor,
registry: ComponentRegistry
) {
this.includeMask = new BitSet(registry.maxComponents);
for (const schema of descriptor.include) {
this.includeMask.set(registry.getId(schema));
}
if (descriptor.exclude !== undefined && descriptor.exclude.length > 0) {
this.excludeMask = new BitSet(registry.maxComponents);
for (const schema of descriptor.exclude) {
this.excludeMask.set(registry.getId(schema));
}
} else {
this.excludeMask = null;
}
}
/** True when an archetype matches this query's include/exclude masks. */
public matches(archetype: Archetype): boolean {
if (!archetype.mask.containsAll(this.includeMask)) return false;
if (
this.excludeMask !== null &&
archetype.mask.intersects(this.excludeMask)
) {
return false;
}
return true;
}
/** Mark the cached match list dirty. */
public invalidate(): void {
this.dirty = true;
}
/** Collect every matching entity across all archetypes. */
public evaluate(
archetypes: ReadonlyArray<Archetype>
): ReadonlyArray<Entity> {
if (this.dirty) {
this.matchedArchetypes = [];
for (const arch of archetypes) {
if (this.matches(arch)) {
this.matchedArchetypes.push(arch);
}
}
this.dirty = false;
}
const result: Entity[] = [];
for (const arch of this.matchedArchetypes) {
const entities = arch.getEntities();
for (let i = 0; i < entities.length; i++) {
result.push(entities[i] as Entity);
}
}
return result;
}
}
/** Build a {@link QueryDescriptor} from component schemas. */
export function defineQuery(
include: QueryDescriptor["include"],
exclude?: QueryDescriptor["exclude"]
): QueryDescriptor {
return exclude !== undefined ? { include, exclude } : { include };
}

View File

@@ -0,0 +1 @@
export { defineComponent } from "./schema.js";

View 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`.

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { defineComponent } from "./schema.js";
describe("defineComponent", () => {
it("returns a frozen descriptor with frozen defaults", {
timeout: 5000,
}, () => {
const Position = defineComponent("Position", { x: 0, y: 0 });
expect(Object.isFrozen(Position)).toBe(true);
expect(Object.isFrozen(Position.defaults)).toBe(true);
expect(() => {
(Position.defaults as { x: number }).x = 5;
}).toThrow(TypeError);
});
it("exposes the name and defaults", { timeout: 5000 }, () => {
const Health = defineComponent("Health", { hp: 100, max: 100 });
expect(Health.name).toBe("Health");
expect(Health.defaults).toEqual({ hp: 100, max: 100 });
});
it("omits schema when not supplied", { timeout: 5000 }, () => {
const Position = defineComponent("Position", { x: 0 });
expect(Position.schema).toBeUndefined();
});
it("exposes a supplied Zod schema", { timeout: 5000 }, () => {
const schema = z.object({ hp: z.number().min(0) });
const Health = defineComponent("Health", { hp: 100 }, schema);
expect(Health.schema).toBe(schema);
});
it("defaults are copied so caller mutations cannot leak in", {
timeout: 5000,
}, () => {
const draft = { x: 0, y: 0 };
const Position = defineComponent("Position", draft);
draft.x = 5;
expect(Position.defaults).toEqual({ x: 0, y: 0 });
});
});

View File

@@ -0,0 +1,33 @@
import type z from "zod";
import type { ComponentData, ComponentSchema } from "../types/ecs.types.js";
/**
* Build a frozen {@link ComponentSchema}.
*
* The returned object and its `defaults` are deep-frozen so accidental
* mutation is rejected in strict mode. Supplying a Zod `schema` opts the
* descriptor into runtime validation inside `World.addComponent` and
* `World.setComponent`.
*
* @example
* ```ts
* const Position = defineComponent("Position", { x: 0, y: 0, z: 0 });
* const Health = defineComponent(
* "Health",
* { current: 100, max: 100 },
* z.object({ current: z.number().nonnegative(), max: z.number().positive() }),
* );
* ```
*/
export function defineComponent<T extends ComponentData>(
name: string,
defaults: T,
schema?: z.ZodType<T>
): ComponentSchema<T> {
const frozen: ComponentSchema<T> = {
name,
defaults: Object.freeze({ ...defaults }),
...(schema !== undefined ? { schema } : {}),
};
return Object.freeze(frozen);
}

View File

@@ -0,0 +1,7 @@
export {
EntityComponentSnapshotSchema,
EntitySnapshotSchema,
SystemPhaseSchema,
WorldConfigSchema,
WorldSnapshotSchema,
} from "./world.schemas.js";

View File

@@ -0,0 +1,34 @@
import z from "zod";
/** Zod schema for a single snapshotted component entry. */
export const EntityComponentSnapshotSchema = z.object({
name: z.string().min(1),
data: z.record(z.string(), z.unknown()),
});
/** Zod schema for a single snapshotted entity. */
export const EntitySnapshotSchema = z.object({
id: z.number().int().positive(),
components: z.array(EntityComponentSnapshotSchema),
});
/** Zod schema for {@link WorldSnapshot}. */
export const WorldSnapshotSchema = z.object({
entities: z.array(EntitySnapshotSchema),
nextEntityId: z.number().int().positive(),
});
/** Zod schema for {@link WorldConfig}. */
export const WorldConfigSchema = z
.object({
maxComponents: z.number().int().positive().optional(),
})
.strict();
/** Zod schema for {@link SystemPhase}. */
export const SystemPhaseSchema = z.enum([
"preUpdate",
"update",
"postUpdate",
"render",
]);

View File

@@ -0,0 +1,5 @@
export type { DefineSystemOptions } from "./system.js";
export {
defineSystem,
SystemScheduler,
} from "./system.js";

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

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineComponent } from "../schema/schema.js";
import { defineSystem, SystemScheduler } from "./system.js";
const Position = defineComponent("Position", { x: 0 });
describe("defineSystem + SystemScheduler", () => {
it("defineSystem applies phase / priority / enabled defaults", {
timeout: 5000,
}, () => {
const sys = defineSystem({
name: "render",
query: { include: [Position] },
execute: () => undefined,
});
expect(sys.phase).toBe("update");
expect(sys.priority).toBe(0);
expect(sys.enabled).toBe(true);
});
it("defineSystem honors explicit values", { timeout: 5000 }, () => {
const sys = defineSystem({
name: "render",
phase: "render",
priority: 5,
enabled: false,
query: { include: [Position] },
execute: () => undefined,
});
expect(sys.phase).toBe("render");
expect(sys.priority).toBe(5);
expect(sys.enabled).toBe(false);
});
it("phases run in fixed order preUpdate update postUpdate render", {
timeout: 5000,
}, () => {
const scheduler = new SystemScheduler();
for (const phase of ["render", "preUpdate", "update"] as const) {
scheduler.add(
defineSystem({
name: phase,
phase,
query: { include: [Position] },
execute: () => undefined,
})
);
}
const names = scheduler.getOrdered().map((s) => s.name);
expect(names).toEqual(["preUpdate", "update", "render"]);
});
it("ties within a phase break by ascending priority", {
timeout: 5000,
}, () => {
const scheduler = new SystemScheduler();
scheduler.add(
defineSystem({
name: "B",
priority: 5,
query: { include: [Position] },
execute: () => undefined,
})
);
scheduler.add(
defineSystem({
name: "A",
priority: 2,
query: { include: [Position] },
execute: () => undefined,
})
);
const names = scheduler.getOrdered().map((s) => s.name);
expect(names).toEqual(["A", "B"]);
});
it("remove returns false for unknown system name", {
timeout: 5000,
}, () => {
const scheduler = new SystemScheduler();
expect(scheduler.remove("nope")).toBe(false);
});
it("remove returns true and shrinks the registered list", {
timeout: 5000,
}, () => {
const scheduler = new SystemScheduler();
scheduler.add(
defineSystem({
name: "A",
query: { include: [Position] },
execute: () => undefined,
})
);
expect(scheduler.remove("A")).toBe(true);
expect(scheduler.count).toBe(0);
});
it("has reports membership by name", { timeout: 5000 }, () => {
const scheduler = new SystemScheduler();
scheduler.add(
defineSystem({
name: "A",
query: { include: [Position] },
execute: () => undefined,
})
);
expect(scheduler.has("A")).toBe(true);
expect(scheduler.has("B")).toBe(false);
});
it("getAll returns a live read-only view, getOrdered returns a sorted view", {
timeout: 5000,
}, () => {
const scheduler = new SystemScheduler();
scheduler.add(
defineSystem({
name: "A",
query: { include: [Position] },
execute: () => undefined,
})
);
expect(scheduler.getAll().length).toBe(1);
expect(scheduler.getOrdered().length).toBe(1);
// calling again re-uses the sorted cache
expect(scheduler.getOrdered().length).toBe(1);
});
});

View File

@@ -0,0 +1,90 @@
import type {
QueryDescriptor,
SystemDefinition,
SystemExecuteFn,
SystemPhase,
} from "../types/ecs.types.js";
/** Options accepted by {@link defineSystem}. */
export interface DefineSystemOptions {
name: string;
phase?: SystemPhase;
priority?: number;
enabled?: boolean;
query: QueryDescriptor;
execute: SystemExecuteFn;
}
/** Build a {@link SystemDefinition} with phase/priority/enabled defaults. */
export function defineSystem(options: DefineSystemOptions): SystemDefinition {
return {
name: options.name,
phase: options.phase ?? "update",
priority: options.priority ?? 0,
enabled: options.enabled ?? true,
query: options.query,
execute: options.execute,
};
}
/**
* Phase- and priority-ordered system scheduler.
*
* Phases run in fixed order — `preUpdate → update → postUpdate → render`.
* Within a phase, lower-priority numbers run first. Insertion order is the
* stable tiebreaker for systems with equal priority.
*/
export class SystemScheduler {
private readonly systems: SystemDefinition[] = [];
private sorted = false;
private static readonly PHASE_ORDER: Record<SystemPhase, number> = {
preUpdate: 0,
update: 1,
postUpdate: 2,
render: 3,
};
/** Register a system. Caller is responsible for unique names. */
public add(system: SystemDefinition): void {
this.systems.push(system);
this.sorted = false;
}
/** Remove a system by name. Returns `false` if no match. */
public remove(name: string): boolean {
const index = this.systems.findIndex((s) => s.name === name);
if (index === -1) return false;
this.systems.splice(index, 1);
return true;
}
/** Return the registered systems in execution order. */
public getOrdered(): ReadonlyArray<SystemDefinition> {
if (!this.sorted) {
this.systems.sort((a, b) => {
const phaseA = SystemScheduler.PHASE_ORDER[a.phase];
const phaseB = SystemScheduler.PHASE_ORDER[b.phase];
if (phaseA !== phaseB) return phaseA - phaseB;
return a.priority - b.priority;
});
this.sorted = true;
}
return this.systems;
}
/** Read-only snapshot of every registered system. */
public getAll(): ReadonlyArray<SystemDefinition> {
return this.systems;
}
/** True when a system with `name` is registered. */
public has(name: string): boolean {
return this.systems.some((s) => s.name === name);
}
/** Number of registered systems. */
public get count(): number {
return this.systems.length;
}
}

View 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`.

View File

@@ -0,0 +1,105 @@
import type { z } from "zod";
/** Unique entity identifier — opaque positive integer assigned by the world. */
export type Entity = number;
/** Component data — any plain object indexed by string keys. */
export type ComponentData = Record<string, unknown>;
/** Stable integer ID assigned to a {@link ComponentSchema} by the registry. */
export type ComponentId = number;
/** Sorted, comma-separated component-ID string used as an archetype map key. */
export type ArchetypeId = string;
/**
* Schema definition for a component — provides a stable name, default values,
* and an optional Zod validator. Schemas are deep-frozen and identity-stable.
*/
export interface ComponentSchema<T extends ComponentData = ComponentData> {
readonly name: string;
readonly defaults: Readonly<T>;
readonly schema?: z.ZodType<T>;
}
/** Execution phase. Phases run in the order declared by `PHASE_ORDER`. */
export type SystemPhase = "preUpdate" | "update" | "postUpdate" | "render";
/** Arguments passed to a system's `execute` callback. */
export interface SystemContext {
readonly entities: ReadonlyArray<Entity>;
readonly world: WorldApi;
readonly dt: number;
}
/** System execute function signature. */
export type SystemExecuteFn = (ctx: SystemContext) => void;
/** System definition registered with the world. */
export interface SystemDefinition {
readonly name: string;
readonly phase: SystemPhase;
readonly priority: number;
readonly query: QueryDescriptor;
readonly execute: SystemExecuteFn;
readonly enabled: boolean;
}
/** Declarative query — which components to include and/or exclude. */
export interface QueryDescriptor {
readonly include: ReadonlyArray<ComponentSchema>;
readonly exclude?: ReadonlyArray<ComponentSchema>;
}
/** Serialized form of a single entity for snapshot transport. */
export interface EntitySnapshot {
readonly id: Entity;
readonly components: ReadonlyArray<{
readonly name: string;
readonly data: ComponentData;
}>;
}
/** Serialized form of an entire world. */
export interface WorldSnapshot {
readonly entities: ReadonlyArray<EntitySnapshot>;
readonly nextEntityId: number;
}
/** Public surface exposed to systems via {@link SystemContext.world}. */
export interface WorldApi {
spawn(): Entity;
despawn(entity: Entity): void;
isAlive(entity: Entity): boolean;
addComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>,
data?: Partial<T>
): void;
removeComponent(entity: Entity, schema: ComponentSchema): void;
getComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>
): T | undefined;
setComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>,
data: Partial<T>
): void;
hasComponent(entity: Entity, schema: ComponentSchema): boolean;
query(descriptor: QueryDescriptor): ReadonlyArray<Entity>;
}
/**
* EventBus payload map emitted by `world.events`.
*
* Genesis events use single-object payloads (not tuples) per the
* `@suaveplan/events` `EventMap = Record<string, unknown>` contract.
*/
export type WorldEventMap = {
entitySpawned: { entity: Entity };
entityDespawned: { entity: Entity };
componentAdded: { entity: Entity; component: string };
componentRemoved: { entity: Entity; component: string };
[key: string]: unknown;
};

View File

@@ -0,0 +1,16 @@
export type {
ArchetypeId,
ComponentData,
ComponentId,
ComponentSchema,
Entity,
EntitySnapshot,
QueryDescriptor,
SystemContext,
SystemDefinition,
SystemExecuteFn,
SystemPhase,
WorldApi,
WorldEventMap,
WorldSnapshot,
} from "./ecs.types.js";

View File

@@ -0,0 +1,5 @@
export type { WorldConfig } from "./world.js";
export {
createWorld,
World,
} from "./world.js";

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

View File

@@ -0,0 +1,149 @@
import { metrics } from "@opentelemetry/api";
import type { DataPoint, ResourceMetrics } from "@opentelemetry/sdk-metrics";
import {
afterAll,
afterEach,
describe,
expect,
expectTypeOf,
it,
} from "@suaveplan/testing/runner";
import {
flushTestMetrics,
testInMemoryMetricExporter,
testMeterProvider,
} from "../../tests/setup/polyfills.js";
import { defineComponent } from "../schema/schema.js";
import type { WorldEventMap } from "../types/ecs.types.js";
import { createWorld } from "./world.js";
const Position = defineComponent("Position", { x: 0, y: 0 });
function findMetric(
snapshots: ResourceMetrics[],
name: string
): DataPoint<number> | DataPoint<unknown> | undefined {
for (const rm of snapshots) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) {
return m.dataPoints[0] as DataPoint<number>;
}
}
}
}
return undefined;
}
describe("World — spec conformance", () => {
afterEach(() => {
testInMemoryMetricExporter.reset();
});
afterAll(async () => {
await testMeterProvider.shutdown();
metrics.disable();
});
it("execute records into the ecs.world.execute_duration_ms histogram via an in-memory MeterProvider", {
timeout: 5000,
}, async () => {
const world = createWorld();
for (let i = 0; i < 5; i++) world.execute(0.016);
const snapshots = await flushTestMetrics();
const hist = findMetric(snapshots, "ecs.world.execute_duration_ms");
expect(hist).toBeDefined();
const point = hist as DataPoint<{ count: number; sum: number }>;
expect(point.value.count).toBe(5);
expect(point.value.sum).toBeGreaterThanOrEqual(0);
});
it("spawn and despawn increment the ecs.entity.lifecycle_total counter via an in-memory MeterProvider", {
timeout: 5000,
}, async () => {
const world = createWorld();
const a = world.spawn();
world.spawn();
world.despawn(a);
const snapshots = await flushTestMetrics();
const counter = findMetric(snapshots, "ecs.entity.lifecycle_total");
let spawnTotal = 0;
let despawnTotal = 0;
for (const rm of snapshots) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name !== "ecs.entity.lifecycle_total")
continue;
for (const dp of m.dataPoints) {
const op = (dp.attributes as { op?: string }).op;
if (op === "spawn") spawnTotal += dp.value as number;
if (op === "despawn")
despawnTotal += dp.value as number;
}
}
}
}
expect(counter).toBeDefined();
expect(spawnTotal).toBe(2);
expect(despawnTotal).toBe(1);
});
it("spawn emits entitySpawned with a single-object payload", {
timeout: 5000,
}, () => {
const world = createWorld();
let received: { entity: number } | undefined;
world.events.on("entitySpawned", (payload) => {
received = payload;
});
const entity = world.spawn();
expect(received).toEqual({ entity });
expectTypeOf<WorldEventMap["entitySpawned"]>().toEqualTypeOf<{
entity: number;
}>();
});
it("componentAdded fires after the component is queryable on the entity", {
timeout: 5000,
}, () => {
const world = createWorld();
let visible = false;
world.events.on("componentAdded", (payload) => {
const { entity, component } = payload as {
entity: number;
component: string;
};
if (component === "Position") {
visible = world.hasComponent(entity, Position);
}
});
const entity = world.spawn();
world.addComponent(entity, Position);
expect(visible).toBe(true);
});
it("componentRemoved fires with entity and component name", {
timeout: 5000,
}, () => {
const world = createWorld();
const events: Array<{ entity: number; component: string }> = [];
world.events.on("componentRemoved", (payload) => {
events.push(payload as { entity: number; component: string });
});
const entity = world.spawn();
world.addComponent(entity, Position);
world.removeComponent(entity, Position);
expect(events).toEqual([{ entity, component: "Position" }]);
});
it("entityDespawned fires for every despawn", { timeout: 5000 }, () => {
const world = createWorld();
const seen: number[] = [];
world.events.on("entityDespawned", (payload) => {
seen.push((payload as { entity: number }).entity);
});
const a = world.spawn();
world.despawn(a);
expect(seen).toEqual([a]);
});
});

View File

@@ -0,0 +1,455 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
import { defineQuery } from "../query/query.js";
import { defineComponent } from "../schema/schema.js";
import { defineSystem } from "../system/system.js";
import { createWorld, World } from "./world.js";
const Position = defineComponent("Position", { x: 0, y: 0 });
const Velocity = defineComponent("Velocity", { dx: 0, dy: 0 });
const Disabled = defineComponent("Disabled", { d: true });
const Health = defineComponent(
"Health",
{ hp: 100 },
z.object({ hp: z.number().min(0) })
);
describe("World", () => {
it("createWorld returns a World instance", { timeout: 5000 }, () => {
expect(createWorld()).toBeInstanceOf(World);
});
it("spawn and addComponent place the entity in the matching archetype", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position, { x: 5 });
expect(world.hasComponent(entity, Position)).toBe(true);
expect(world.getComponent(entity, Position)).toEqual({
x: 5,
y: 0,
});
const matches = world.query(defineQuery([Position]));
expect([...matches]).toEqual([entity]);
});
it("spawn placement defaults to the empty archetype", {
timeout: 5000,
}, () => {
const world = createWorld();
world.spawn();
expect(world.archetypeCount).toBe(1);
expect(world.entityCount).toBe(1);
});
it("addComponent is idempotent when the component already exists", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position, { x: 5 });
world.addComponent(entity, Position, { x: 9 });
expect(world.getComponent(entity, Position)).toEqual({
x: 5,
y: 0,
});
});
it("addComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.despawn(entity);
try {
world.addComponent(entity, Position);
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EcsError);
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
}
});
it("addComponent throws ECS.COMPONENT_VALIDATION_FAILED for data that fails the descriptor's Zod schema", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
try {
world.addComponent(entity, Health, { hp: -5 });
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(EcsError);
expect((err as EcsError).code).toBe(
ECS_CODES.COMPONENT_VALIDATION_FAILED
);
}
});
it("removeComponent moves the entity to a smaller archetype", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position);
world.addComponent(entity, Velocity);
world.removeComponent(entity, Velocity);
expect(world.hasComponent(entity, Velocity)).toBe(false);
expect(world.hasComponent(entity, Position)).toBe(true);
});
it("removeComponent is a no-op when the component is absent", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position);
world.registerComponent(Velocity);
const before = world.archetypeCount;
world.removeComponent(entity, Velocity);
expect(world.archetypeCount).toBe(before);
expect(world.hasComponent(entity, Position)).toBe(true);
});
it("removeComponent throws ECS.ENTITY_NOT_ALIVE for a despawned entity", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position);
world.despawn(entity);
try {
world.removeComponent(entity, Position);
expect.unreachable("should have thrown");
} catch (err) {
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
}
});
it("setComponent merges partial data and re-validates", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position, { x: 1, y: 2 });
world.setComponent(entity, Position, { x: 9 });
expect(world.getComponent(entity, Position)).toEqual({
x: 9,
y: 2,
});
});
it("setComponent delegates to addComponent when the component is absent", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.setComponent(entity, Position, { x: 4 });
expect(world.hasComponent(entity, Position)).toBe(true);
expect(world.getComponent(entity, Position)).toEqual({ x: 4, y: 0 });
});
it("setComponent throws ECS.ENTITY_NOT_ALIVE when the entity is despawned", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position);
world.despawn(entity);
try {
world.setComponent(entity, Position, { x: 1 });
expect.unreachable("should have thrown");
} catch (err) {
expect((err as EcsError).code).toBe(ECS_CODES.ENTITY_NOT_ALIVE);
}
});
it("setComponent rejects updates that fail the descriptor schema", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Health);
try {
world.setComponent(entity, Health, { hp: -1 });
expect.unreachable("should have thrown");
} catch (err) {
expect((err as EcsError).code).toBe(
ECS_CODES.COMPONENT_VALIDATION_FAILED
);
}
});
it("getComponent returns undefined for absent components and despawned entities", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.registerComponent(Position);
expect(world.getComponent(entity, Position)).toBeUndefined();
world.despawn(entity);
expect(world.getComponent(entity, Position)).toBeUndefined();
});
it("despawn is a no-op for unknown entities", { timeout: 5000 }, () => {
const world = createWorld();
world.despawn(9999);
expect(world.entityCount).toBe(0);
});
it("isAlive reflects the entity pool", { timeout: 5000 }, () => {
const world = createWorld();
const entity = world.spawn();
expect(world.isAlive(entity)).toBe(true);
world.despawn(entity);
expect(world.isAlive(entity)).toBe(false);
});
it("hasComponent returns false for a despawned entity", {
timeout: 5000,
}, () => {
const world = createWorld();
const entity = world.spawn();
world.addComponent(entity, Position);
world.despawn(entity);
expect(world.hasComponent(entity, Position)).toBe(false);
});
it("query results respect include and exclude", { timeout: 5000 }, () => {
const world = createWorld();
const a = world.spawn();
const b = world.spawn();
world.addComponent(a, Position);
world.addComponent(b, Position);
world.addComponent(b, Disabled);
const enabled = world.query(defineQuery([Position], [Disabled]));
expect([...enabled]).toEqual([a]);
});
it("addSystem throws ECS.SYSTEM_NAME_CONFLICT on duplicate name", {
timeout: 5000,
}, () => {
const world = createWorld();
const sys = defineSystem({
name: "tick",
query: { include: [Position] },
execute: () => undefined,
});
world.addSystem(sys);
try {
world.addSystem(sys);
expect.unreachable("should have thrown");
} catch (err) {
expect((err as EcsError).code).toBe(ECS_CODES.SYSTEM_NAME_CONFLICT);
}
});
it("addSystem registers schemas from include and exclude clauses", {
timeout: 5000,
}, () => {
const world = createWorld();
world.addSystem(
defineSystem({
name: "tick",
query: { include: [Position], exclude: [Disabled] },
execute: () => undefined,
})
);
expect(world.registry.has(Position)).toBe(true);
expect(world.registry.has(Disabled)).toBe(true);
});
it("addSystem is a no-op for schemas already registered", {
timeout: 5000,
}, () => {
const world = createWorld();
world.registerComponent(Position);
world.registerComponent(Disabled);
world.addSystem(
defineSystem({
name: "tick",
query: { include: [Position], exclude: [Disabled] },
execute: () => undefined,
})
);
expect(world.registry.count).toBe(2);
});
it("addSystem with no exclude clause registers only include schemas", {
timeout: 5000,
}, () => {
const world = createWorld();
world.addSystem(
defineSystem({
name: "tick",
query: { include: [Position] },
execute: () => undefined,
})
);
expect(world.registry.has(Position)).toBe(true);
expect(world.registry.count).toBe(1);
});
it("removeSystem returns false for unknown names", {
timeout: 5000,
}, () => {
const world = createWorld();
expect(world.removeSystem("nope")).toBe(false);
});
it("execute runs registered systems in scheduler order", {
timeout: 5000,
}, () => {
const world = createWorld();
const order: string[] = [];
world.addSystem(
defineSystem({
name: "render",
phase: "render",
query: { include: [Position] },
execute: () => order.push("render"),
})
);
world.addSystem(
defineSystem({
name: "pre",
phase: "preUpdate",
query: { include: [Position] },
execute: () => order.push("pre"),
})
);
world.addSystem(
defineSystem({
name: "update",
query: { include: [Position] },
execute: ({ dt }) => order.push(`update:${dt}`),
})
);
world.execute(0.016);
expect(order).toEqual(["pre", "update:0.016", "render"]);
});
it("execute skips disabled systems", { timeout: 5000 }, () => {
const world = createWorld();
let fired = false;
world.addSystem(
defineSystem({
name: "off",
enabled: false,
query: { include: [Position] },
execute: () => {
fired = true;
},
})
);
world.execute(0.016);
expect(fired).toBe(false);
});
it("snapshot and restore round-trip preserves entities and components", {
timeout: 5000,
}, () => {
const a = createWorld();
a.registerComponent(Position);
const e1 = a.spawn();
const e2 = a.spawn();
a.addComponent(e1, Position, { x: 1, y: 2 });
a.addComponent(e2, Position, { x: 3, y: 4 });
a.despawn(e1);
const snap = a.snapshot();
const b = createWorld();
b.registerComponent(Position);
b.restore(snap);
expect(b.entityCount).toBe(1);
const restored = b.query(defineQuery([Position]));
expect(restored.length).toBe(1);
expect(b.getComponent(restored[0] ?? 0, Position)).toEqual({
x: 3,
y: 4,
});
});
it("restore silently skips snapshot components with unknown names", {
timeout: 5000,
}, () => {
const a = createWorld();
const entity = a.spawn();
a.addComponent(entity, Position, { x: 7, y: 8 });
const snap = a.snapshot();
const b = createWorld();
// Only register Position, not Velocity — snapshot only has Position so OK.
// Inject a phantom component name to exercise the skip branch.
const tampered = {
...snap,
entities: snap.entities.map((e) => ({
...e,
components: [
...e.components,
{ name: "Ghost", data: { ghost: true } },
],
})),
};
b.registerComponent(Position);
b.restore(tampered);
expect(b.entityCount).toBe(1);
});
it("restore throws ECS.SNAPSHOT_INVALID for a malformed snapshot", {
timeout: 5000,
}, () => {
const world = createWorld();
try {
world.restore({
entities: [],
nextEntityId: -1,
});
expect.unreachable("should have thrown");
} catch (err) {
expect((err as EcsError).code).toBe(ECS_CODES.SNAPSHOT_INVALID);
}
});
it("clear removes entities and archetypes but keeps systems", {
timeout: 5000,
}, () => {
const world = createWorld();
world.addSystem(
defineSystem({
name: "tick",
query: { include: [Position] },
execute: () => undefined,
})
);
const entity = world.spawn();
world.addComponent(entity, Position);
world.clear();
expect(world.entityCount).toBe(0);
expect(world.archetypeCount).toBe(0);
expect(world.getSystems().length).toBe(1);
});
it("getSystems returns the registered systems", { timeout: 5000 }, () => {
const world = createWorld();
world.addSystem(
defineSystem({
name: "tick",
query: { include: [Position] },
execute: () => undefined,
})
);
expect(world.getSystems().length).toBe(1);
});
it("WorldConfig.maxComponents threads through to the registry", {
timeout: 5000,
}, () => {
const world = createWorld({ maxComponents: 4 });
expect(world.registry.maxComponents).toBe(4);
});
});

View File

@@ -0,0 +1,391 @@
import { EventBus } from "@suaveplan/events";
import { Archetype } from "../archetype/archetype.js";
import { BitSet } from "../bitset/bitset.js";
import { ComponentRegistry } from "../component-registry/component-registry.js";
import { EntityPool } from "../entity-pool/entity-pool.js";
import { ECS_CODES, EcsError } from "../errors/ecs-error.js";
import {
recordExecuteDuration,
recordLifecycle,
} from "../observability/otel.js";
import { Query } from "../query/query.js";
import { WorldSnapshotSchema } from "../schemas/world.schemas.js";
import { SystemScheduler } from "../system/system.js";
import type {
ComponentData,
ComponentId,
ComponentSchema,
Entity,
EntitySnapshot,
QueryDescriptor,
SystemDefinition,
WorldApi,
WorldEventMap,
WorldSnapshot,
} from "../types/ecs.types.js";
/** Configuration accepted by {@link createWorld} / {@link World}. */
export interface WorldConfig {
/** Hard cap on the number of distinct component types. Default 256. */
readonly maxComponents?: number;
}
/**
* The World is the central container for an ECS instance. It owns the
* component registry, entity pool, archetype graph, query cache, and system
* scheduler, and emits typed lifecycle events through `world.events`.
*
* Public mutators ({@link spawn}, {@link despawn}, {@link addComponent},
* {@link removeComponent}, {@link execute}, {@link snapshot},
* {@link restore}) are instrumented with OpenTelemetry counters / histograms.
*/
export class World implements WorldApi {
public readonly events = new EventBus<WorldEventMap>();
public readonly registry: ComponentRegistry;
private readonly pool: EntityPool;
private readonly scheduler: SystemScheduler;
private readonly archetypes: Archetype[] = [];
private readonly archetypeMap = new Map<string, Archetype>();
private readonly entityArchetype = new Map<Entity, Archetype>();
public constructor(config?: WorldConfig) {
this.registry = new ComponentRegistry(config?.maxComponents);
this.pool = new EntityPool();
this.scheduler = new SystemScheduler();
}
/** Register a component schema. Idempotent by schema identity. */
public registerComponent<T extends ComponentData>(
schema: ComponentSchema<T>
): void {
this.registry.register(schema);
}
/** Spawn a new entity in the empty archetype and emit `entitySpawned`. */
public spawn(): Entity {
const entity = this.pool.allocate();
const emptyArch = this.getOrCreateArchetype(
new BitSet(this.registry.maxComponents),
[]
);
emptyArch.addEntity(entity, new Map());
this.entityArchetype.set(entity, emptyArch);
recordLifecycle("spawn");
this.events.emit("entitySpawned", { entity });
return entity;
}
/** Despawn `entity`, freeing its ID and emitting `entityDespawned`. */
public despawn(entity: Entity): void {
const arch = this.entityArchetype.get(entity);
if (arch === undefined) return;
arch.removeEntity(entity);
this.entityArchetype.delete(entity);
this.pool.free(entity);
recordLifecycle("despawn");
this.events.emit("entityDespawned", { entity });
}
/** True when `entity` is currently alive in this world. */
public isAlive(entity: Entity): boolean {
return this.pool.isAlive(entity);
}
/**
* Add a component to `entity`, moving it to a new archetype as needed.
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity has been despawned.
* No-op when the entity already has the component.
*/
public addComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>,
data?: Partial<T>
): void {
if (!this.registry.has(schema)) this.registry.register(schema);
const componentId = this.registry.getId(schema);
const oldArch = this.entityArchetype.get(entity);
if (oldArch === undefined) {
throw new EcsError(
ECS_CODES.ENTITY_NOT_ALIVE,
"error.ecs.entity_not_alive",
{ context: { entity } }
);
}
if (oldArch.hasComponent(componentId)) return;
const merged = { ...schema.defaults, ...data } as T;
this.validateComponent(schema, merged);
const oldData = oldArch.removeEntity(entity) as Map<
ComponentId,
ComponentData
>;
oldData.set(componentId, merged);
const newMask = oldArch.mask.clone();
newMask.set(componentId);
const newIds = [...oldArch.getComponentIds(), componentId].sort(
(a, b) => a - b
);
const newArch = this.getOrCreateArchetype(newMask, newIds);
newArch.addEntity(entity, oldData);
this.entityArchetype.set(entity, newArch);
this.events.emit("componentAdded", {
entity,
component: schema.name,
});
}
/** Remove a component from `entity`, moving it to a new archetype. */
public removeComponent(entity: Entity, schema: ComponentSchema): void {
const componentId = this.registry.getId(schema);
const oldArch = this.entityArchetype.get(entity);
if (oldArch === undefined) {
throw new EcsError(
ECS_CODES.ENTITY_NOT_ALIVE,
"error.ecs.entity_not_alive",
{ context: { entity } }
);
}
if (!oldArch.hasComponent(componentId)) return;
const oldData = oldArch.removeEntity(entity) as Map<
ComponentId,
ComponentData
>;
oldData.delete(componentId);
const newMask = oldArch.mask.clone();
newMask.unset(componentId);
const newIds = oldArch
.getComponentIds()
.filter((id) => id !== componentId);
const newArch = this.getOrCreateArchetype(newMask, newIds);
newArch.addEntity(entity, oldData);
this.entityArchetype.set(entity, newArch);
this.events.emit("componentRemoved", {
entity,
component: schema.name,
});
}
/** Read a component value, or `undefined` if absent. */
public getComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>
): T | undefined {
const componentId = this.registry.getId(schema);
const arch = this.entityArchetype.get(entity);
return arch?.getComponent(entity, componentId) as T | undefined;
}
/**
* Partially merge new data into an existing component value.
* Throws `ECS.ENTITY_NOT_ALIVE` if the entity is absent and
* `ECS.COMPONENT_VALIDATION_FAILED` when the descriptor's Zod schema rejects
* the merged result.
*/
public setComponent<T extends ComponentData>(
entity: Entity,
schema: ComponentSchema<T>,
data: Partial<T>
): void {
if (!this.registry.has(schema)) this.registry.register(schema);
const componentId = this.registry.getId(schema);
const arch = this.entityArchetype.get(entity);
if (arch === undefined) {
throw new EcsError(
ECS_CODES.ENTITY_NOT_ALIVE,
"error.ecs.entity_not_alive",
{ context: { entity } }
);
}
const current = arch.getComponent(entity, componentId) as T | undefined;
if (current === undefined) {
this.addComponent(entity, schema, data);
return;
}
const merged = { ...current, ...data } as T;
this.validateComponent(schema, merged);
arch.setComponent(entity, componentId, merged);
}
/** True when `entity` has the component. */
public hasComponent(entity: Entity, schema: ComponentSchema): boolean {
const componentId = this.registry.getId(schema);
const arch = this.entityArchetype.get(entity);
return arch?.hasComponent(componentId) ?? false;
}
/** Evaluate a one-off query — for repeated queries register a system. */
public query(descriptor: QueryDescriptor): ReadonlyArray<Entity> {
const q = new Query(descriptor, this.registry);
return q.evaluate(this.archetypes);
}
/**
* Register a system. Throws `ECS.SYSTEM_NAME_CONFLICT` if another system
* shares the same name.
*/
public addSystem(system: SystemDefinition): void {
if (this.scheduler.has(system.name)) {
throw new EcsError(
ECS_CODES.SYSTEM_NAME_CONFLICT,
"error.ecs.system_name_conflict",
{ context: { name: system.name } }
);
}
const schemas: ComponentSchema[] = [
...system.query.include,
...(system.query.exclude ?? []),
];
for (const schema of schemas) {
if (!this.registry.has(schema)) this.registry.register(schema);
}
this.scheduler.add(system);
}
/** Remove a system by name. Returns `false` if no match. */
public removeSystem(name: string): boolean {
return this.scheduler.remove(name);
}
/**
* Run every enabled system in scheduler order. Records the wall-clock
* duration in the `ecs.world.execute_duration_ms` histogram.
*/
public execute(dt: number): void {
const start = performance.now();
const ordered = this.scheduler.getOrdered();
for (const system of ordered) {
if (!system.enabled) continue;
const q = new Query(system.query, this.registry);
const entities = q.evaluate(this.archetypes);
system.execute({ entities, world: this, dt });
}
recordExecuteDuration(performance.now() - start);
}
/** Capture a structured snapshot of every alive entity. */
public snapshot(): WorldSnapshot {
const entities: EntitySnapshot[] = [];
for (const [entity, arch] of this.entityArchetype) {
const components: {
readonly name: string;
readonly data: ComponentData;
}[] = [];
for (const cid of arch.getComponentIds()) {
const schema = this.registry.getSchema(cid) as ComponentSchema;
const data = arch.getComponent(entity, cid) as ComponentData;
components.push({
name: schema.name,
data: structuredClone(data),
});
}
entities.push({ id: entity, components });
}
return { entities, nextEntityId: this.pool.peekNextId };
}
/**
* Restore world state from a snapshot. The snapshot is Zod-validated;
* `ECS.SNAPSHOT_INVALID` is thrown on failure. Component schemas
* referenced by name must already be registered — unknown names are
* silently skipped.
*/
public restore(snap: WorldSnapshot): void {
const parsed = WorldSnapshotSchema.safeParse(snap);
if (!parsed.success) {
throw new EcsError(
ECS_CODES.SNAPSHOT_INVALID,
"error.ecs.snapshot_invalid",
{ context: { issues: parsed.error.issues } }
);
}
this.clear();
this.pool.setNextId(parsed.data.nextEntityId);
for (const es of parsed.data.entities) {
const entity = this.pool.allocate();
const emptyArch = this.getOrCreateArchetype(
new BitSet(this.registry.maxComponents),
[]
);
emptyArch.addEntity(entity, new Map());
this.entityArchetype.set(entity, emptyArch);
for (const comp of es.components) {
const schema = this.registry.getSchemaByName(comp.name);
if (schema !== undefined) {
this.addComponent(entity, schema, comp.data);
}
}
}
}
/** Remove every entity and archetype. Systems and registrations are kept. */
public clear(): void {
this.entityArchetype.clear();
this.archetypes.length = 0;
this.archetypeMap.clear();
this.pool.clear();
}
/** Number of alive entities. */
public get entityCount(): number {
return this.pool.aliveCount;
}
/** Number of distinct archetypes currently held. */
public get archetypeCount(): number {
return this.archetypes.length;
}
/** Read-only snapshot of every registered system. */
public getSystems(): ReadonlyArray<SystemDefinition> {
return this.scheduler.getAll();
}
private getOrCreateArchetype(
mask: BitSet,
componentIds: ReadonlyArray<ComponentId>
): Archetype {
const key = mask.toKey();
let arch = this.archetypeMap.get(key);
if (arch === undefined) {
arch = new Archetype(mask, componentIds);
this.archetypes.push(arch);
this.archetypeMap.set(key, arch);
}
return arch;
}
private validateComponent<T extends ComponentData>(
schema: ComponentSchema<T>,
value: T
): void {
if (schema.schema === undefined) return;
const result = schema.schema.safeParse(value);
if (!result.success) {
throw new EcsError(
ECS_CODES.COMPONENT_VALIDATION_FAILED,
"error.ecs.component_validation_failed",
{
context: {
name: schema.name,
issues: result.error.issues,
},
}
);
}
}
}
/** Factory wrapper around `new World(config)`. */
export function createWorld(config?: WorldConfig): World {
return new World(config);
}

View File

@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const ALLOWED_RUNTIME_DEPS = new Set([
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
]);
describe("dependency graph", () => {
it("runtime dependencies are restricted to the declared allowlist", {
timeout: 5000,
}, async () => {
const pkg = JSON.parse(
await readFile(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies: Record<string, string> };
const declared = new Set(Object.keys(pkg.dependencies));
const extras = [...declared].filter(
(d) => !ALLOWED_RUNTIME_DEPS.has(d)
);
expect(extras).toEqual([]);
});
});

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { BitSet } from "../src/bitset/bitset.js";
import { defineQuery } from "../src/query/query.js";
import { defineComponent } from "../src/schema/schema.js";
import { createWorld } from "../src/world/world.js";
const MAX_BITS = 128;
const MAX_ENTITIES = 32;
function bitsetFromBits(size: number, bits: ReadonlyArray<number>): BitSet {
const set = new BitSet(size);
for (const b of bits) set.set(b);
return set;
}
function bruteContainsAll(a: BitSet, b: BitSet, size: number): boolean {
for (let i = 0; i < size; i++) {
if (b.has(i) && !a.has(i)) return false;
}
return true;
}
function bruteIntersects(a: BitSet, b: BitSet, size: number): boolean {
for (let i = 0; i < size; i++) {
if (a.has(i) && b.has(i)) return true;
}
return false;
}
describe("property invariants", () => {
it("bitset algebra matches brute-force on random masks", {
timeout: 10000,
}, () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: MAX_BITS }),
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
(rawSize, aBits, bBits) => {
const size = Math.max(rawSize, ...aBits, ...bBits, 0) + 1;
const a = bitsetFromBits(size, aBits);
const b = bitsetFromBits(size, bBits);
expect(a.containsAll(b)).toBe(bruteContainsAll(a, b, size));
expect(a.intersects(b)).toBe(b.intersects(a));
expect(a.intersects(b)).toBe(bruteIntersects(a, b, size));
}
),
{ numRuns: 100 }
);
});
it("query result equals brute-force scan on random worlds", {
timeout: 30000,
}, () => {
const Position = defineComponent("Position", { x: 0 });
const Velocity = defineComponent("Velocity", { dx: 0 });
const Disabled = defineComponent("Disabled", { d: true });
const schemas = [Position, Velocity, Disabled] as const;
fc.assert(
fc.property(
fc.array(fc.tuple(fc.boolean(), fc.boolean(), fc.boolean()), {
maxLength: MAX_ENTITIES,
}),
fc.subarray([Position, Velocity, Disabled], {
minLength: 1,
maxLength: 3,
}),
fc.subarray([Position, Velocity, Disabled], {
minLength: 0,
maxLength: 3,
}),
(entitySpec, include, exclude) => {
const world = createWorld();
for (const s of schemas) world.registerComponent(s);
const entities: number[] = [];
for (const flags of entitySpec) {
const entity = world.spawn();
entities.push(entity);
for (let i = 0; i < schemas.length; i++) {
if (flags[i] === true) {
world.addComponent(entity, schemas[i]!);
}
}
}
const descriptor = defineQuery(
include,
exclude.length > 0 ? exclude : undefined
);
const queried = [...world.query(descriptor)].sort(
(a, b) => a - b
);
const expected = entities
.filter((e) => {
for (const s of include) {
if (!world.hasComponent(e, s)) return false;
}
for (const s of exclude) {
if (world.hasComponent(e, s)) return false;
}
return true;
})
.sort((a, b) => a - b);
expect(queried).toEqual(expected);
}
),
{ numRuns: 50 }
);
});
it("entity count tracks spawn / despawn monotonically", {
timeout: 10000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.boolean(), { maxLength: MAX_ENTITIES }),
(ops) => {
const world = createWorld();
const live: number[] = [];
for (const op of ops) {
if (op || live.length === 0) {
live.push(world.spawn());
} else {
const last = live.pop();
if (last !== undefined) world.despawn(last);
}
}
expect(world.entityCount).toBe(live.length);
}
),
{ numRuns: 100 }
);
});
it("snapshot then restore preserves observable state", {
timeout: 15000,
}, () => {
const Position = defineComponent("Position", { x: 0, y: 0 });
fc.assert(
fc.property(
fc.array(fc.record({ x: fc.integer(), y: fc.integer() }), {
maxLength: MAX_ENTITIES,
}),
(positions) => {
const a = createWorld();
a.registerComponent(Position);
for (const p of positions) {
const entity = a.spawn();
a.addComponent(entity, Position, p);
}
const snap = a.snapshot();
const b = createWorld();
b.registerComponent(Position);
b.restore(snap);
expect(b.entityCount).toBe(a.entityCount);
expect(b.archetypeCount).toBe(a.archetypeCount);
}
),
{ numRuns: 30 }
);
});
});

View File

@@ -0,0 +1,52 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(__dirname, "..", "src");
const BANNED_PATTERNS = [
/\bfrom\s+["']node:/,
/\bfrom\s+["']fs["']/,
/\bfrom\s+["']path["']/,
/\bfrom\s+["']crypto["']/,
/\brequire\(["']node:/,
/\bprocess\.[a-zA-Z]/,
/\bBuffer\.[a-zA-Z]/,
/\bwindow\./,
/\bdocument\./,
/\bnavigator\./,
];
async function* walk(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (
entry.isFile() &&
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".bench.ts")
) {
yield full;
}
}
}
describe("purity guards", () => {
it("built dist references no Node or browser globals", {
timeout: 5000,
}, async () => {
const offenders: Array<{ file: string; pattern: string }> = [];
for await (const file of walk(SRC_ROOT)) {
const source = await readFile(file, "utf8");
for (const pattern of BANNED_PATTERNS) {
if (pattern.test(source)) {
offenders.push({ file, pattern: pattern.source });
}
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Test setup for @suaveplan/ecs.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Also
* provides a `webcrypto`-backed `globalThis.crypto` for bare-Node runs.
*/
import { webcrypto } from "node:crypto";
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/games-ecs",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 30000,
},
});