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

19
packages/gaming/README.md Normal file
View File

@@ -0,0 +1,19 @@
# `gaming`
Game engines, entity-component systems, and simulation libraries.
<!-- begin:auto-docs -->
## Subcategories
1 subcategory, 3 packages.
### `games`
Game controllers, entity-component systems, and game-server logic.
| Package | Description |
|---|---|
| [`@suaveplan/controller`](./controller/README.md) | Universal input controller foundation. Ships the unified `ControllerInput` shape (37 normalised fields in `[0, 255]`), `BaseController` abstract base with… |
| [`@suaveplan/ecs`](./ecs/README.md) | Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component sche… |
| [`@suaveplan/game-server`](./game-server/README.md) | Universal game-server framework. Migrated from `@suaveplan/game-server` on the reference branch `claude/review-game-engine-arch-Kaykr` (main repo `packages/uni… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `gaming`
Game engines, entity-component systems, and simulation libraries.

View File

@@ -0,0 +1,15 @@
# `games`
Game controllers, entity-component systems, and game-server logic.
<!-- begin:auto-docs -->
## Packages in `games`
3 packages.
| Package | Description |
|---|---|
| [`@suaveplan/controller`](./controller/README.md) | Universal input controller foundation. Ships the unified `ControllerInput` shape (37 normalised fields in `[0, 255]`), `BaseController` abstract base with… |
| [`@suaveplan/ecs`](./ecs/README.md) | Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component sche… |
| [`@suaveplan/game-server`](./game-server/README.md) | Universal game-server framework. Migrated from `@suaveplan/game-server` on the reference branch `claude/review-game-engine-arch-Kaykr` (main repo `packages/uni… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Game controllers, entity-component systems, and game-server logic.

View File

@@ -0,0 +1,66 @@
# @suaveplan/controller - Universal input controller foundation — unified 32-field ControllerInput shape, BaseController abstract base with Clock-injected pollRate gating and threshold/deadzone normalisation, typed InputEventEmitter, stable CONTROLLER.* error code registry, and OpenTelemetry instrumentation. Chunk A of the @suaveplan/controller migration; device adapters and merger ship in follow-up chunks.
Universal input controller foundation. Ships the unified
`ControllerInput` shape (37 normalised fields in `[0, 255]`),
`BaseController` abstract base with injectable `Clock`/`IdGenerator`,
`pollRate` gating and per-input threshold/deadzone normalisation, the
typed `InputEventEmitter`, the stable `CONTROLLER.*` error-code
registry, and the package's OpenTelemetry instruments.
Device adapters (`KeyboardController`, `GamepadController`,
`TouchController`, `MouseController`) and `ControllerMerger` ship in
Chunks B and C.
<!-- begin:auto-docs -->
## Documentation tree
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
- [base](./src/base/base.md) — Abstract base for every device-specific controller adapter shipped by this package. Owns the 37-field `ControllerInput`…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CONTROLLER.*` error-code registry and the typed `ControllerError` class used by every public surfac…
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
- [events](./src/events/events.md) — Typed event dispatcher used by `BaseController` to fan input, state and combo events out to listeners. Centralising the…
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
- [gamepad.internal](./src/gamepad/gamepad.internal.md) — Module-private helpers used by `GamepadController` to keep the main class file under the repository's 300-line hard cap…
- [gamepad](./src/gamepad/gamepad.md) — Browser-tier device adapter that polls the Web Gamepad API and normalises button + axis state into the canonical 37-fie…
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
- [keyboard](./src/keyboard/keyboard.md) — Browser-tier device adapter that normalises DOM `keydown`/`keyup` events into the canonical 37-field `ControllerInput`…
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
- [merger](./src/merger/merger.md) — `ControllerMerger` fuses an ordered list of `BaseController` children into a single virtual controller whose `Controlle…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
- [telemetry](./src/telemetry/telemetry.md) — Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`: two counters tracking input and state-change thr…
- [types](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
- [keys](./src/types/keys.md) — Provides the canonical iteration order over the 37 `ControllerInput` keys and a `createDefaultControllerInput()` factor…
<!-- end:auto-docs -->
## Modules
- [`base/base.md`](src/base/base.md) — `BaseController` lifecycle,
state, normalisation, pollRate gating.
- [`events/events.md`](src/events/events.md) — `InputEventEmitter`
typed dispatcher with disposable listeners.
- [`error-codes.md`](src/error-codes.md) — `CONTROLLER_CODES` registry
and `ControllerError` class.
- [`telemetry.md`](src/telemetry.md) — `controller_inputs_total`,
`controller_state_changes_total` counters and the pattern-match
duration histogram.
- [`types/keys.md`](src/types/keys.md) — `CONTROLLER_INPUT_KEYS` and
`createDefaultControllerInput`.
## Status
`0.1.0` — Chunk A foundation. Chunks B (device adapters) and C
(merger) extend the barrel with additional named exports.
## Verification
```bash
bunx turbo lint typecheck test --filter=@suaveplan/controller
bunx turbo test:coverage --filter=@suaveplan/controller
```
The coverage gate is 100/100/100/100 (istanbul). The package's
`tests/setup/polyfills.ts` registers an in-memory `MeterProvider` before
source modules import the OTel meter so the telemetry assertions resolve
against a real exporter, with no mocks.

View File

@@ -0,0 +1,61 @@
{
"name": "@suaveplan/controller",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal input controller foundation — unified 32-field ControllerInput shape, BaseController abstract base with Clock-injected pollRate gating and threshold/deadzone normalisation, typed InputEventEmitter, stable CONTROLLER.* error code registry, and OpenTelemetry instrumentation. Chunk A of the @suaveplan/controller migration; device adapters and merger ship in follow-up chunks.",
"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",
"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,121 @@
# `base` — `BaseController` abstract base
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Abstract base for every device-specific controller adapter shipped by
this package. Owns the 37-field `ControllerInput` state, per-input
gating (`{ threshold, deadzone }`), the typed `InputEventEmitter`, and
the `pollRate`-gated `update()` cadence. Subclasses landing in Chunk B
(`KeyboardController`, `GamepadController`, `TouchController`,
`MouseController`) and Chunk C (`ControllerMerger`) extend this class
and override `updateState()` to translate device-specific signals into
the canonical input shape.
## Features
- Injectable `Clock` (`@suaveplan/types`) — every wall-clock read goes
through `this.clock()` so tests substitute a `FakeClock` without
touching globals.
- Injectable `IdGenerator` — every instance carries a stable `id` used
by OTel attributes and audit logs.
- `pollRate` gating — repeated `update()` calls within
`1000 / pollRate` ms are coalesced; the first one inside the window
runs `updateState()`, the rest are no-ops.
- Per-input threshold + deadzone normalisation, clamp to `[0, 255]`,
integer rounding.
- Event-suppression: `input` and `state-change` events fire only when
the post-normalisation value differs from the previous value AND the
press-state changed accordingly.
- Strict-mode opt-in — `strict: true` makes post-`destroy()` `setState`
throw `ControllerError(DESTROYED_INSTANCE)` instead of silently
no-op'ing.
- Zod-validated boundary on constructor options; failures surface as
`ControllerError(INVALID_OPTIONS)` carrying the Zod issue list.
- Idempotent `destroy()` — safe to call multiple times.
- OpenTelemetry instrumentation — every `input` and `state-change` event
records into the package's counters, attributed to the subclass's
`device` tag.
## Usage: Basic
```ts
import { BaseController } from "@suaveplan/controller";
class FakeController extends BaseController {
protected override updateState(): void {
// pump device-specific signals into setState() / pendingStateUpdates
}
}
const c = new FakeController({ device: "fake", pollRate: 30 });
c.onInput("up", (input, value) => console.log(input, value));
c.setState({ up: 200 });
console.log(c.getValue("up")); // 200
c.destroy();
```
## Usage: Advanced
```ts
import type { Clock } from "@suaveplan/types";
import { BaseController, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
let t = 0;
const clock: Clock = () => t;
class TestController extends BaseController {
public constructor() {
super({ clock, pollRate: 10, device: "test", strict: true });
}
protected override updateState(): void {}
}
const c = new TestController();
c.setThreshold("lx", { deadzone: 40 });
c.setState({ lx: 20 });
console.log(c.getValue("lx")); // 0 — deadzone snapped to zero
c.destroy();
try {
c.setState({ up: 1 });
} catch (err) {
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
console.warn("controller is shut down");
}
}
```
## API Reference
- `new BaseController({ clock?, idGenerator?, pollRate?, device?, strict? })`
(abstract — must be extended)
- `onInput(input, cb): Disposer`
- `onAnyInput(cb): Disposer`
- `onCombo(cb): Disposer`
- `getState(): ControllerInput`
- `getValue(input): number`
- `setState(partial): void`
- `setThreshold(input, { threshold?, deadzone? }): void`
- `getDirectionalVector(): { x, y }`
- `update(): void` — pollRate-gated; subclass `updateState()` runs at
most once per `1000 / pollRate` ms window
- `destroy(): void` — idempotent; clears state, listeners, pending
updates, emitter
## Implementation Notes
The class is `abstract` so consumers can't instantiate it directly;
TypeScript enforces this and the destructor logic is meaningful only in
a subclass context. Threshold normalisation runs in the order
`clamp → round → deadzone → threshold` to match the upstream behaviour,
where the deadzone is checked against the clamped magnitude before the
threshold floor is applied. The `pendingStateUpdates` map is captured
before the gating check so subclass overrides of `updateState()` that
batch multiple `setState` calls (e.g. the merger) see a consistent map
across the tick. Strict mode is wired through to the inner
`InputEventEmitter` so the destroyed-state contract is enforced
end-to-end without duplicate guards.

View File

@@ -0,0 +1,454 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, IdGenerator } from "@suaveplan/types";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "../types/keys.js";
import { BaseController } from "./base.js";
class TestController extends BaseController {
/** Expose protected `updateState` so we can drive it from tests. */
public override updateState(): void {
// Subclasses populate pendingStateUpdates externally
}
/** Test hook to call updateInput directly for normalisation paths. */
public callUpdateInput(
input: Parameters<TestController["updateInput"]>[0],
value: number
): void {
this.updateInput(input, value);
}
/** Test hook to enqueue without triggering processStateUpdates yet. */
public enqueue(
input: Parameters<TestController["updateInput"]>[0],
value: number,
time: number
): void {
this.pendingStateUpdates.set(input, { value, time });
this.needsUpdate = true;
}
public flush(): void {
this.processStateUpdates();
}
}
function makeClock(): {
clock: Clock;
advance: (ms: number) => void;
now: () => number;
} {
let t = 0;
return {
clock: () => t,
advance: (ms) => {
t += ms;
},
now: () => t,
};
}
describe("BaseController — construction", () => {
it("defaults options", { timeout: 5000 }, () => {
const c = new TestController();
expect(c.id).toMatch(/^controller-\d+$/);
c.destroy();
});
it("accepts injected clock + idGenerator", { timeout: 5000 }, () => {
let calls = 0;
const ids: IdGenerator = () => `ctrl-${++calls}`;
const { clock } = makeClock();
const c = new TestController({ clock, idGenerator: ids });
expect(c.id).toBe("ctrl-1");
c.destroy();
});
it("throws INVALID_OPTIONS on pollRate < 1", { timeout: 5000 }, () => {
try {
new TestController({ pollRate: 0 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
expect(
(err.context as { issues: unknown[] }).issues.length
).toBeGreaterThan(0);
}
}
});
it("throws INVALID_OPTIONS on pollRate > 1000", { timeout: 5000 }, () => {
expect(() => new TestController({ pollRate: 5000 })).toThrow(
ControllerError
);
});
it("throws INVALID_OPTIONS on non-function clock", {
timeout: 5000,
}, () => {
expect(
() => new TestController({ clock: 42 as unknown as Clock })
).toThrow(ControllerError);
});
it("throws INVALID_OPTIONS on non-function idGenerator", {
timeout: 5000,
}, () => {
expect(
() =>
new TestController({
idGenerator: "x" as unknown as IdGenerator,
})
).toThrow(ControllerError);
});
it("throws INVALID_OPTIONS on empty device", { timeout: 5000 }, () => {
expect(() => new TestController({ device: "" })).toThrow(
ControllerError
);
});
it("throws INVALID_OPTIONS on unknown option key", {
timeout: 5000,
}, () => {
expect(
() =>
new TestController({
bogus: 1,
} as unknown as ConstructorParameters<typeof BaseController>[0])
).toThrow(ControllerError);
});
});
describe("BaseController — getState", () => {
it("returns a 37-field zero-initialised ControllerInput", {
timeout: 5000,
}, () => {
const c = new TestController();
const s = c.getState();
expect(Object.keys(s).length).toBe(CONTROLLER_INPUT_KEYS.length);
for (const key of CONTROLLER_INPUT_KEYS) {
expect(s[key]).toBe(0);
}
c.destroy();
});
it("returns a fresh object — mutation does not leak", {
timeout: 5000,
}, () => {
const c = new TestController();
const s = c.getState();
s.up = 200;
expect(c.getValue("up")).toBe(0);
c.destroy();
});
});
describe("BaseController — setState normalisation", () => {
it("clamps to [0, 255] and rounds", { timeout: 5000 }, () => {
const c = new TestController();
c.setState({ up: 999 });
expect(c.getValue("up")).toBe(255);
c.setState({ down: -5 });
expect(c.getValue("down")).toBe(0);
c.setState({ a: 12.6 });
expect(c.getValue("a")).toBe(13);
c.destroy();
});
it("deadzone zeroes sub-threshold values and emits no input event", {
timeout: 5000,
}, () => {
const c = new TestController();
c.setThreshold("lx", { deadzone: 30 });
let inputs = 0;
c.onInput("lx", () => {
inputs += 1;
});
c.setState({ lx: 20 });
expect(c.getValue("lx")).toBe(0);
expect(inputs).toBe(0);
c.destroy();
});
it("threshold zeroes values below threshold", { timeout: 5000 }, () => {
const c = new TestController();
c.setThreshold("rx", { threshold: 100 });
c.setState({ rx: 50 });
expect(c.getValue("rx")).toBe(0);
c.setState({ rx: 150 });
expect(c.getValue("rx")).toBe(150);
c.destroy();
});
it("emits input + state-change only when value differs", {
timeout: 5000,
}, () => {
const c = new TestController();
let inputs = 0;
let states = 0;
c.onInput("up", () => {
inputs += 1;
});
c.onAnyInput(() => {
states += 1;
});
c.setState({ up: 100 });
c.setState({ up: 100 }); // no change
c.setState({ up: 200 });
expect(inputs).toBe(2);
expect(states).toBe(2);
c.destroy();
});
it("setState ignores non-numeric values", { timeout: 5000 }, () => {
const c = new TestController();
c.setState({ up: "abc" as unknown as number });
expect(c.getValue("up")).toBe(0);
c.destroy();
});
it("setState throws INVALID_INPUT_KEY on unknown key", {
timeout: 5000,
}, () => {
const c = new TestController();
expect(() =>
c.setState({ bogus: 1 } as unknown as { up: number })
).toThrow(ControllerError);
try {
c.setState({ bogus: 1 } as unknown as { up: number });
} catch (err) {
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.INVALID_INPUT_KEY);
}
}
c.destroy();
});
it("setState during processing is a no-op", { timeout: 5000 }, () => {
const c = new TestController();
let called = 0;
c.onInput("up", () => {
called += 1;
// re-entrant call: should be ignored
c.setState({ down: 100 });
});
c.setState({ up: 100 });
expect(called).toBe(1);
expect(c.getValue("down")).toBe(0);
c.destroy();
});
});
describe("BaseController — setThreshold", () => {
it("throws INVALID_INPUT_KEY on unknown key", { timeout: 5000 }, () => {
const c = new TestController();
expect(() =>
c.setThreshold(
"bogus" as unknown as Parameters<typeof c.setThreshold>[0],
{ threshold: 100 }
)
).toThrow(ControllerError);
c.destroy();
});
it("defaults missing fields to 0", { timeout: 5000 }, () => {
const c = new TestController();
c.setThreshold("up", {});
// No throw, normalises with zero gates
c.setState({ up: 10 });
expect(c.getValue("up")).toBe(10);
c.destroy();
});
});
describe("BaseController — getDirectionalVector", () => {
it("returns right-left and down-up", { timeout: 5000 }, () => {
const c = new TestController();
c.setState({ right: 200, left: 50, down: 100, up: 10 });
expect(c.getDirectionalVector()).toEqual({ x: 150, y: 90 });
c.destroy();
});
});
describe("BaseController — update / pollRate gating", () => {
it("update is a no-op when needsUpdate is false", { timeout: 5000 }, () => {
const c = new TestController();
// No setState yet → needsUpdate is false
let calls = 0;
const original = c.updateState.bind(c);
c.updateState = () => {
calls += 1;
original();
};
c.update();
expect(calls).toBe(0);
c.destroy();
});
it("update respects pollRate gating", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const c = new TestController({ clock, pollRate: 10 }); // 100ms window
let runs = 0;
c.updateState = () => {
runs += 1;
};
c.enqueue("up", 100, 0);
c.update(); // first run — lastUpdate = 0
expect(runs).toBe(1);
// Force needsUpdate back on without advancing time — must be gated
c.needsUpdate = true;
c.update();
expect(runs).toBe(1);
advance(150);
c.needsUpdate = true;
c.update();
expect(runs).toBe(2);
c.destroy();
});
it("update on destroyed controller is no-op", { timeout: 5000 }, () => {
const c = new TestController();
c.destroy();
let runs = 0;
c.updateState = () => {
runs += 1;
};
c.update();
expect(runs).toBe(0);
});
});
describe("BaseController — destroy", () => {
it("destroy is idempotent", { timeout: 5000 }, () => {
const c = new TestController();
c.destroy();
expect(() => c.destroy()).not.toThrow();
});
it("destroy clears state and listeners", { timeout: 5000 }, () => {
const c = new TestController();
c.setState({ up: 200 });
let fired = 0;
c.onInput("up", () => {
fired += 1;
});
c.destroy();
expect(c.getValue("up")).toBe(0);
c.setState({ up: 100 });
expect(fired).toBe(0);
});
it("setState after destroy in non-strict mode is silent", {
timeout: 5000,
}, () => {
const c = new TestController();
c.destroy();
expect(() => c.setState({ up: 100 })).not.toThrow();
});
it("setState after destroy in strict mode throws DESTROYED_INSTANCE", {
timeout: 5000,
}, () => {
const c = new TestController({ strict: true });
c.destroy();
try {
c.setState({ up: 100 });
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
}
}
});
it("updateInput is a no-op on destroyed controller (defensive guard)", {
timeout: 5000,
}, () => {
const c = new TestController();
c.destroy();
c.callUpdateInput("up", 100);
// State was already cleared by destroy and updateInput skips
expect(c.getValue("up")).toBe(0);
});
});
describe("BaseController — base updateState default", () => {
it("default updateState is a no-op when not overridden", {
timeout: 5000,
}, () => {
class Default extends BaseController {}
const c = new Default();
c.setState({ up: 100 });
c.update();
// No throw, value still propagated via setState pipeline
expect(c.getValue("up")).toBe(100);
c.destroy();
});
});
describe("BaseController — processStateUpdates reentrance", () => {
it("returns early if called recursively (defensive guard)", {
timeout: 5000,
}, () => {
const c = new TestController();
let reentry = 0;
c.onInput("up", () => {
// We are inside processStateUpdates here; recursive flush should be a no-op.
c.flush();
reentry += 1;
});
c.setState({ up: 200 });
expect(reentry).toBe(1);
c.destroy();
});
});
describe("BaseController — combo forwarding", () => {
it("onCombo proxies to the inner emitter", { timeout: 5000 }, () => {
const c = new TestController();
const seen: string[][] = [];
c.onCombo((combo) => seen.push(combo));
// Drive emitCombo via emitter directly through a subclass-style call
(
c as unknown as {
eventEmitter: { emitCombo: (s: string[]) => void };
}
).eventEmitter.emitCombo(["up", "a"]);
expect(seen).toEqual([["up", "a"]]);
c.destroy();
});
});
describe("BaseController — internal state shape", () => {
it("createDefaultControllerInput is the canonical default", {
timeout: 5000,
}, () => {
const c = new TestController();
expect(c.getState()).toEqual(createDefaultControllerInput());
c.destroy();
});
it("press-only deltas are not skipped — value remains, pressed flips", {
timeout: 5000,
}, () => {
// Forces the branch where normalized === lastValue but wasPressed !== isPressed.
// Achievable by toggling threshold-gating after setting a value.
const c = new TestController();
let inputs = 0;
c.onInput("up", () => {
inputs += 1;
});
c.setState({ up: 100 });
// Now both pressed and value === 100. Re-emit same value → no event.
c.setState({ up: 100 });
expect(inputs).toBe(1);
c.destroy();
});
});

View File

@@ -0,0 +1,278 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import z from "zod";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import { InputEventEmitter } from "../events/events.js";
import { recordInput, recordStateChange } from "../telemetry/telemetry.js";
import type {
ComboCallback,
ControllerInput,
InputChangeCallback,
InputThresholds,
StateChangeCallback,
Vector2D,
} from "../types/controller.types.js";
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "../types/keys.js";
import type {
BaseControllerOptions,
InputBookkeeping,
ThresholdMap,
} from "./base.types.js";
const BaseControllerOptionsSchema = z
.object({
clock: z.custom<Clock>((v) => typeof v === "function").optional(),
idGenerator: z
.custom<IdGenerator>((v) => typeof v === "function")
.optional(),
pollRate: z.number().int().min(1).max(1000).optional(),
device: z.string().min(1).optional(),
strict: z.boolean().optional(),
})
.strict()
.optional();
const VALID_KEYS = new Set<keyof ControllerInput>(CONTROLLER_INPUT_KEYS);
const defaultClock: Clock = () => Date.now();
let instanceCounter = 0;
const defaultIdGenerator: IdGenerator = () => `controller-${++instanceCounter}`;
/**
* Abstract base class for every device-specific controller adapter.
*
* Owns the 37-field `ControllerInput` state, per-input gating
* (`{ threshold, deadzone }`), the typed `InputEventEmitter`, and the
* `pollRate`-gated `update()` cadence. Subclasses (`KeyboardController`,
* `GamepadController`, `ControllerMerger`) extend this class and
* override `updateState()` to read device state into `pendingStateUpdates`.
*
* All wall-clock reads go through the injected `Clock` so subclasses
* and tests can replace it with a `FakeClock` without touching globals.
*/
export abstract class BaseController {
protected state: ControllerInput = createDefaultControllerInput();
protected readonly thresholds: ThresholdMap = this.createThresholdMap();
protected readonly eventEmitter: InputEventEmitter;
protected isDestroyed = false;
protected readonly pollRate: number;
protected lastUpdate = Number.NEGATIVE_INFINITY;
protected readonly lastInputStates = new Map<
keyof ControllerInput,
InputBookkeeping
>();
protected needsUpdate = false;
protected isProcessingUpdate = false;
protected readonly pendingStateUpdates = new Map<
keyof ControllerInput,
{ value: number; time: number }
>();
protected readonly clock: Clock;
protected readonly idGenerator: IdGenerator;
protected readonly device: string;
protected readonly strict: boolean;
public readonly id: string;
protected constructor(options: BaseControllerOptions = {}) {
const parsed = BaseControllerOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_OPTIONS,
"BaseController received invalid options",
{ context: { issues: parsed.error.issues } }
);
}
this.clock = options.clock ?? defaultClock;
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
this.pollRate = options.pollRate ?? 60;
this.device = options.device ?? "base";
this.strict = options.strict ?? false;
this.id = this.idGenerator();
this.eventEmitter = new InputEventEmitter({ strict: this.strict });
}
public onInput(
input: keyof ControllerInput,
callback: InputChangeCallback
): () => void {
return this.eventEmitter.onInput(input, callback);
}
public onAnyInput(callback: StateChangeCallback): () => void {
return this.eventEmitter.onStateChange(callback);
}
public onCombo(callback: ComboCallback): () => void {
return this.eventEmitter.onCombo(callback);
}
public getState(): ControllerInput {
return { ...this.state };
}
public getValue(input: keyof ControllerInput): number {
return this.state[input];
}
public setState(newState: Partial<ControllerInput>): void {
if (this.isProcessingUpdate) {
return;
}
if (this.isDestroyed) {
this.guardDestroyed();
return;
}
const now = this.clock();
for (const [rawKey, value] of Object.entries(newState)) {
const key = rawKey as keyof ControllerInput;
if (!VALID_KEYS.has(key)) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_INPUT_KEY,
`Unknown input key: ${rawKey}`,
{ context: { input: rawKey } }
);
}
if (typeof value !== "number") {
continue;
}
this.pendingStateUpdates.set(key, { value, time: now });
}
this.needsUpdate = true;
this.processStateUpdates();
}
public setThreshold(
input: keyof ControllerInput,
thresholds: InputThresholds
): void {
if (!VALID_KEYS.has(input)) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_INPUT_KEY,
`Unknown input key: ${String(input)}`,
{ context: { input } }
);
}
this.thresholds[input] = {
threshold: thresholds.threshold ?? 0,
deadzone: thresholds.deadzone ?? 0,
};
}
public getDirectionalVector(): Vector2D {
return {
x: this.state.right - this.state.left,
y: this.state.down - this.state.up,
};
}
public update(): void {
if (this.isDestroyed) {
return;
}
if (!this.needsUpdate) {
return;
}
const now = this.clock();
const minUpdateInterval = 1000 / this.pollRate;
if (now - this.lastUpdate < minUpdateInterval) {
return;
}
this.lastUpdate = now;
this.updateState();
this.needsUpdate = false;
}
/** Overridden by subclasses to read device state into pending updates. */
protected updateState(): void {
/* base no-op */
}
protected processStateUpdates(): void {
if (this.isProcessingUpdate) {
return;
}
this.isProcessingUpdate = true;
try {
for (const [input, { value }] of this.pendingStateUpdates) {
this.updateInput(input, value);
}
this.pendingStateUpdates.clear();
} finally {
this.isProcessingUpdate = false;
}
}
protected updateInput(
input: keyof ControllerInput,
rawValue: number
): void {
if (this.isDestroyed) {
return;
}
const clamped = Math.max(0, Math.min(255, Math.round(rawValue)));
const { threshold = 0, deadzone = 0 } = this.thresholds[input];
let normalized = clamped;
if (Math.abs(normalized) < deadzone) {
normalized = 0;
}
if (normalized > 0 && threshold > 0 && normalized < threshold) {
normalized = 0;
}
const lastState = this.lastInputStates.get(input);
const lastValue = lastState?.value ?? 0;
const wasPressed = lastState?.pressed ?? false;
const isPressed = normalized > 0;
if (normalized === lastValue && wasPressed === isPressed) {
return;
}
const now = this.clock();
this.lastInputStates.set(input, {
value: normalized,
pressed: isPressed,
time: now,
});
const oldState = { ...this.state };
this.state[input] = normalized;
this.eventEmitter.emitInput(input, normalized);
recordInput({ device: this.device, input: input as string });
this.eventEmitter.emitStateChange(this.state, oldState);
recordStateChange({ device: this.device });
}
public destroy(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.eventEmitter.destroy();
this.pendingStateUpdates.clear();
this.lastInputStates.clear();
this.state = createDefaultControllerInput();
}
private guardDestroyed(): void {
if (this.strict) {
throw new ControllerError(
CONTROLLER_CODES.DESTROYED_INSTANCE,
"Operation attempted on a destroyed controller"
);
}
}
private createThresholdMap(): ThresholdMap {
const out = {} as ThresholdMap;
for (const key of CONTROLLER_INPUT_KEYS) {
out[key] = {};
}
return out;
}
}

View File

@@ -0,0 +1,55 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import type {
ControllerInput,
InputThresholds,
} from "../types/controller.types.js";
/** Construction options accepted by `BaseController`. */
export interface BaseControllerOptions {
/**
* Wall-clock source used for `pollRate` gating and listener-state
* timestamps. Defaults to `() => Date.now()` when omitted.
*/
clock?: Clock;
/**
* Identifier factory used to label the controller instance (e.g. for
* telemetry attributes). Defaults to a monotonically increasing
* `"controller-<n>"` counter that lives on the class.
*/
idGenerator?: IdGenerator;
/**
* Maximum updates per second the controller will process. Values
* outside `[1, 1000]` are rejected at the Zod boundary. Defaults to
* 60 Hz.
*/
pollRate?: number;
/**
* Device label written to OTel attributes (e.g. `"keyboard"`,
* `"gamepad"`). Subclasses set their canonical value here; the base
* default is `"base"`.
*/
device?: string;
/**
* Strict-mode opt-in — when `true`, operations on a destroyed
* controller throw `ControllerError(DESTROYED_INSTANCE)`. Defaults
* to `false` (silent no-op, matching the upstream contract).
*/
strict?: boolean;
}
/**
* Internal per-input bookkeeping used by `BaseController.updateInput`.
* Exposed via the types module so test files can pin behaviour without
* reaching into the protected surface.
*/
export interface InputBookkeeping {
value: number;
pressed: boolean;
time: number;
}
/** Snapshot the controller exports via `getState()`. */
export type ControllerSnapshotState = Readonly<ControllerInput>;
/** Map from input key to gating configuration. */
export type ThresholdMap = Record<keyof ControllerInput, InputThresholds>;

View File

@@ -0,0 +1,7 @@
export { BaseController } from "./base.js";
export type {
BaseControllerOptions,
ControllerSnapshotState,
InputBookkeeping,
ThresholdMap,
} from "./base.types.js";

View File

@@ -0,0 +1,126 @@
# `error-codes` — `CONTROLLER.*` registry + `ControllerError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `CONTROLLER.*` error-code registry and the typed
`ControllerError` class used by every public surface in
`@suaveplan/controller`. Centralising the codes here lets consumers
switch on `error.code` against a finite literal union without needing
`instanceof`, and survives boundary serialisation (e.g. when an error is
posted across a worker channel and reconstructed by the receiver).
## Features
- `CONTROLLER_CODES` — frozen record from reason → fully-qualified code
string, built via `@suaveplan/error`'s `defineCodes` helper.
- `ControllerError extends SuaveplanError<ControllerCode>` — concrete
error class with the `code` field narrowed to the literal union.
- Stable namespace prefix (`CONTROLLER.`) shared across this package and
every future Chunk B/C device adapter and merger code.
- Forwards `cause` and `context` to the `SuaveplanError` base so the
error wire format (validated by `@suaveplan/error`'s Zod schema) is
preserved on traversal.
## Usage: Basic
```ts
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
throw new ControllerError(
CONTROLLER_CODES.INVALID_INPUT_KEY,
"Unknown input key passed to setThreshold()",
{ context: { input: "notAKey" } }
);
```
## Usage: Advanced
```ts
import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
function handle(err: unknown): void {
if (!(err instanceof ControllerError)) {
throw err;
}
switch (err.code) {
case CONTROLLER_CODES.INVALID_OPTIONS:
console.warn("config rejected", err.context?.issues);
break;
case CONTROLLER_CODES.INVALID_INPUT_KEY:
console.warn("unknown input", err.context?.input);
break;
case CONTROLLER_CODES.DESTROYED_INSTANCE:
console.warn("operation on destroyed controller");
break;
case CONTROLLER_CODES.KEYBOARD_TARGET_MISSING:
console.warn("keyboard target unresolved", err.context);
break;
case CONTROLLER_CODES.INVALID_KEY_MAP:
console.warn("invalid key map entry", err.context);
break;
case CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE:
console.warn("gamepad API unavailable");
break;
case CONTROLLER_CODES.INVALID_GAMEPAD_MAP:
console.warn("invalid gamepad map entry", err.context);
break;
case CONTROLLER_CODES.DUPLICATE_CHILD:
console.warn("merger child already attached", err.context);
break;
case CONTROLLER_CODES.UNKNOWN_CHILD:
console.warn("merger child not attached", err.context);
break;
}
}
```
The switch is exhaustive against `ControllerCode`; adding a new code
without handling it surfaces as a `noFallthroughCasesInSwitch`-friendly
type error in strict-mode consumers.
## API Reference
- `CONTROLLER_CODES`
- `INVALID_OPTIONS` — Zod boundary rejected the constructor options
bundle; `context.issues` carries the Zod issue array.
- `INVALID_INPUT_KEY` — `setThreshold` (or future device adapters)
received an input key not present in `CONTROLLER_INPUT_KEYS`.
- `DESTROYED_INSTANCE` — operation attempted after `destroy()` on an
instance that does not silently no-op (strict-mode opt-in).
- `KEYBOARD_TARGET_MISSING` — `KeyboardController.attach()` was
called but neither an `EventTargetLike` was injected nor a global
`window` was discoverable; `context.target` reports the resolved
value.
- `INVALID_KEY_MAP` — `KeyboardController` received a `keyMap` whose
value is not one of `CONTROLLER_INPUT_KEYS`; `context.entry`
carries the `[key, input]` tuple that failed validation.
- `GAMEPAD_API_UNAVAILABLE` — `GamepadController.update()` was
called but neither a `GamepadSource` was injected nor a global
`navigator.getGamepads` was discoverable.
- `INVALID_GAMEPAD_MAP` — `GamepadController` received a
`buttonMap`/`axisMap` whose value targets an input key not present
in `CONTROLLER_INPUT_KEYS`; `context.entry` carries the failing
tuple.
- `DUPLICATE_CHILD` — `ControllerMerger.addController()` received a
child already attached, or a second child whose `id` collides with
an existing one; `context` carries the child id.
- `UNKNOWN_CHILD` — `ControllerMerger.removeController()` was called
with a child that is not currently attached.
- `ControllerCode` — literal union of every code string.
- `class ControllerError extends SuaveplanError<ControllerCode>` —
constructor: `(code, message, { cause?, context? })`.
## Implementation Notes
Codes are registered with `defineCodes("CONTROLLER", [...] as const)` so
the returned record is `Object.freeze`d at module load. Chunks B and C
will need to extend the union; the pattern is to declare a second
`defineCodes(...)` block in their own modules and merge at the package
barrel via a union type. The class deliberately does not capture the
`Clock` directly — `SuaveplanError`'s base captures `Date.now()` via the
optional `clock` option, which the controller package opts out of so the
error timestamps remain unambiguously wall-clock for log correlation.

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTROLLER_CODES, ControllerError } from "./error-codes.js";
describe("CONTROLLER_CODES", () => {
it("namespaces every code under CONTROLLER.", { timeout: 5000 }, () => {
for (const value of Object.values(CONTROLLER_CODES)) {
expect(value.startsWith("CONTROLLER.")).toBe(true);
}
});
it("registers the Chunk A foundation codes", { timeout: 5000 }, () => {
expect(CONTROLLER_CODES.INVALID_OPTIONS).toBe(
"CONTROLLER.INVALID_OPTIONS"
);
expect(CONTROLLER_CODES.INVALID_INPUT_KEY).toBe(
"CONTROLLER.INVALID_INPUT_KEY"
);
expect(CONTROLLER_CODES.DESTROYED_INSTANCE).toBe(
"CONTROLLER.DESTROYED_INSTANCE"
);
});
it("registers the Chunk B device-adapter codes", { timeout: 5000 }, () => {
expect(CONTROLLER_CODES.KEYBOARD_TARGET_MISSING).toBe(
"CONTROLLER.KEYBOARD_TARGET_MISSING"
);
expect(CONTROLLER_CODES.INVALID_KEY_MAP).toBe(
"CONTROLLER.INVALID_KEY_MAP"
);
expect(CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE).toBe(
"CONTROLLER.GAMEPAD_API_UNAVAILABLE"
);
expect(CONTROLLER_CODES.INVALID_GAMEPAD_MAP).toBe(
"CONTROLLER.INVALID_GAMEPAD_MAP"
);
});
it("registers the Chunk C merger codes", { timeout: 5000 }, () => {
expect(CONTROLLER_CODES.DUPLICATE_CHILD).toBe(
"CONTROLLER.DUPLICATE_CHILD"
);
expect(CONTROLLER_CODES.UNKNOWN_CHILD).toBe("CONTROLLER.UNKNOWN_CHILD");
});
it("returns a frozen registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(CONTROLLER_CODES)).toBe(true);
});
});
describe("ControllerError", () => {
it("constructs with code, message, cause and context", {
timeout: 5000,
}, () => {
const cause = new Error("root");
const err = new ControllerError(
CONTROLLER_CODES.INVALID_OPTIONS,
"boom",
{ cause, context: { issues: [{ path: ["x"], message: "bad" }] } }
);
expect(err).toBeInstanceOf(ControllerError);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ControllerError");
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
expect(err.message).toBe("boom");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({
issues: [{ path: ["x"], message: "bad" }],
});
});
it("constructs without optional fields", { timeout: 5000 }, () => {
const err = new ControllerError(
CONTROLLER_CODES.DESTROYED_INSTANCE,
"destroyed"
);
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
expect(err.cause).toBeUndefined();
expect(err.context).toBeUndefined();
});
});

View File

@@ -0,0 +1,60 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/controller`.
*
* Codes follow `CONTROLLER.<REASON>` and are resolved via
* `@suaveplan/error`'s `defineCodes`. Consumers SHOULD discriminate on
* `error.code` rather than `instanceof` so the namespace survives module
* boundaries and runtime serialisation.
*
* Chunk A registers the foundation codes (Zod boundary, threshold-key
* validation, destroyed-state guard). Chunks B (device adapters) and C
* (merger) append device + merger codes by re-declaring the union; this
* file is the canonical definition list.
*/
export const CONTROLLER_CODES = defineCodes("CONTROLLER", [
"INVALID_OPTIONS",
"INVALID_INPUT_KEY",
"DESTROYED_INSTANCE",
"KEYBOARD_TARGET_MISSING",
"INVALID_KEY_MAP",
"GAMEPAD_API_UNAVAILABLE",
"INVALID_GAMEPAD_MAP",
"DUPLICATE_CHILD",
"UNKNOWN_CHILD",
] as const);
export type ControllerCode =
(typeof CONTROLLER_CODES)[keyof typeof CONTROLLER_CODES];
/**
* Typed error for every `@suaveplan/controller` failure path.
*
* Extends `SuaveplanError<ControllerCode>` so the `code` field narrows
* against the literal union and consumers get full type-checking on
* `switch (err.code)` blocks.
*
* @example
* ```ts
* import { CONTROLLER_CODES, ControllerError } from "@suaveplan/controller";
*
* try {
* controller.setThreshold("notAKey" as never, { threshold: 100 });
* } catch (err) {
* if (err instanceof ControllerError && err.code === CONTROLLER_CODES.INVALID_INPUT_KEY) {
* console.error("unknown input key", err.context);
* }
* }
* ```
*/
export class ControllerError extends SuaveplanError<ControllerCode> {
public constructor(
code: ControllerCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "ControllerError";
}
}

View File

@@ -0,0 +1,5 @@
export type { ControllerCode } from "./error-codes.js";
export {
CONTROLLER_CODES,
ControllerError,
} from "./error-codes.js";

View File

@@ -0,0 +1,90 @@
# `events` — `InputEventEmitter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Typed event dispatcher used by `BaseController` to fan input, state and
combo events out to listeners. Centralising the dispatcher in its own
module keeps the lifecycle FSM in `BaseController` focused on
normalisation logic, and lets downstream consumers (e.g. the merger in
Chunk C) reuse the same disposable-listener contract without depending
on the base class.
## Features
- Three typed channels: `onInput(key, cb)`, `onStateChange(cb)`,
`onCombo(cb)`. Every registration returns a `Disposer` that removes
exactly the registered listener without affecting siblings.
- Per-key bucket storage with `Set` semantics: O(1) add, O(1) remove,
iteration in insertion order.
- Last-value tracking — each `emitInput` passes the previous value to
the listener so consumers can compute deltas without storing state.
- Strict-mode opt-in: `new InputEventEmitter({ strict: true })` throws
`ControllerError(DESTROYED_INSTANCE)` when an `on*` method is called
after `destroy()`. Default mode silently returns a no-op disposer.
- Zod-validated options boundary — invalid construction throws
`ControllerError(INVALID_OPTIONS)` with the Zod issue array in
`context.issues`.
- Idempotent `destroy()` — calling twice is safe.
## Usage: Basic
```ts
import { InputEventEmitter } from "@suaveplan/controller";
const emitter = new InputEventEmitter();
const off = emitter.onInput("up", (input, value, prev) => {
console.log(input, value, prev);
});
emitter.emitInput("up", 255);
off();
emitter.destroy();
```
## Usage: Advanced
```ts
import { InputEventEmitter, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";
const emitter = new InputEventEmitter({ strict: true });
emitter.destroy();
try {
emitter.onInput("a", () => undefined);
} catch (err) {
if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
console.warn("emitter no longer accepting listeners");
}
}
```
The strict mode is what `BaseController` opts into to enforce its own
destroyed-state guarantees; default mode preserves the upstream emitter
shape used by older fan-out callers that expect post-destroy
registrations to be silent.
## API Reference
- `new InputEventEmitter({ strict? }: InputEventEmitterOptions)`
- `onInput(input, cb): Disposer`
- `onStateChange(cb): Disposer`
- `onCombo(cb): Disposer`
- `emitInput(input, value): void`
- `emitStateChange(state, oldState?): void`
- `emitCombo(combo): void`
- `clear(): void`
- `destroy(): void`
- `destroyed: boolean` (getter)
## Implementation Notes
The per-key bucket is allocated lazily so an emitter with no listeners
on a given key pays no map cost. Disposers capture the `callback` and
`input` by closure rather than indexing into the listener registry, so
multiple `onInput("up", cb)` calls with the same callback are coalesced
by the `Set` (no double-fire), and disposing one call removes the entry
cleanly. `emitInput` records the new value into `lastInputValues` even
when there is no registered listener, so a late subscriber's first event
sees the correct `prevValue`.

View File

@@ -0,0 +1,309 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import { createDefaultControllerInput } from "../types/keys.js";
import { InputEventEmitter } from "./events.js";
describe("InputEventEmitter — construction", () => {
it("accepts no options", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
expect(e.destroyed).toBe(false);
});
it("accepts strict: true", { timeout: 5000 }, () => {
const e = new InputEventEmitter({ strict: true });
expect(e.destroyed).toBe(false);
});
it("throws ControllerError(INVALID_OPTIONS) on bad options", {
timeout: 5000,
}, () => {
expect(
() =>
new InputEventEmitter({
strict: "yes" as unknown as boolean,
})
).toThrow(ControllerError);
try {
new InputEventEmitter({ strict: "yes" as unknown as boolean });
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.INVALID_OPTIONS);
expect(
(err.context as { issues: unknown[] }).issues.length
).toBeGreaterThan(0);
}
}
});
it("rejects unknown option keys via strict zod schema", {
timeout: 5000,
}, () => {
expect(
() =>
new InputEventEmitter({
unexpected: 1,
} as unknown as { strict?: boolean })
).toThrow(ControllerError);
});
});
describe("InputEventEmitter — onInput / emitInput", () => {
it("invokes registered listeners with (input, value, prevValue)", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
const calls: [string, number, number][] = [];
e.onInput("up", (input, value, prev) => {
calls.push([input as string, value, prev]);
});
e.emitInput("up", 100);
e.emitInput("up", 200);
expect(calls).toEqual([
["up", 100, 0],
["up", 200, 100],
]);
});
it("disposer removes only the registered listener", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
let a = 0;
let b = 0;
const offA = e.onInput("up", () => {
a += 1;
});
e.onInput("up", () => {
b += 1;
});
offA();
e.emitInput("up", 1);
expect(a).toBe(0);
expect(b).toBe(1);
});
it("disposer cleans up empty bucket on the inputListeners map", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
const off = e.onInput("up", () => undefined);
off();
// Re-registering recreates the bucket — exercises lazy path again
let called = 0;
e.onInput("up", () => {
called += 1;
});
e.emitInput("up", 1);
expect(called).toBe(1);
});
it("disposer is safe to call after clear()", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
const off = e.onInput("up", () => undefined);
e.clear();
expect(() => off()).not.toThrow();
});
it("returns no-op disposer in non-strict mode after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
e.destroy();
const off = e.onInput("up", () => undefined);
expect(typeof off).toBe("function");
expect(() => off()).not.toThrow();
});
it("emitInput is a no-op after destroy", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
let count = 0;
e.onInput("up", () => {
count += 1;
});
e.destroy();
e.emitInput("up", 100);
expect(count).toBe(0);
});
it("emitInput without listeners still tracks lastInputValues", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
e.emitInput("up", 10);
const calls: number[] = [];
e.onInput("up", (_input, _value, prev) => {
calls.push(prev);
});
e.emitInput("up", 20);
expect(calls).toEqual([10]);
});
});
describe("InputEventEmitter — onStateChange / emitStateChange", () => {
it("invokes state listeners with state + optional old state", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
const seen: Array<[number, number | undefined]> = [];
e.onStateChange((state, old) => {
seen.push([state.up, old?.up]);
});
const state = createDefaultControllerInput();
state.up = 100;
e.emitStateChange(state, { up: 50 });
e.emitStateChange(state);
expect(seen).toEqual([
[100, 50],
[100, undefined],
]);
});
it("emitStateChange is a no-op after destroy", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
let count = 0;
e.onStateChange(() => {
count += 1;
});
e.destroy();
e.emitStateChange(createDefaultControllerInput());
expect(count).toBe(0);
});
it("disposer removes the state listener", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
let count = 0;
const off = e.onStateChange(() => {
count += 1;
});
off();
e.emitStateChange(createDefaultControllerInput());
expect(count).toBe(0);
});
it("returns no-op disposer on onStateChange after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
e.destroy();
const off = e.onStateChange(() => undefined);
expect(() => off()).not.toThrow();
});
});
describe("InputEventEmitter — onCombo / emitCombo", () => {
it("invokes combo listeners with the sequence", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
const seen: string[][] = [];
e.onCombo((c) => {
seen.push(c);
});
e.emitCombo(["up", "a"]);
expect(seen).toEqual([["up", "a"]]);
});
it("disposer removes the combo listener", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
let count = 0;
const off = e.onCombo(() => {
count += 1;
});
off();
e.emitCombo(["up"]);
expect(count).toBe(0);
});
it("emitCombo is a no-op after destroy", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
let count = 0;
e.onCombo(() => {
count += 1;
});
e.destroy();
e.emitCombo(["up"]);
expect(count).toBe(0);
});
it("returns no-op disposer on onCombo after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
e.destroy();
const off = e.onCombo(() => undefined);
expect(() => off()).not.toThrow();
});
});
describe("InputEventEmitter — strict mode", () => {
it("throws DESTROYED_INSTANCE on onInput after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter({ strict: true });
e.destroy();
try {
e.onInput("up", () => undefined);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.DESTROYED_INSTANCE);
}
}
});
it("throws DESTROYED_INSTANCE on onStateChange after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter({ strict: true });
e.destroy();
expect(() => e.onStateChange(() => undefined)).toThrow(ControllerError);
});
it("throws DESTROYED_INSTANCE on onCombo after destroy", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter({ strict: true });
e.destroy();
expect(() => e.onCombo(() => undefined)).toThrow(ControllerError);
});
});
describe("InputEventEmitter — lifecycle", () => {
it("destroy is idempotent", { timeout: 5000 }, () => {
const e = new InputEventEmitter();
e.destroy();
expect(e.destroyed).toBe(true);
expect(() => e.destroy()).not.toThrow();
expect(e.destroyed).toBe(true);
});
it("clear removes all listeners but keeps the emitter usable", {
timeout: 5000,
}, () => {
const e = new InputEventEmitter();
let count = 0;
e.onInput("up", () => {
count += 1;
});
e.onStateChange(() => {
count += 1;
});
e.onCombo(() => {
count += 1;
});
e.clear();
e.emitInput("up", 1);
e.emitStateChange(createDefaultControllerInput());
e.emitCombo(["up"]);
expect(count).toBe(0);
// Still alive — can register again
e.onInput("up", () => {
count += 1;
});
e.emitInput("up", 1);
expect(count).toBe(1);
});
});

View File

@@ -0,0 +1,172 @@
import z from "zod";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import type {
ComboCallback,
ControllerInput,
InputChangeCallback,
StateChangeCallback,
} from "../types/controller.types.js";
import type {
Disposer,
InputEventEmitterApi,
InputEventEmitterOptions,
} from "./events.types.js";
const InputEventEmitterOptionsSchema = z
.object({
strict: z.boolean().optional(),
})
.strict()
.optional();
const noop: Disposer = () => {
/* no-op */
};
/**
* Typed event dispatcher for `BaseController` and downstream consumers.
*
* Listeners are stored in `Set`s so registration order is preserved and
* disposal is O(1). Every `on*` method returns a `Disposer` that removes
* exactly the listener that was registered, never affecting siblings.
*
* In non-strict mode (default) post-`destroy()` registrations return a
* no-op disposer, matching the upstream emitter contract. In strict mode
* they throw `ControllerError(DESTROYED_INSTANCE)`.
*/
export class InputEventEmitter implements InputEventEmitterApi {
private readonly inputListeners = new Map<
keyof ControllerInput,
Set<InputChangeCallback>
>();
private readonly stateListeners = new Set<StateChangeCallback>();
private readonly comboListeners = new Set<ComboCallback>();
private readonly lastInputValues = new Map<keyof ControllerInput, number>();
private readonly strict: boolean;
private isDestroyed = false;
public constructor(options: InputEventEmitterOptions = {}) {
const parsed = InputEventEmitterOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_OPTIONS,
"InputEventEmitter received invalid options",
{ context: { issues: parsed.error.issues } }
);
}
this.strict = options.strict ?? false;
}
public get destroyed(): boolean {
return this.isDestroyed;
}
public onInput(
input: keyof ControllerInput,
callback: InputChangeCallback
): Disposer {
if (this.isDestroyed) {
return this.guardDestroyed();
}
let bucket = this.inputListeners.get(input);
if (bucket === undefined) {
bucket = new Set();
this.inputListeners.set(input, bucket);
}
bucket.add(callback);
return () => {
const listeners = this.inputListeners.get(input);
if (listeners === undefined) {
return;
}
listeners.delete(callback);
if (listeners.size === 0) {
this.inputListeners.delete(input);
}
};
}
public onStateChange(callback: StateChangeCallback): Disposer {
if (this.isDestroyed) {
return this.guardDestroyed();
}
this.stateListeners.add(callback);
return () => {
this.stateListeners.delete(callback);
};
}
public onCombo(callback: ComboCallback): Disposer {
if (this.isDestroyed) {
return this.guardDestroyed();
}
this.comboListeners.add(callback);
return () => {
this.comboListeners.delete(callback);
};
}
public emitInput(input: keyof ControllerInput, value: number): void {
if (this.isDestroyed) {
return;
}
const prevValue = this.lastInputValues.get(input) ?? 0;
this.lastInputValues.set(input, value);
const listeners = this.inputListeners.get(input);
if (listeners === undefined) {
return;
}
for (const callback of listeners) {
callback(input, value, prevValue);
}
}
public emitStateChange(
state: ControllerInput,
oldState?: Partial<ControllerInput>
): void {
if (this.isDestroyed) {
return;
}
for (const callback of this.stateListeners) {
callback(state, oldState);
}
}
public emitCombo(combo: string[]): void {
if (this.isDestroyed) {
return;
}
for (const callback of this.comboListeners) {
callback(combo);
}
}
public clear(): void {
this.inputListeners.clear();
this.stateListeners.clear();
this.comboListeners.clear();
this.lastInputValues.clear();
}
public destroy(): void {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.clear();
}
private guardDestroyed(): Disposer {
if (this.strict) {
throw new ControllerError(
CONTROLLER_CODES.DESTROYED_INSTANCE,
"InputEventEmitter has been destroyed"
);
}
return noop;
}
}

View File

@@ -0,0 +1,39 @@
import type {
ComboCallback,
ControllerInput,
InputChangeCallback,
StateChangeCallback,
} from "../types/controller.types.js";
/** Disposer returned by every `on*` registration. */
export type Disposer = () => void;
/** Construction options for `InputEventEmitter`. */
export interface InputEventEmitterOptions {
/**
* Strict mode — when `true`, post-`destroy()` `on*` registrations throw
* `ControllerError(DESTROYED_INSTANCE, …)` instead of silently returning
* a no-op disposer. Defaults to `false` for back-compat with the
* upstream emitter shape.
*/
strict?: boolean;
}
/** Shape of the typed emitter exported from this module. */
export interface InputEventEmitterApi {
onInput(
input: keyof ControllerInput,
callback: InputChangeCallback
): Disposer;
onStateChange(callback: StateChangeCallback): Disposer;
onCombo(callback: ComboCallback): Disposer;
emitInput(input: keyof ControllerInput, value: number): void;
emitStateChange(
state: ControllerInput,
oldState?: Partial<ControllerInput>
): void;
emitCombo(combo: string[]): void;
clear(): void;
destroy(): void;
readonly destroyed: boolean;
}

View File

@@ -0,0 +1,6 @@
export { InputEventEmitter } from "./events.js";
export type {
Disposer,
InputEventEmitterApi,
InputEventEmitterOptions,
} from "./events.types.js";

View File

@@ -0,0 +1,114 @@
# `gamepad.internal` — gamepad-adapter validators + global resolver
## Purpose
Module-private helpers used by `GamepadController` to keep the main
class file under the repository's 300-line hard cap. Centralising
these as standalone functions also makes them easy to fuzz from
focused unit tests in `gamepad.test.ts` without instantiating a
full controller.
The exports are not part of the package's public API — they are
re-bound by name inside `GamepadController` and the package barrel
does not surface them. Treat the file as the implementation backing
store for the gamepad adapter's option-validation and global
discovery surface.
## Features
- `validateButtonMap(map)` — verifies every entry in a
`GamepadButtonMap` targets a key listed in
`CONTROLLER_INPUT_KEYS`. Returns a frozen shallow copy on
success; throws `ControllerError(INVALID_GAMEPAD_MAP)` on the
first invalid entry with the offending tuple in `context.entry`.
- `validateAxisMap(map)` — verifies each
`positive` / `negative` / `analog` binding inside every axis
entry. Each entry is independently frozen, then the outer record
is frozen. Errors carry the `[rawIndex, role, value]` tuple in
`context.entry`.
- `resolveGlobalGamepadSource()` — discovers
`globalThis.navigator.getGamepads` lazily, returning a bound
`GamepadSource` when the global is present and a function. Returns
`undefined` otherwise — the adapter only throws
`GAMEPAD_API_UNAVAILABLE` when `update()` is later called without
having injected a source.
## Usage: Basic
```ts
import {
validateButtonMap,
validateAxisMap,
} from "./gamepad.internal.js";
const buttonMap = validateButtonMap({ 0: "a", 1: "b" });
const axisMap = validateAxisMap({
0: { positive: "right", negative: "left", analog: "lx" },
});
```
## Usage: Advanced
The validators throw at the first invalid entry, so callers that
want to collect every problem can wrap each entry in a try/catch:
```ts
import {
validateButtonMap,
validateAxisMap,
} from "./gamepad.internal.js";
const candidate = { 0: "a", 99: "bogus" } as Record<number, string>;
const errors: unknown[] = [];
try {
validateButtonMap(candidate as Parameters<typeof validateButtonMap>[0]);
} catch (err) {
errors.push(err);
}
if (errors.length === 0) {
// candidate is valid
}
// Hosts without `navigator` (Node, Bun outside a DOM polyfill)
// receive `undefined` and must inject a source explicitly:
import { resolveGlobalGamepadSource } from "./gamepad.internal.js";
const source = resolveGlobalGamepadSource();
if (source === undefined) {
// fall back to a virtual source
}
```
## API Reference
- `validateButtonMap(map: GamepadButtonMap): GamepadButtonMap` —
validates + freezes a button map. Throws
`ControllerError(INVALID_GAMEPAD_MAP)` on invalid entries.
- `validateAxisMap(map: GamepadAxisMap): GamepadAxisMap` —
validates + freezes an axis map, including each per-binding
inner object. Throws `ControllerError(INVALID_GAMEPAD_MAP)` on
invalid entries.
- `resolveGlobalGamepadSource(): GamepadSource | undefined` — best
-effort discovery of the host's `navigator.getGamepads`. Always
returns synchronously and never throws.
## Implementation Notes
- Validators take the input map as a value (not a class field) so
the unit tests can exercise them without standing up a full
`GamepadController`. The maps are checked once at construction
time; consumers cannot mutate the runtime-bound versions.
- `Object.freeze` is applied at two levels for axis maps (each
binding and the outer record) so accidental rebinding via
`axisMap[2] = …` surfaces as a `TypeError` in strict-mode
consumers rather than silently mutating the adapter.
- `resolveGlobalGamepadSource` binds the discovered function to
`navigator` so callers that later strip the receiver (e.g.
destructuring) keep the correct `this`. Returning `() =>
getGamepads()` rather than the raw bound function keeps the
signature predictable when the underlying global is replaced
after construction (rare, but observed in some test harnesses).
- The function `globalThis as unknown as { navigator?: … }` cast is
the only place in the gamepad module that touches a host global;
centralising it here keeps the cast localised and means the
Biome `noExplicitAny` rule never has to allow exceptions in the
main class file.

View File

@@ -0,0 +1,71 @@
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
import type {
GamepadAxisBinding,
GamepadAxisMap,
GamepadButtonMap,
GamepadLike,
GamepadSource,
} from "./gamepad.types.js";
const VALID_INPUT_KEYS = new Set<string>(
CONTROLLER_INPUT_KEYS as readonly string[]
);
/** Validate every button-map entry against `CONTROLLER_INPUT_KEYS`. */
export function validateButtonMap(map: GamepadButtonMap): GamepadButtonMap {
for (const [index, input] of Object.entries(map)) {
if (!VALID_INPUT_KEYS.has(input as string)) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP,
`GamepadController buttonMap entry "${index}" targets unknown input "${input}"`,
{ context: { entry: [index, input] } }
);
}
}
return Object.freeze({ ...map });
}
/** Validate every axis-map binding against `CONTROLLER_INPUT_KEYS`. */
export function validateAxisMap(map: GamepadAxisMap): GamepadAxisMap {
const frozen: Record<number, GamepadAxisBinding> = {};
for (const [rawIndex, binding] of Object.entries(map)) {
const checks: Array<
["positive" | "negative" | "analog", string | undefined]
> = [
["positive", binding.positive],
["negative", binding.negative],
["analog", binding.analog],
];
for (const [role, value] of checks) {
if (value !== undefined && !VALID_INPUT_KEYS.has(value)) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP,
`GamepadController axisMap entry "${rawIndex}.${role}" targets unknown input "${value}"`,
{ context: { entry: [rawIndex, role, value] } }
);
}
}
frozen[Number(rawIndex)] = Object.freeze({ ...binding });
}
return Object.freeze(frozen);
}
/** Resolve `navigator.getGamepads` from the host without referencing it at module-load. */
export function resolveGlobalGamepadSource(): GamepadSource | undefined {
const nav = (
globalThis as unknown as {
navigator?: {
getGamepads?: () => readonly (GamepadLike | null | undefined)[];
};
}
).navigator;
if (nav !== undefined && typeof nav.getGamepads === "function") {
const getGamepads = nav.getGamepads.bind(nav);
return () => getGamepads();
}
return undefined;
}

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { GamepadController } from "./gamepad.js";
import type { GamepadLike } from "./gamepad.types.js";
function makeClock(): {
clock: Clock;
advance: (ms: number) => void;
} {
let t = 0;
return {
clock: () => t,
advance: (ms) => {
t += ms;
},
};
}
function makePad(
buttons: ReadonlyArray<{ pressed: boolean; value?: number } | null>,
axes: readonly number[],
extra: { connected?: boolean } = {}
): GamepadLike {
return {
buttons: buttons.map((b) =>
b === null ? { pressed: false, value: 0 } : b
),
axes,
...extra,
};
}
describe("GamepadController — connection lifecycle", () => {
it("zeros state on disconnect (null pad)", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
let connected = true;
const pads: () => (GamepadLike | null)[] = () =>
connected ? [makePad([{ pressed: true, value: 1 }], [])] : [null];
const gp = new GamepadController({ clock, source: pads });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
connected = false;
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(0);
gp.destroy();
});
it("zeros state on undefined slot", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
let connected = true;
const pads: () => (GamepadLike | undefined)[] = () =>
connected
? [makePad([{ pressed: true, value: 1 }], [])]
: [undefined];
const gp = new GamepadController({ clock, source: pads });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
connected = false;
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(0);
gp.destroy();
});
it("handles connected: false explicitly", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
let connected = true;
const pads = (): (GamepadLike | null)[] => [
makePad([{ pressed: true, value: 1 }], [], { connected }),
];
const gp = new GamepadController({ clock, source: pads });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
connected = false;
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(0);
gp.destroy();
});
it("does not zero again if already disconnected", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const gp = new GamepadController({
clock,
source: () => [null],
});
let changes = 0;
gp.onAnyInput(() => {
changes += 1;
});
advance(100);
gp.update();
expect(changes).toBe(0);
gp.destroy();
});
it("reads from the requested padIndex", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [null, makePad([{ pressed: true, value: 1 }], [])];
const gp = new GamepadController({
clock,
source: () => pads,
padIndex: 1,
});
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
gp.destroy();
});
it("ignores connected: false pad on first observation", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const gp = new GamepadController({
clock,
source: () => [makePad([], [], { connected: false })],
});
let changes = 0;
gp.onAnyInput(() => {
changes += 1;
});
advance(100);
gp.update();
expect(changes).toBe(0);
gp.destroy();
});
it("treats released button with missing value as 0", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const pads = [
makePad(
[
{
pressed: false,
} as unknown as { pressed: boolean; value?: number },
],
[]
),
];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(0);
gp.destroy();
});
it("zero-all skips analog reset when axisMap lacks analog binding", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
let connected = true;
const gp = new GamepadController({
clock,
source: () =>
connected
? [makePad([{ pressed: true, value: 1 }], [0.5])]
: [null],
axisMap: { 0: { positive: "right", negative: "left" } },
});
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
connected = false;
advance(100);
gp.update();
expect(gp.getValue("lx")).toBe(0);
expect(gp.getValue("right")).toBe(0);
gp.destroy();
});
it("uses defaults when no options argument is passed", {
timeout: 5000,
}, () => {
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value: {
getGamepads: () => [makePad([{ pressed: true, value: 1 }], [])],
},
});
try {
const gp = new GamepadController();
expect(gp.getDeadzone()).toBeCloseTo(0.1);
gp.destroy();
} finally {
if (prior === undefined) {
Reflect.deleteProperty(globalThis, "navigator");
} else {
Object.defineProperty(globalThis, "navigator", prior);
}
}
});
});

View File

@@ -0,0 +1,142 @@
# `gamepad` — `GamepadController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Browser-tier device adapter that polls the Web Gamepad API and
normalises button + axis state into the canonical 37-field
`ControllerInput` shape exposed by `BaseController`. The adapter
covers the W3C "standard mapping" layout (face buttons, bumpers,
triggers, sticks, D-pad, system buttons) out of the box and exposes
the button + axis maps so non-standard pads or custom layouts can be
described per-instance.
The class never reads from `navigator` at module-load time. The
`GamepadSource` is either supplied via `options.source` or resolved
lazily from `globalThis.navigator.getGamepads()` when `update()`
runs. Tests inject a deterministic source returning fixture pads and
drive the adapter directly — no jsdom or `navigator` polyfill
required.
## Features
- Standard W3C button mapping for all 17 indices, including analog
triggers (`lt`/`rt`) and D-pad-to-digital mapping.
- Standard axis mapping covering both digital halves
(`right`/`left`/`up`/`down`) and centred analog representations
(`lx`/`ly`/`rx`/`ry`).
- Per-axis deadzone (defaults to `0.1`) applied before the magnitude
is rescaled to the `[0, 255]` field range.
- Per-instance `padIndex` so two adapters on the same source can
drive split-screen co-op without further routing.
- Connection diff — when the source returns `null`, `undefined`, or
a pad with `connected: false`, the adapter zeroes the mapped
fields exactly once and stops emitting until the pad reappears.
- Validates every map entry against `CONTROLLER_INPUT_KEYS`; invalid
entries throw `ControllerError(INVALID_GAMEPAD_MAP)` with the
offending tuple in `context.entry`.
- Inherits the full `BaseController` surface — `onInput`,
`onAnyInput`, `setThreshold`, `getDirectionalVector`,
telemetry instrumentation.
## Usage: Basic
```ts
import { GamepadController } from "@suaveplan/controller/gamepad";
const gp = new GamepadController();
gp.onInput("a", (_input, value) => {
if (value === 255) console.log("A pressed");
});
// pump every frame
function tick() {
gp.update();
requestAnimationFrame(tick);
}
tick();
```
## Usage: Advanced
Drive the adapter from a synthetic source with deterministic timing
— no browser globals required:
```ts
import { GamepadController } from "@suaveplan/controller/gamepad";
import type { GamepadLike } from "@suaveplan/controller/gamepad";
let pad: GamepadLike | null = null;
const gp = new GamepadController({
source: () => [pad],
deadzone: 0.2,
padIndex: 0,
axisMap: {
0: { positive: "right", negative: "left", analog: "lx" },
1: { positive: "down", negative: "up", analog: "ly" },
},
buttonMap: { 0: "a", 1: "b" },
clock: () => performance.now(),
pollRate: 120,
});
pad = {
buttons: [
{ pressed: true, value: 1 },
{ pressed: false, value: 0 },
],
axes: [0.75, -0.5],
};
gp.update();
console.log(gp.getValue("a")); // 255
console.log(gp.getValue("right")); // 191
```
## API Reference
- `class GamepadController extends BaseController`
- `constructor(options?: GamepadControllerOptions)`
- `update(): void` — polls the source, diffs against last frame,
pushes updates through `setState()`. Throws
`ControllerError(GAMEPAD_API_UNAVAILABLE)` when no source is
available. No-op after `destroy()`.
- `getDeadzone(): number` — resolved deadzone.
- `getPadIndex(): number` — bound pad index.
- `getButtonMap(): GamepadButtonMap` — immutable button map.
- `getAxisMap(): GamepadAxisMap` — immutable axis map.
- `DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap` — W3C standard
mapping.
- `DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap` — W3C standard axis
layout (left + right sticks).
- `interface GamepadControllerOptions extends BaseControllerOptions`
— adds `source`, `buttonMap`, `axisMap`, `deadzone`, `padIndex`.
- `interface GamepadLike` — structural subset of `Gamepad`
(`buttons[i].pressed`, `buttons[i].value`, `axes[i]`,
`connected`).
- `type GamepadSource = () => readonly (GamepadLike | null | undefined)[]`.
- `interface GamepadAxisBinding` — `positive`, `negative`, `analog`
fields, all optional.
## Implementation Notes
- Button values are taken from `buttons[i].value` when present so
analog triggers report the full `[0, 255]` range. Pads that omit
`value` (older drivers, software emulators) fall back to a binary
`pressed ? 255 : 0`.
- Axis values are clamped to `[-1, 1]` before the deadzone test so
drivers that report slightly over-range readings (common on dead
or worn sticks) cannot push the field above `255`.
- The disconnect zero-out is one-shot — guarded by a `wasConnected`
flag — so a permanently absent pad does not flood downstream
consumers with redundant `value=0` events every poll.
- The Zod options schema is `.strict()` so unknown options are
rejected at the boundary as `ControllerError(INVALID_OPTIONS)`;
field-level rejections (e.g. `deadzone: -1`) flow through the
same code with the Zod issues in `context.issues`.
- `super.update()` re-applies the `pollRate` gate, so callers may
invoke `update()` more often than the configured rate without
paying repeated traversal cost — the source function is only
invoked once per gated tick.

View File

@@ -0,0 +1,391 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import {
DEFAULT_GAMEPAD_AXIS_MAP,
DEFAULT_GAMEPAD_BUTTON_MAP,
GamepadController,
} from "./gamepad.js";
import type { GamepadLike, GamepadSource } from "./gamepad.types.js";
function makeClock(): {
clock: Clock;
advance: (ms: number) => void;
} {
let t = 0;
return {
clock: () => t,
advance: (ms) => {
t += ms;
},
};
}
function makePad(
buttons: ReadonlyArray<{ pressed: boolean; value?: number } | null>,
axes: readonly number[],
extra: { connected?: boolean } = {}
): GamepadLike {
return {
buttons: buttons.map((b) =>
b === null ? { pressed: false, value: 0 } : b
),
axes,
...extra,
};
}
describe("GamepadController — construction", () => {
it("uses keyboard defaults and exposes them", { timeout: 5000 }, () => {
const gp = new GamepadController({ source: () => [] });
expect(gp.getButtonMap()).toEqual(DEFAULT_GAMEPAD_BUTTON_MAP);
expect(gp.getAxisMap()).toEqual(DEFAULT_GAMEPAD_AXIS_MAP);
expect(gp.getDeadzone()).toBeCloseTo(0.1);
expect(gp.getPadIndex()).toBe(0);
gp.destroy();
});
it("throws INVALID_OPTIONS for bad source", { timeout: 5000 }, () => {
const err = (() => {
try {
return new GamepadController({
source: "not-a-fn" as unknown as GamepadSource,
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_GAMEPAD_MAP for bad button input", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new GamepadController({
source: () => [],
buttonMap: { 0: "nope" as never },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
);
});
it("throws INVALID_GAMEPAD_MAP for bad axis positive", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new GamepadController({
source: () => [],
axisMap: { 0: { positive: "nope" as never } },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
);
});
it("throws INVALID_GAMEPAD_MAP for bad axis negative", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new GamepadController({
source: () => [],
axisMap: { 0: { negative: "bad" as never } },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
);
});
it("throws INVALID_GAMEPAD_MAP for bad axis analog", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new GamepadController({
source: () => [],
axisMap: { 0: { analog: "bad" as never } },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_GAMEPAD_MAP
);
});
it("resolves globalThis.navigator.getGamepads", { timeout: 5000 }, () => {
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value: {
getGamepads: () => [makePad([{ pressed: true, value: 1 }], [])],
},
});
try {
const { clock, advance } = makeClock();
const gp = new GamepadController({ clock });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
gp.destroy();
} finally {
if (prior === undefined) {
Reflect.deleteProperty(globalThis, "navigator");
} else {
Object.defineProperty(globalThis, "navigator", prior);
}
}
});
it("throws GAMEPAD_API_UNAVAILABLE when no source resolvable", {
timeout: 5000,
}, () => {
const prior = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
writable: true,
value: undefined,
});
try {
const { clock, advance } = makeClock();
const gp = new GamepadController({ clock });
advance(100);
expect(() => gp.update()).toThrowError(ControllerError);
} finally {
if (prior === undefined) {
Reflect.deleteProperty(globalThis, "navigator");
} else {
Object.defineProperty(globalThis, "navigator", prior);
}
}
});
it("update is a no-op after destroy", { timeout: 5000 }, () => {
const gp = new GamepadController({ source: () => [] });
gp.destroy();
gp.update(); // should not throw
});
it("accepts injected clock, idGenerator, pollRate, strict and device", {
timeout: 5000,
}, () => {
const gp = new GamepadController({
source: () => [],
clock: () => 0,
idGenerator: () => "gp-1",
pollRate: 30,
strict: true,
device: "custom-pad",
});
expect(gp.id).toBe("gp-1");
gp.destroy();
});
});
describe("GamepadController — buttons", () => {
it("maps a pressed button to 255", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads: (GamepadLike | null)[] = [
makePad([{ pressed: true, value: 1 }], []),
];
const gp = new GamepadController({
clock,
source: () => pads,
});
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
gp.destroy();
});
it("uses analog button value when present", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([{ pressed: true, value: 0.5 }], [])];
const gp = new GamepadController({
clock,
source: () => pads,
});
advance(100);
gp.update();
expect(gp.getValue("a")).toBeGreaterThan(120);
expect(gp.getValue("a")).toBeLessThan(135);
gp.destroy();
});
it("falls back to pressed when value is missing", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [
makePad(
[
{
pressed: true,
} as unknown as { pressed: boolean; value?: number },
],
[]
),
];
const gp = new GamepadController({
clock,
source: () => pads,
});
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
gp.destroy();
});
it("skips undefined buttons", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
// a pad with one defined button and the rest absent
const pad: GamepadLike = {
buttons: [{ pressed: true, value: 1 }],
axes: [],
};
const gp = new GamepadController({ clock, source: () => [pad] });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
// 'b' is not driven because index 1 is undefined
expect(gp.getValue("b")).toBe(0);
gp.destroy();
});
it("clamps over-range analog button values", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([{ pressed: true, value: 1.7 }], [])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("a")).toBe(255);
gp.destroy();
});
});
describe("GamepadController — axes", () => {
it("drives positive half of axis", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [0.5, 0, 0, 0])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("right")).toBe(128); // 0.5 * 255 = 127.5 -> 128
expect(gp.getValue("left")).toBe(0);
gp.destroy();
});
it("drives negative half of axis", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [-0.75, 0, 0, 0])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("left")).toBe(191); // 0.75 * 255 ≈ 191
expect(gp.getValue("right")).toBe(0);
gp.destroy();
});
it("applies deadzone", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [0.05, 0, 0, 0])];
const gp = new GamepadController({
clock,
source: () => pads,
deadzone: 0.1,
});
advance(100);
gp.update();
expect(gp.getValue("right")).toBe(0);
expect(gp.getValue("left")).toBe(0);
gp.destroy();
});
it("emits centred analog value", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [0, 0, 0, 0])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("lx")).toBe(128);
gp.destroy();
});
it("clamps over-range axis values", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [1.5, 0, 0, 0])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("right")).toBe(255);
gp.destroy();
});
it("ignores NaN axes", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [Number.NaN, 0, 0, 0])];
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("right")).toBe(0);
gp.destroy();
});
it("skips undefined axis indices", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [])]; // empty axes array
const gp = new GamepadController({ clock, source: () => pads });
advance(100);
gp.update();
expect(gp.getValue("lx")).toBe(0);
gp.destroy();
});
it("respects axis bindings with only positive defined", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const pads = [makePad([], [0.6])];
const gp = new GamepadController({
clock,
source: () => pads,
axisMap: { 0: { positive: "right" } },
});
advance(100);
gp.update();
expect(gp.getValue("right")).toBe(153);
// 'left' was never bound -> stays 0
expect(gp.getValue("left")).toBe(0);
gp.destroy();
});
});
// Connection lifecycle, padIndex selection, default-options, and edge
// cases are exercised in `gamepad.lifecycle.test.ts` to keep each test
// file under the 500-line repository limit.

View File

@@ -0,0 +1,260 @@
import z from "zod";
import { BaseController } from "../base/base.js";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import type { ControllerInput } from "../types/controller.types.js";
import {
resolveGlobalGamepadSource,
validateAxisMap,
validateButtonMap,
} from "./gamepad.internal.js";
import type {
GamepadAxisMap,
GamepadButtonMap,
GamepadControllerOptions,
GamepadLike,
GamepadSource,
} from "./gamepad.types.js";
const GamepadOptionsSchema = z
.object({
clock: z
.custom<NonNullable<GamepadControllerOptions["clock"]>>(
(v) => typeof v === "function"
)
.optional(),
idGenerator: z
.custom<NonNullable<GamepadControllerOptions["idGenerator"]>>(
(v) => typeof v === "function"
)
.optional(),
pollRate: z.number().int().min(1).max(1000).optional(),
device: z.string().min(1).optional(),
strict: z.boolean().optional(),
source: z
.custom<GamepadSource>((v) => typeof v === "function")
.optional(),
buttonMap: z.record(z.string(), z.string()).optional(),
axisMap: z
.record(
z.string(),
z
.object({
positive: z.string().optional(),
negative: z.string().optional(),
analog: z.string().optional(),
})
.strict()
)
.optional(),
deadzone: z.number().min(0).max(0.99).optional(),
padIndex: z.number().int().min(0).optional(),
})
.strict()
.optional();
/** W3C standard-mapping button index → `ControllerInput` field. */
export const DEFAULT_GAMEPAD_BUTTON_MAP: GamepadButtonMap = Object.freeze({
0: "a",
1: "b",
2: "x",
3: "y",
4: "t1",
5: "t2",
6: "lt",
7: "rt",
8: "f1",
9: "f2",
10: "l3",
11: "r3",
12: "up",
13: "down",
14: "left",
15: "right",
16: "f3",
});
/** W3C standard-mapping axis layout (left + right sticks). */
export const DEFAULT_GAMEPAD_AXIS_MAP: GamepadAxisMap = Object.freeze({
0: { positive: "right", negative: "left", analog: "lx" },
1: { positive: "down", negative: "up", analog: "ly" },
2: { analog: "rx" },
3: { analog: "ry" },
});
const DEFAULT_DEADZONE = 0.1;
/**
* Browser-tier Web Gamepad API adapter.
*
* Polls the injected `GamepadSource` (defaulting to
* `navigator.getGamepads()` when available) each `update()` tick,
* diffs button + axis state against the previous frame, and pushes
* normalised values into the canonical `ControllerInput` shape via
* `BaseController.setState`.
*/
export class GamepadController extends BaseController {
private readonly source: GamepadSource | undefined;
private readonly buttonMap: GamepadButtonMap;
private readonly axisMap: GamepadAxisMap;
private readonly deadzone: number;
private readonly padIndex: number;
private wasConnected = false;
public constructor(options: GamepadControllerOptions = {}) {
const parsed = GamepadOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_OPTIONS,
"GamepadController received invalid options",
{ context: { issues: parsed.error.issues } }
);
}
const baseOptions: Partial<GamepadControllerOptions> = {
device: options.device ?? "gamepad",
};
if (options.clock !== undefined) {
baseOptions.clock = options.clock;
}
if (options.idGenerator !== undefined) {
baseOptions.idGenerator = options.idGenerator;
}
if (options.pollRate !== undefined) {
baseOptions.pollRate = options.pollRate;
}
if (options.strict !== undefined) {
baseOptions.strict = options.strict;
}
super(baseOptions);
this.buttonMap = validateButtonMap(
options.buttonMap ?? DEFAULT_GAMEPAD_BUTTON_MAP
);
this.axisMap = validateAxisMap(
options.axisMap ?? DEFAULT_GAMEPAD_AXIS_MAP
);
this.deadzone = options.deadzone ?? DEFAULT_DEADZONE;
this.padIndex = options.padIndex ?? 0;
this.source = options.source ?? resolveGlobalGamepadSource();
this.needsUpdate = true;
}
public override update(): void {
if (this.isDestroyed) {
return;
}
if (this.source === undefined) {
throw new ControllerError(
CONTROLLER_CODES.GAMEPAD_API_UNAVAILABLE,
"GamepadController.update() requires a source or a navigator.getGamepads global"
);
}
this.needsUpdate = true;
super.update();
}
public getDeadzone(): number {
return this.deadzone;
}
public getPadIndex(): number {
return this.padIndex;
}
public getButtonMap(): GamepadButtonMap {
return this.buttonMap;
}
public getAxisMap(): GamepadAxisMap {
return this.axisMap;
}
protected override updateState(): void {
// `update()` guards `source === undefined` already, so this method
// is only reached when a source is configured.
const source = this.source as GamepadSource;
const pads = source();
const pad = pads[this.padIndex];
if (pad === null || pad === undefined) {
this.handleDisconnect();
return;
}
if (pad.connected === false) {
this.handleDisconnect();
return;
}
this.wasConnected = true;
this.applyButtons(pad);
this.applyAxes(pad);
}
private handleDisconnect(): void {
if (this.wasConnected) {
this.zeroAll();
this.wasConnected = false;
}
}
private applyButtons(pad: GamepadLike): void {
const updates: Partial<ControllerInput> = {};
for (const [rawIndex, input] of Object.entries(this.buttonMap)) {
const button = pad.buttons[Number(rawIndex)];
if (button === undefined) {
continue;
}
const analog =
typeof button.value === "number"
? button.value
: button.pressed
? 1
: 0;
updates[input as keyof ControllerInput] = Math.round(
Math.max(0, Math.min(1, analog)) * 255
);
}
if (Object.keys(updates).length > 0) {
this.setState(updates);
}
}
private applyAxes(pad: GamepadLike): void {
const updates: Partial<ControllerInput> = {};
for (const [rawIndex, binding] of Object.entries(this.axisMap)) {
const raw = pad.axes[Number(rawIndex)];
if (typeof raw !== "number" || Number.isNaN(raw)) {
continue;
}
const clamped = Math.max(-1, Math.min(1, raw));
const magnitude = Math.abs(clamped);
const past = magnitude >= this.deadzone;
if (binding.positive !== undefined) {
updates[binding.positive] =
past && clamped > 0 ? Math.round(magnitude * 255) : 0;
}
if (binding.negative !== undefined) {
updates[binding.negative] =
past && clamped < 0 ? Math.round(magnitude * 255) : 0;
}
if (binding.analog !== undefined) {
updates[binding.analog] = Math.round(128 + clamped * 127.5);
}
}
if (Object.keys(updates).length > 0) {
this.setState(updates);
}
}
private zeroAll(): void {
const updates: Partial<ControllerInput> = {};
for (const input of Object.values(this.buttonMap)) {
updates[input] = 0;
}
for (const binding of Object.values(this.axisMap)) {
if (binding.positive !== undefined) updates[binding.positive] = 0;
if (binding.negative !== undefined) updates[binding.negative] = 0;
if (binding.analog !== undefined) updates[binding.analog] = 128;
}
this.setState(updates);
}
}

View File

@@ -0,0 +1,84 @@
import type { BaseControllerOptions } from "../base/base.types.js";
import type { ControllerInput } from "../types/controller.types.js";
/**
* Minimal structural view of a single Web Gamepad API entry.
*
* The adapter only ever reads `buttons[i].pressed` /
* `buttons[i].value` and `axes[i]`. Anything broader is intentionally
* left out of the contract so test doubles do not need to fabricate
* vibration actuators, mapping strings, or connection metadata.
*/
export interface GamepadLike {
readonly buttons: ReadonlyArray<{
readonly pressed: boolean;
readonly value?: number;
}>;
readonly axes: readonly number[];
readonly connected?: boolean;
}
/**
* Injection point for the Web Gamepad API.
*
* Returns the array of pads available at this tick. Entries may be
* `null` for empty slots — exactly mirroring
* `navigator.getGamepads()`.
*/
export type GamepadSource = () => readonly (GamepadLike | null | undefined)[];
/** Map from gamepad button index → `ControllerInput` key. */
export type GamepadButtonMap = Readonly<Record<number, keyof ControllerInput>>;
/**
* Map from gamepad axis index → `ControllerInput` keys for the
* positive and negative halves of the axis.
*
* For example, axis 0 (left stick X) typically maps to
* `{ positive: "right", negative: "left", analog: "lx" }`.
*
* - `positive` — input that receives `|v| * 255` when `v > deadzone`.
* - `negative` — input that receives `|v| * 255` when `v < -deadzone`.
* - `analog` — optional input that always receives the centred
* `128 + v * 127.5` analog representation regardless of sign.
*/
export interface GamepadAxisBinding {
positive?: keyof ControllerInput;
negative?: keyof ControllerInput;
analog?: keyof ControllerInput;
}
/** Indexed map of axis bindings. */
export type GamepadAxisMap = Readonly<Record<number, GamepadAxisBinding>>;
/**
* Construction options for `GamepadController`.
*
* Extends `BaseControllerOptions` with the Web Gamepad API-specific
* surface (source injection, button + axis maps, deadzone, pad
* selection).
*/
export interface GamepadControllerOptions extends BaseControllerOptions {
/**
* Returns the array of pads available this tick. Defaults to
* `navigator.getGamepads()` when present at construction. When
* absent and not injected, `update()` throws
* `ControllerError(GAMEPAD_API_UNAVAILABLE)`.
*/
source?: GamepadSource;
/** Override the default standard-mapping button table. */
buttonMap?: GamepadButtonMap;
/** Override the default standard-mapping axis table. */
axisMap?: GamepadAxisMap;
/**
* Analog axis deadzone in the `[0, 1)` range. Values with
* absolute magnitude below this snap to centre. Defaults to
* `0.1`.
*/
deadzone?: number;
/**
* Zero-based pad index this controller binds to. Defaults to
* `0` — the first pad reported by the source.
*/
padIndex?: number;
}

View File

@@ -0,0 +1,13 @@
export {
DEFAULT_GAMEPAD_AXIS_MAP,
DEFAULT_GAMEPAD_BUTTON_MAP,
GamepadController,
} from "./gamepad.js";
export type {
GamepadAxisBinding,
GamepadAxisMap,
GamepadButtonMap,
GamepadControllerOptions,
GamepadLike,
GamepadSource,
} from "./gamepad.types.js";

View File

@@ -0,0 +1,77 @@
// @suaveplan/controller — public surface barrel.
//
// Foundation (`BaseController`, `InputEventEmitter`, telemetry, error codes,
// types) + device adapters (`KeyboardController`, `GamepadController`) +
// composition (`ControllerMerger`). `TouchController`/`MouseController`
// remain deferred per openspec/changes/enterprise-controller (Priority 2).
export { BaseController } from "./base/base.js";
export type {
BaseControllerOptions,
ControllerSnapshotState,
InputBookkeeping,
ThresholdMap,
} from "./base/base.types.js";
export {
CONTROLLER_CODES,
type ControllerCode,
ControllerError,
} from "./error-codes/index.js";
export { InputEventEmitter } from "./events/events.js";
export type {
Disposer,
InputEventEmitterApi,
InputEventEmitterOptions,
} from "./events/events.types.js";
export {
DEFAULT_GAMEPAD_AXIS_MAP,
DEFAULT_GAMEPAD_BUTTON_MAP,
GamepadController,
} from "./gamepad/gamepad.js";
export type {
GamepadAxisBinding,
GamepadAxisMap,
GamepadButtonMap,
GamepadControllerOptions,
GamepadLike,
GamepadSource,
} from "./gamepad/gamepad.types.js";
export {
DEFAULT_KEYBOARD_KEY_MAP,
KeyboardController,
} from "./keyboard/keyboard.js";
export type {
EventTargetLike,
KeyboardControllerOptions,
KeyboardEventLike,
KeyboardKeyMap,
} from "./keyboard/keyboard.types.js";
export { ControllerMerger } from "./merger/merger.js";
export type {
ControllerMergerOptions,
MergerChildren,
} from "./merger/merger.types.js";
export {
inputsCounter,
patternMatchDurationHistogram,
recordInput,
recordPatternMatchDuration,
recordStateChange,
stateChangesCounter,
} from "./telemetry/index.js";
export type {
ComboCallback,
ControllerInput,
ControllerSnapshot,
ControllerState,
InputChangeCallback,
InputCounterAttributes,
InputThresholds,
StateChangeCallback,
StateCounterAttributes,
Vector2D,
} from "./types/controller.types.js";
export {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "./types/keys.js";

View File

@@ -0,0 +1,7 @@
export { DEFAULT_KEYBOARD_KEY_MAP, KeyboardController } from "./keyboard.js";
export type {
EventTargetLike,
KeyboardControllerOptions,
KeyboardEventLike,
KeyboardKeyMap,
} from "./keyboard.types.js";

View File

@@ -0,0 +1,134 @@
# `keyboard` — `KeyboardController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Browser-tier device adapter that normalises DOM `keydown`/`keyup`
events into the canonical 37-field `ControllerInput` shape exposed by
`BaseController`. The adapter exists so games, UIs, and headless test
harnesses can consume the same controller surface whether the user is
pressing physical keys, dispatching synthetic events from a test
double, or driving the adapter from a non-DOM emitter such as a
custom virtual keyboard.
The class never touches a global at module load time. The target is
either supplied via `options.target` or resolved lazily from
`globalThis.window` when `attach()` runs. Tests inject an
`EventTargetLike` double and drive the adapter directly — no jsdom or
browser environment is required.
## Features
- Configurable `keyMap` covering arrow keys, WASD, ZXCV face buttons,
QERT triggers, and `Digit1`–`Digit4` function keys out of the box.
- Validates every key-map entry against `CONTROLLER_INPUT_KEYS` at
construction; invalid entries throw
`ControllerError(INVALID_KEY_MAP)` with the offending tuple in
`context.entry`.
- `attach()` / `detach()` are idempotent and survive `destroy()`
cleanly (no listeners leak, `attach()` after destroy is a no-op).
- Repeats and double-presses are suppressed (`event.repeat === true`
and already-pressed keys are ignored).
- Optional `preventDefault: true` to suppress default browser
behaviour (e.g. spacebar scrolling) without leaking that decision
into the input pipeline.
- Inherits the full `BaseController` surface — `onInput`,
`onAnyInput`, `onCombo`, `setThreshold`, `getDirectionalVector`,
`update()`, telemetry instrumentation.
## Usage: Basic
```ts
import { KeyboardController } from "@suaveplan/controller/keyboard";
const kb = new KeyboardController();
kb.onInput("up", (_input, value) => {
console.log(value === 255 ? "pressed up" : "released up");
});
// later
kb.destroy();
```
## Usage: Advanced
Drive the adapter from a synthetic emitter — no DOM required:
```ts
import { KeyboardController } from "@suaveplan/controller/keyboard";
import type { EventTargetLike } from "@suaveplan/controller/keyboard";
const listeners = new Map<string, Set<(e: unknown) => void>>();
const target: EventTargetLike = {
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? new Set();
bucket.add(listener as never);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
listeners.get(type)?.delete(listener as never);
},
};
const kb = new KeyboardController({
target,
keyMap: { Space: "a", KeyJ: "b" },
preventDefault: true,
pollRate: 120,
});
const dispatch = (type: string, event: unknown) => {
for (const cb of listeners.get(type) ?? []) cb(event as never);
};
dispatch("keydown", { code: "Space" });
console.log(kb.getValue("a")); // 255
dispatch("keyup", { code: "Space" });
kb.destroy();
```
## API Reference
- `class KeyboardController extends BaseController`
- `constructor(options?: KeyboardControllerOptions)`
- `attach(): void` — bind `keydown`/`keyup` to the resolved
target. Throws `ControllerError(KEYBOARD_TARGET_MISSING)` when
no target is available.
- `detach(): void` — idempotent removal of listeners.
- `getPressedKeys(): readonly string[]` — snapshot of physical
keys currently held down (by `code` or `key` identifier).
- `getKeyMap(): KeyboardKeyMap` — immutable key → input map.
- `destroy(): void` — detaches listeners and clears state.
- `DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap` — frozen default map
used when `options.keyMap` is omitted.
- `interface KeyboardControllerOptions extends BaseControllerOptions`
— adds `target`, `keyMap`, `autoAttach`, `preventDefault`.
- `interface EventTargetLike` — structural subset of `EventTarget`.
- `interface KeyboardEventLike` — structural subset of
`KeyboardEvent` (`code`, `key`, `repeat`, `preventDefault`).
- `type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>`.
## Implementation Notes
- The adapter writes through `BaseController.setState({ [input]:
value })` rather than reaching into `pendingStateUpdates`. This
keeps the OTel counters (`controller_inputs_total`,
`controller_state_changes_total`) and the `InputEventEmitter`
firing on the same code path as direct programmatic updates.
- Key identification consults `event.code` first, then `event.key`.
Both must be strings; the adapter rejects events whose identifier
is `undefined`. This matches the upstream behaviour and aligns
with W3C UI Events.
- Repeat suppression is double-guarded: the adapter ignores
`event.repeat === true` and also short-circuits when the key is
already tracked as pressed.
- The Zod options schema covers every public option; rejections are
surfaced as `ControllerError(INVALID_OPTIONS)` with the Zod issues
in `context.issues`. The schema is `.strict()` so unknown keys are
rejected at the boundary — typos surface as construction errors
rather than silent ignores.
- `setState` accepts a partial `ControllerInput`, so the adapter
computes the field per event rather than buffering. This keeps the
per-event latency below the `pollRate` gate that `BaseController`
applies to `update()`.

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import { DEFAULT_KEYBOARD_KEY_MAP, KeyboardController } from "./keyboard.js";
import type { EventTargetLike, KeyboardEventLike } from "./keyboard.types.js";
interface MockTarget extends EventTargetLike {
readonly down: Set<(e: KeyboardEventLike) => void>;
readonly up: Set<(e: KeyboardEventLike) => void>;
dispatchDown(event: KeyboardEventLike): void;
dispatchUp(event: KeyboardEventLike): void;
}
function createTarget(): MockTarget {
const down = new Set<(e: KeyboardEventLike) => void>();
const up = new Set<(e: KeyboardEventLike) => void>();
return {
down,
up,
addEventListener(type, listener) {
if (type === "keydown") down.add(listener);
else up.add(listener);
},
removeEventListener(type, listener) {
if (type === "keydown") down.delete(listener);
else up.delete(listener);
},
dispatchDown(event) {
for (const cb of down) cb(event);
},
dispatchUp(event) {
for (const cb of up) cb(event);
},
};
}
describe("KeyboardController — construction", () => {
it("attaches automatically by default", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
expect(target.down.size).toBe(1);
expect(target.up.size).toBe(1);
kb.destroy();
expect(target.down.size).toBe(0);
expect(target.up.size).toBe(0);
});
it("respects autoAttach: false", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target, autoAttach: false });
expect(target.down.size).toBe(0);
kb.attach();
expect(target.down.size).toBe(1);
kb.destroy();
});
it("throws INVALID_OPTIONS when target lacks addEventListener", {
timeout: 5000,
}, () => {
expect(
() =>
new KeyboardController({
target: { foo: 1 } as unknown as EventTargetLike,
})
).toThrowError(ControllerError);
});
it("throws INVALID_OPTIONS for pollRate out of range", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new KeyboardController({ pollRate: 0 });
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_OPTIONS
);
});
it("throws INVALID_KEY_MAP for unknown input target", {
timeout: 5000,
}, () => {
const target = createTarget();
const err = (() => {
try {
return new KeyboardController({
target,
keyMap: { KeyZ: "notAnInput" as never },
});
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.INVALID_KEY_MAP
);
expect((err as ControllerError).context).toEqual({
entry: ["KeyZ", "notAnInput"],
});
});
it("throws KEYBOARD_TARGET_MISSING when no target resolvable", {
timeout: 5000,
}, () => {
const err = (() => {
try {
return new KeyboardController({ autoAttach: true });
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(ControllerError);
expect((err as ControllerError).code).toBe(
CONTROLLER_CODES.KEYBOARD_TARGET_MISSING
);
});
it("resolves globalThis.window when present", { timeout: 5000 }, () => {
const target = createTarget();
const g = globalThis as { window?: unknown };
const prior = g.window;
g.window = target;
try {
const kb = new KeyboardController();
expect(target.down.size).toBe(1);
kb.destroy();
} finally {
if (prior === undefined) delete g.window;
else g.window = prior;
}
});
it("exposes the resolved key map and default map", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
expect(kb.getKeyMap().KeyW).toBe("up");
expect(DEFAULT_KEYBOARD_KEY_MAP.ArrowUp).toBe("up");
kb.destroy();
});
});
describe("KeyboardController — key events", () => {
it("maps keydown via event.code", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "KeyW" });
expect(kb.getValue("up")).toBe(255);
target.dispatchUp({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("falls back to event.key when code is unknown", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "Unknown", key: "w" });
expect(kb.getValue("up")).toBe(255);
kb.destroy();
});
it("ignores unmapped keys and missing identifiers", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "NotAKey" });
target.dispatchDown({});
expect(kb.getValue("up")).toBe(0);
target.dispatchUp({ code: "NotAKey" });
kb.destroy();
});
it("ignores repeated keydown events", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
let changes = 0;
kb.onInput("up", () => {
changes += 1;
});
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyW", repeat: true });
expect(changes).toBe(1);
kb.destroy();
});
it("ignores keyup without prior keydown", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchUp({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("calls preventDefault when configured", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target, preventDefault: true });
let prevented = 0;
target.dispatchDown({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
target.dispatchUp({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
expect(prevented).toBe(2);
kb.destroy();
});
it("does not call preventDefault when not configured", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
let prevented = 0;
target.dispatchDown({
code: "KeyW",
preventDefault: () => {
prevented += 1;
},
});
// missing preventDefault on event should not crash
target.dispatchUp({ code: "KeyW" });
expect(prevented).toBe(0);
kb.destroy();
});
it("getPressedKeys reflects current state", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ code: "KeyW" });
target.dispatchDown({ code: "KeyA" });
expect(kb.getPressedKeys().sort()).toEqual(["KeyA", "KeyW"]);
target.dispatchUp({ code: "KeyW" });
expect(kb.getPressedKeys()).toEqual(["KeyA"]);
kb.destroy();
});
it("no-ops after destroy", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
kb.destroy();
target.dispatchDown({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
// second destroy is idempotent
kb.destroy();
});
it("attach is idempotent and noop after destroy", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
kb.attach();
expect(target.down.size).toBe(1);
kb.destroy();
kb.attach();
expect(target.down.size).toBe(0);
});
it("detach is idempotent and tolerates missing target", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target, autoAttach: false });
kb.detach();
expect(target.down.size).toBe(0);
kb.attach();
kb.detach();
kb.detach();
expect(target.down.size).toBe(0);
kb.destroy();
});
it("accepts injected clock, idGenerator, pollRate and strict", {
timeout: 5000,
}, () => {
const target = createTarget();
let t = 0;
const kb = new KeyboardController({
target,
clock: () => t,
idGenerator: () => "kb-1",
pollRate: 30,
strict: true,
});
expect(kb.id).toBe("kb-1");
target.dispatchDown({ code: "KeyW" });
t += 100;
kb.update();
kb.destroy();
});
it("uses event.key when event.code is undefined", { timeout: 5000 }, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
target.dispatchDown({ key: "w" });
expect(kb.getValue("up")).toBe(255);
target.dispatchUp({ key: "w" });
expect(kb.getValue("up")).toBe(0);
kb.destroy();
});
it("ignores key events delivered after destroy via retained listener", {
timeout: 5000,
}, () => {
const target = createTarget();
const kb = new KeyboardController({ target });
const listener = [...target.down][0];
if (listener === undefined) {
throw new Error("expected one keydown listener");
}
kb.destroy();
listener({ code: "KeyW" });
expect(kb.getValue("up")).toBe(0);
});
});

View File

@@ -0,0 +1,279 @@
import z from "zod";
import { BaseController } from "../base/base.js";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import type { ControllerInput } from "../types/controller.types.js";
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
import type {
EventTargetLike,
KeyboardControllerOptions,
KeyboardEventLike,
KeyboardKeyMap,
} from "./keyboard.types.js";
const KeyboardOptionsSchema = z
.object({
clock: z
.custom<NonNullable<KeyboardControllerOptions["clock"]>>(
(v) => typeof v === "function"
)
.optional(),
idGenerator: z
.custom<NonNullable<KeyboardControllerOptions["idGenerator"]>>(
(v) => typeof v === "function"
)
.optional(),
pollRate: z.number().int().min(1).max(1000).optional(),
device: z.string().min(1).optional(),
strict: z.boolean().optional(),
target: z
.custom<EventTargetLike>(
(v) =>
v !== null &&
typeof v === "object" &&
typeof (v as EventTargetLike).addEventListener ===
"function" &&
typeof (v as EventTargetLike).removeEventListener ===
"function"
)
.optional(),
keyMap: z.record(z.string(), z.string()).optional(),
autoAttach: z.boolean().optional(),
preventDefault: z.boolean().optional(),
})
.strict()
.optional();
const VALID_INPUT_KEYS = new Set<string>(
CONTROLLER_INPUT_KEYS as readonly string[]
);
/** WASD + arrows + face buttons + triggers + function digits. */
export const DEFAULT_KEYBOARD_KEY_MAP: KeyboardKeyMap = Object.freeze({
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
KeyW: "up",
w: "up",
W: "up",
KeyS: "down",
s: "down",
S: "down",
KeyA: "left",
a: "left",
A: "left",
KeyD: "right",
d: "right",
D: "right",
KeyZ: "a",
z: "a",
Z: "a",
KeyX: "b",
x: "b",
X: "b",
KeyC: "x",
c: "x",
C: "x",
KeyV: "y",
v: "y",
V: "y",
KeyQ: "t1",
q: "t1",
Q: "t1",
KeyE: "t2",
e: "t2",
E: "t2",
KeyR: "t3",
r: "t3",
R: "t3",
KeyT: "t4",
t: "t4",
T: "t4",
Digit1: "f1",
"1": "f1",
Digit2: "f2",
"2": "f2",
Digit3: "f3",
"3": "f3",
Digit4: "f4",
"4": "f4",
});
/**
* Browser-tier keyboard adapter that drives the canonical
* `ControllerInput` shape from `keydown`/`keyup` events.
*
* The adapter never touches a global at module-load time; the event
* target is resolved lazily by `attach()`. Inject an
* `EventTargetLike` in tests to drive the adapter from synthetic
* events without jsdom.
*/
export class KeyboardController extends BaseController {
private readonly keyMap: KeyboardKeyMap;
private readonly preventDefault: boolean;
private readonly target: EventTargetLike | undefined;
private readonly pressedKeys = new Set<string>();
private readonly boundKeyDown: (event: KeyboardEventLike) => void;
private readonly boundKeyUp: (event: KeyboardEventLike) => void;
private isAttached = false;
public constructor(options: KeyboardControllerOptions = {}) {
const parsed = KeyboardOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_OPTIONS,
"KeyboardController received invalid options",
{ context: { issues: parsed.error.issues } }
);
}
const baseOptions: Partial<KeyboardControllerOptions> = {
device: options.device ?? "keyboard",
};
if (options.clock !== undefined) {
baseOptions.clock = options.clock;
}
if (options.idGenerator !== undefined) {
baseOptions.idGenerator = options.idGenerator;
}
if (options.pollRate !== undefined) {
baseOptions.pollRate = options.pollRate;
}
if (options.strict !== undefined) {
baseOptions.strict = options.strict;
}
super(baseOptions);
this.keyMap = KeyboardController.validateKeyMap(
options.keyMap ?? DEFAULT_KEYBOARD_KEY_MAP
);
this.preventDefault = options.preventDefault ?? false;
this.target =
options.target ?? KeyboardController.resolveGlobalTarget();
this.boundKeyDown = (event) => {
this.handleKeyEvent(event, true);
};
this.boundKeyUp = (event) => {
this.handleKeyEvent(event, false);
};
if ((options.autoAttach ?? true) === true) {
this.attach();
}
}
/**
* Bind `keydown`/`keyup` listeners to the resolved target. Safe to
* call repeatedly — subsequent calls are no-ops until `detach()`.
*/
public attach(): void {
if (this.isAttached || this.isDestroyed) {
return;
}
if (this.target === undefined) {
throw new ControllerError(
CONTROLLER_CODES.KEYBOARD_TARGET_MISSING,
"KeyboardController.attach() called with no target",
{ context: { target: this.target } }
);
}
this.target.addEventListener("keydown", this.boundKeyDown);
this.target.addEventListener("keyup", this.boundKeyUp);
this.isAttached = true;
}
/** Idempotent detach; safe across destroy. */
public detach(): void {
if (!this.isAttached || this.target === undefined) {
this.isAttached = false;
return;
}
this.target.removeEventListener("keydown", this.boundKeyDown);
this.target.removeEventListener("keyup", this.boundKeyUp);
this.isAttached = false;
}
/** Snapshot of physical keys currently held down. */
public getPressedKeys(): readonly string[] {
return Array.from(this.pressedKeys);
}
/** Resolved key → input map (immutable). */
public getKeyMap(): KeyboardKeyMap {
return this.keyMap;
}
protected override updateState(): void {
/* event-driven; nothing to poll */
}
public override destroy(): void {
if (this.isDestroyed) {
return;
}
this.detach();
this.pressedKeys.clear();
super.destroy();
}
private handleKeyEvent(event: KeyboardEventLike, isDown: boolean): void {
if (this.isDestroyed) {
return;
}
const keyId = event.code ?? event.key;
if (keyId === undefined) {
return;
}
const input =
(event.code !== undefined ? this.keyMap[event.code] : undefined) ??
(event.key !== undefined ? this.keyMap[event.key] : undefined);
if (input === undefined) {
return;
}
if (this.preventDefault && typeof event.preventDefault === "function") {
event.preventDefault();
}
if (isDown) {
if (event.repeat === true) {
return;
}
if (this.pressedKeys.has(keyId)) {
return;
}
this.pressedKeys.add(keyId);
this.setState({ [input]: 255 } as Partial<ControllerInput>);
return;
}
if (!this.pressedKeys.has(keyId)) {
return;
}
this.pressedKeys.delete(keyId);
this.setState({ [input]: 0 } as Partial<ControllerInput>);
}
private static validateKeyMap(map: KeyboardKeyMap): KeyboardKeyMap {
for (const [key, input] of Object.entries(map)) {
if (!VALID_INPUT_KEYS.has(input as string)) {
throw new ControllerError(
CONTROLLER_CODES.INVALID_KEY_MAP,
`KeyboardController keyMap entry "${key}" targets unknown input "${input}"`,
{ context: { entry: [key, input] } }
);
}
}
return Object.freeze({ ...map });
}
private static resolveGlobalTarget(): EventTargetLike | undefined {
const w = (globalThis as unknown as { window?: EventTargetLike })
.window;
if (
w !== undefined &&
typeof w.addEventListener === "function" &&
typeof w.removeEventListener === "function"
) {
return w;
}
return undefined;
}
}

View File

@@ -0,0 +1,78 @@
import type { BaseControllerOptions } from "../base/base.types.js";
import type { ControllerInput } from "../types/controller.types.js";
/**
* Minimal structural view of an `EventTarget` that the
* `KeyboardController` calls into. Mirrors the subset of the DOM
* `EventTarget` interface we actually need so the adapter can be
* driven from a test double or a non-DOM emitter without requiring
* jsdom or any browser global.
*/
export interface EventTargetLike {
addEventListener(
type: "keydown" | "keyup",
listener: (event: KeyboardEventLike) => void
): void;
removeEventListener(
type: "keydown" | "keyup",
listener: (event: KeyboardEventLike) => void
): void;
}
/**
* Minimal structural view of a `KeyboardEvent`.
*
* The adapter only ever touches `code`, `key`, `repeat`, and
* `preventDefault`. Anything broader would force a DOM dependency on
* universal consumers; this shape is satisfied by both the real
* `KeyboardEvent` and the test doubles in `keyboard.test.ts`.
*/
export interface KeyboardEventLike {
code?: string;
key?: string;
repeat?: boolean;
preventDefault?: () => void;
}
/**
* Map from a keyboard `code` (e.g. `"KeyW"`) or `key` (e.g. `"w"`) to
* the `ControllerInput` field the press should drive.
*
* Lookups consult `code` first, then `key`. Both are case-sensitive.
*/
export type KeyboardKeyMap = Readonly<Record<string, keyof ControllerInput>>;
/**
* Construction options accepted by `KeyboardController`.
*
* Extends `BaseControllerOptions` with the keyboard-specific surface
* (target injection, key-map override, auto-attach toggle).
*/
export interface KeyboardControllerOptions extends BaseControllerOptions {
/**
* Event target the adapter binds `keydown`/`keyup` to. Defaults to
* `globalThis.window` when present. When absent and not injected,
* `attach()` throws `ControllerError(KEYBOARD_TARGET_MISSING)`.
*/
target?: EventTargetLike;
/**
* Override the default key → input map. The provided object is
* deep-frozen on construction and validated against
* `CONTROLLER_INPUT_KEYS`; an invalid entry throws
* `ControllerError(INVALID_KEY_MAP)`.
*/
keyMap?: KeyboardKeyMap;
/**
* When `true` (the default), the constructor calls `attach()`
* automatically. Set to `false` if you need to defer binding (e.g.
* SSR rehydration where the target only becomes available after
* mount).
*/
autoAttach?: boolean;
/**
* When `true`, the adapter calls `event.preventDefault()` on every
* mapped key event. Defaults to `false` so callers opt in to
* suppressing default browser behaviour (e.g. spacebar scroll).
*/
preventDefault?: boolean;
}

View File

@@ -0,0 +1,5 @@
export { ControllerMerger } from "./merger.js";
export type {
ControllerMergerOptions,
MergerChildren,
} from "./merger.types.js";

View File

@@ -0,0 +1,169 @@
# `merger` — `ControllerMerger`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ControllerMerger` fuses an ordered list of `BaseController` children
into a single virtual controller whose `ControllerInput` snapshot is the
per-field maximum of every attached child. It is the layer that lets a
game treat keyboard, gamepad, touch, and remote network controllers as
one unified action source without re-binding consumers when a player
adds or removes a device mid-session.
The merger extends `BaseController` so it transparently inherits the
typed `InputEventEmitter`, threshold/deadzone normalisation, OpenTelemetry
counters, and lifecycle guards. Children are observed through their
public `onAnyInput` and `onCombo` subscriptions only — the merger never
peeks at protected state, so any subclass of `BaseController` (current
or future) plugs in without an adapter shim.
## Features
- Per-field `Math.max` reduction across N children, walked in stable
insertion order over the canonical `CONTROLLER_INPUT_KEYS`.
- Subscription-based wiring — child state-change events are funnelled
into per-child snapshots, then merged on `update()`.
- Combo events from any child are re-emitted through the merger's own
`eventEmitter` so consumers only listen in one place.
- Duplicate-detection by both instance identity and `child.id`, raising
`ControllerError(CONTROLLER.DUPLICATE_CHILD)` early.
- `removeController()` validates membership and throws
`ControllerError(CONTROLLER.UNKNOWN_CHILD)` for stale references.
- Lifecycle propagation — `destroy()` disposes every subscription and
destroys every child before clearing local state.
- pollRate gating inherited from `BaseController`, driven by the same
injected `Clock` so tests can use a `FakeClock` without globals.
## Usage: Basic
```ts
import {
BaseController,
ControllerMerger,
} from "@suaveplan/controller";
// Two concrete children produced by Chunk B's adapters.
declare const keyboard: BaseController;
declare const gamepad: BaseController;
const merger = new ControllerMerger({ children: [keyboard, gamepad] });
merger.onAnyInput((state) => {
// Single subscription regardless of how many devices are connected.
render(state);
});
// Drive every tick of the game loop.
function tick(): void {
merger.update();
requestAnimationFrame(tick);
}
```
## Usage: Advanced
```ts
import {
BaseController,
CONTROLLER_CODES,
ControllerError,
ControllerMerger,
} from "@suaveplan/controller";
import type { Clock, IdGenerator } from "@suaveplan/types";
const fakeClock: Clock = (() => {
let t = 0;
return () => (t += 16);
})();
let n = 0;
const ids: IdGenerator = () => `m-${++n}`;
const merger = new ControllerMerger({
clock: fakeClock,
idGenerator: ids,
pollRate: 120,
strict: true,
});
declare const padA: BaseController;
declare const padB: BaseController;
merger.addController(padA);
try {
merger.addController(padA);
} catch (err) {
if (
err instanceof ControllerError &&
err.code === CONTROLLER_CODES.DUPLICATE_CHILD
) {
console.warn("already attached:", err.context);
}
}
merger.addController(padB);
merger.setThreshold("lx", { deadzone: 8, threshold: 32 });
merger.update();
merger.removeController(padA);
merger.destroy(); // destroys padB as well
```
## API Reference
- `class ControllerMerger extends BaseController`
- `constructor(options?: ControllerMergerOptions)` — accepts every
`BaseControllerOptions` field plus `children?: readonly BaseController[]`
seeded eagerly via `addController()`.
- `addController(child: BaseController): void` — subscribes to
`onAnyInput` + `onCombo`, captures the current snapshot, marks the
merger dirty. Throws `CONTROLLER.DUPLICATE_CHILD` on identity or
`id` collision.
- `removeController(child: BaseController): void` — disposes
subscriptions, drops the snapshot, marks dirty. Throws
`CONTROLLER.UNKNOWN_CHILD` when the child is not attached.
- `getChildren(): MergerChildren` — read-only snapshot of children
in insertion order.
- `get childCount(): number` — current attachment count.
- `update(): void` — polls every child, then runs the per-field max
reduction over `CONTROLLER_INPUT_KEYS` and routes diffs through
`updateInput` so threshold/deadzone gating + event emission stay
consistent.
- `destroy(): void` — disposes all subscriptions, destroys every
child, clears local state, then defers to `BaseController.destroy`.
- `interface ControllerMergerOptions extends BaseControllerOptions`
- `children?: readonly BaseController[]` — initial seed.
- `type MergerChildren = readonly BaseController[]`
## Implementation Notes
The merger captures child snapshots **eagerly on subscription** so the
first `update()` after construction reflects whatever state the child
already has, even if no further input events fire between `addController`
and `update`. Snapshots are stored keyed by `child.id` rather than by
identity reference so the lookup remains O(1) and the duplicate guard
shares the same key space.
`update()` is overridden in full rather than using the base
`updateState()` template. The base `update()` short-circuits when
`needsUpdate` is false, which would skip child polling entirely — a
visible bug in a tick-driven game loop where children must always be
polled regardless of whether the merger itself observed prior changes.
The override polls children first, then applies the same pollRate gating
the base class would have applied before mutating local state.
Per-field reduction uses `Math.max` because every input value lives in
`[0, 255]` and the desired semantic is "any source wins" — a pressed
button on the keyboard plus a released button on the gamepad should
read as pressed, and a partially deflected stick on one pad should not
be cancelled by a centred stick on another. This matches the upstream
contract and avoids subtle precedence rules between devices.
`destroy()` propagates to children because the merger owns the wiring;
leaving children alive after the merger goes away leaves dangling
subscriptions on the children's emitters and is the failure mode
removed-without-destroy would otherwise create. Callers that need
independent child lifecycles call `removeController()` before
`destroy()`.

View File

@@ -0,0 +1,413 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { BaseController } from "../base/base.js";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import type { ControllerInput } from "../types/controller.types.js";
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
import { ControllerMerger } from "./merger.js";
/** Concrete `BaseController` exposing `emitCombo` and direct state poke. */
class FakeChild extends BaseController {
public constructor(
device = "fake",
opts: ConstructorParameters<typeof BaseController>[0] = {}
) {
super({ device, ...opts });
}
public emitComboPublic(combo: string[]): void {
this.eventEmitter.emitCombo(combo);
}
public override updateState(): void {
/* no-op for tests */
}
}
function makeClock(): {
clock: Clock;
advance: (ms: number) => void;
now: () => number;
} {
let t = 0;
return {
clock: () => t,
advance: (ms) => {
t += ms;
},
now: () => t,
};
}
let idCounter = 0;
const makeIdGen =
(prefix: string): IdGenerator =>
() =>
`${prefix}-${++idCounter}`;
describe("ControllerMerger — construction", () => {
it("defaults device to 'merger' and exposes 0 children", {
timeout: 5000,
}, () => {
const m = new ControllerMerger();
expect(m.childCount).toBe(0);
expect(m.getChildren()).toEqual([]);
m.destroy();
});
it("seeds children via options", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
const m = new ControllerMerger({ children: [a, b] });
expect(m.childCount).toBe(2);
expect(m.getChildren()).toEqual([a, b]);
m.destroy();
});
it("rejects pollRate via Zod (inherited boundary)", {
timeout: 5000,
}, () => {
expect(() => new ControllerMerger({ pollRate: 0 })).toThrow(
ControllerError
);
});
});
describe("ControllerMerger — addController", () => {
it("attaches a child and captures its current state", {
timeout: 5000,
}, () => {
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
c.setState({ up: 100 });
const m = new ControllerMerger();
m.addController(c);
m.update();
expect(m.getValue("up")).toBe(100);
m.destroy();
});
it("throws DUPLICATE_CHILD on the same instance", {
timeout: 5000,
}, () => {
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
const m = new ControllerMerger({ children: [c] });
try {
m.addController(c);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.DUPLICATE_CHILD);
expect((err.context as { id: string }).id).toBe(c.id);
}
}
m.destroy();
});
it("throws DUPLICATE_CHILD on id collision (distinct instance)", {
timeout: 5000,
}, () => {
const fixedId: IdGenerator = () => "shared";
const c1 = new FakeChild("c1", { idGenerator: fixedId });
const c2 = new FakeChild("c2", { idGenerator: fixedId });
const m = new ControllerMerger();
m.addController(c1);
expect(() => m.addController(c2)).toThrow(ControllerError);
m.destroy();
});
it("is a no-op on destroyed merger", { timeout: 5000 }, () => {
const m = new ControllerMerger();
m.destroy();
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
expect(() => m.addController(c)).not.toThrow();
expect(m.childCount).toBe(0);
c.destroy();
});
});
describe("ControllerMerger — removeController", () => {
it("detaches a child and drops its snapshot", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
const b = new FakeChild("b", { clock, idGenerator: makeIdGen("b") });
a.setState({ up: 200 });
b.setState({ up: 50 });
const m = new ControllerMerger({ clock, children: [a, b] });
m.update();
expect(m.getValue("up")).toBe(200);
m.removeController(a);
advance(100);
m.update();
expect(m.getValue("up")).toBe(50);
expect(m.childCount).toBe(1);
m.destroy();
});
it("ignores future events from a detached child", {
timeout: 5000,
}, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
m.removeController(a);
a.setState({ down: 200 });
m.update();
expect(m.getValue("down")).toBe(0);
a.destroy();
m.destroy();
});
it("throws UNKNOWN_CHILD when child is not attached", {
timeout: 5000,
}, () => {
const stray = new FakeChild("s", { idGenerator: makeIdGen("s") });
const m = new ControllerMerger();
try {
m.removeController(stray);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ControllerError);
if (err instanceof ControllerError) {
expect(err.code).toBe(CONTROLLER_CODES.UNKNOWN_CHILD);
}
}
stray.destroy();
m.destroy();
});
it("is a no-op on destroyed merger", { timeout: 5000 }, () => {
const c = new FakeChild("c", { idGenerator: makeIdGen("c") });
const m = new ControllerMerger({ children: [c] });
m.destroy();
expect(() => m.removeController(c)).not.toThrow();
});
});
describe("ControllerMerger — merge reduction", () => {
it("takes per-field max across children", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
a.setState({ up: 80, left: 200, a: 0 });
b.setState({ up: 150, left: 50, a: 255 });
const m = new ControllerMerger({ children: [a, b] });
m.update();
expect(m.getValue("up")).toBe(150);
expect(m.getValue("left")).toBe(200);
expect(m.getValue("a")).toBe(255);
m.destroy();
});
it("iterates fields in CONTROLLER_INPUT_KEYS order", {
timeout: 5000,
}, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
const partial: Partial<ControllerInput> = {};
for (const key of CONTROLLER_INPUT_KEYS) {
partial[key] = 10;
}
a.setState(partial);
m.update();
for (const key of CONTROLLER_INPUT_KEYS) {
expect(m.getValue(key)).toBe(10);
}
m.destroy();
});
it("recomputes when a child fires onAnyInput post-construction", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ clock, children: [a] });
m.update();
expect(m.getValue("right")).toBe(0);
advance(100);
a.setState({ right: 222 });
m.update();
expect(m.getValue("right")).toBe(222);
m.destroy();
});
it("emits onAnyInput on the merger when merged state changes", {
timeout: 5000,
}, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
let calls = 0;
m.onAnyInput(() => {
calls += 1;
});
a.setState({ up: 100 });
m.update();
expect(calls).toBeGreaterThan(0);
m.destroy();
});
it("emits onInput per changed field", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ clock, children: [a] });
let upCalls = 0;
m.onInput("up", () => {
upCalls += 1;
});
a.setState({ up: 100 });
m.update();
expect(upCalls).toBe(1);
advance(100);
a.setState({ up: 150 });
m.update();
expect(upCalls).toBe(2);
m.destroy();
});
it("update with no changes clears needsUpdate", { timeout: 5000 }, () => {
const { clock, advance } = makeClock();
const a = new FakeChild("a", { clock, idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ clock, children: [a] });
m.update();
advance(100);
// Second update with no child change → exercises the !changed branch.
(m as unknown as { needsUpdate: boolean }).needsUpdate = true;
m.update();
advance(100);
m.update(); // already cleared; this re-exercises the !needsUpdate branch
expect(m.getValue("up")).toBe(0);
m.destroy();
});
});
describe("ControllerMerger — combo forwarding", () => {
it("re-emits combos from any child", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
const m = new ControllerMerger({ children: [a, b] });
const seen: string[][] = [];
m.onCombo((c) => seen.push(c));
a.emitComboPublic(["up", "a"]);
b.emitComboPublic(["down", "b"]);
expect(seen).toEqual([
["up", "a"],
["down", "b"],
]);
m.destroy();
});
it("ignores child combo after merger destroyed", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
let calls = 0;
m.onCombo(() => {
calls += 1;
});
// Capture a is still alive so its onCombo subscribers still get called
// — but the merger destroyed itself, so the forwarder must early-out.
// We invoke the forwarder directly by re-attaching after destroy.
m.destroy();
// Listeners are cleared by destroy, so even if the underlying handler
// ran, no consumer-visible event fires. This asserts the destroyed
// guard path on the forwarder by ensuring no exception is thrown.
expect(() => a.emitComboPublic(["x"])).not.toThrow();
expect(calls).toBe(0);
a.destroy();
});
});
describe("ControllerMerger — state change forwarding guard", () => {
it("does not capture child state once merger is destroyed", {
timeout: 5000,
}, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
m.destroy();
// Child still alive but merger guards inside the listener; a.setState
// must not throw, and the merger snapshot must not exist anymore.
expect(() => a.setState({ up: 100 })).not.toThrow();
a.destroy();
});
});
describe("ControllerMerger — pollRate gating", () => {
it("skips merge work inside the pollRate window", {
timeout: 5000,
}, () => {
const { clock, advance } = makeClock();
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({
clock,
pollRate: 10, // 100 ms window
children: [a],
});
a.setState({ up: 100 });
m.update();
expect(m.getValue("up")).toBe(100);
a.setState({ up: 200 });
// No time advanced → gated; merger value frozen.
m.update();
expect(m.getValue("up")).toBe(100);
advance(150);
m.update();
expect(m.getValue("up")).toBe(200);
m.destroy();
});
it("update is a no-op on destroyed merger", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
m.destroy();
expect(() => m.update()).not.toThrow();
});
it("ignores recursive update calls", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const m = new ControllerMerger({ children: [a] });
let calls = 0;
m.onInput("up", () => {
calls += 1;
// Recursive update during emit must early-out via isMerging.
m.update();
});
a.setState({ up: 100 });
m.update();
expect(calls).toBe(1);
m.destroy();
});
});
describe("ControllerMerger — destroy", () => {
it("destroy is idempotent", { timeout: 5000 }, () => {
const m = new ControllerMerger();
m.destroy();
expect(() => m.destroy()).not.toThrow();
});
it("destroys every attached child", { timeout: 5000 }, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
const b = new FakeChild("b", { idGenerator: makeIdGen("b") });
const m = new ControllerMerger({ children: [a, b] });
m.destroy();
// Children are destroyed → setState in non-strict mode is silent
// and their state has been zeroed.
expect(a.getValue("up")).toBe(0);
expect(b.getValue("up")).toBe(0);
// The child id-keyed maps were cleared (no internal leak).
expect(m.childCount).toBe(0);
});
it("clears lastMerged so post-destroy snapshot reads zero", {
timeout: 5000,
}, () => {
const a = new FakeChild("a", { idGenerator: makeIdGen("a") });
a.setState({ up: 200 });
const m = new ControllerMerger({ children: [a] });
m.update();
m.destroy();
for (const key of CONTROLLER_INPUT_KEYS) {
expect(m.getValue(key)).toBe(0);
}
});
});

View File

@@ -0,0 +1,214 @@
import { BaseController } from "../base/base.js";
import {
CONTROLLER_CODES,
ControllerError,
} from "../error-codes/error-codes.js";
import type { ControllerInput } from "../types/controller.types.js";
import { CONTROLLER_INPUT_KEYS } from "../types/keys.js";
import type {
ControllerMergerOptions,
MergerChildren,
} from "./merger.types.js";
/**
* Multi-source controller that fuses N `BaseController` children into a
* single `ControllerInput` via per-field max-value reduction.
*
* Children are walked in **stable insertion order** so the reduction is
* deterministic for byte-equality assertions. Each child is observed
* through its public `onAnyInput` (state snapshots) and `onCombo`
* (combo forwarding) listeners; both disposers are tracked and torn down
* on `removeController()` or `destroy()`.
*
* The merger extends `BaseController` so it inherits the typed event
* emitter, threshold/deadzone pipeline, OTel telemetry, and lifecycle
* guards. Children remain owned by their original creators — calling
* `destroy()` on the merger destroys each child as well, matching the
* "merger owns the wiring" lifecycle contract.
*/
export class ControllerMerger extends BaseController {
private readonly children: BaseController[] = [];
private readonly childDisposers = new Map<string, Array<() => void>>();
private readonly childStates = new Map<string, ControllerInput>();
private readonly lastMerged: ControllerInput;
private isMerging = false;
public constructor(options: ControllerMergerOptions = {}) {
const { children, ...rest } = options;
super({ device: "merger", ...rest });
this.lastMerged = this.getState();
for (const child of children ?? []) {
this.addController(child);
}
}
/**
* Attach a child controller. Duplicates (same instance or colliding
* `id`) throw `CONTROLLER.DUPLICATE_CHILD`. The child's current state
* is captured immediately so the next `update()` reflects it.
*/
public addController(child: BaseController): void {
if (this.isDestroyed) {
return;
}
if (this.childStates.has(child.id)) {
throw new ControllerError(
CONTROLLER_CODES.DUPLICATE_CHILD,
`Child controller with id "${child.id}" already attached`,
{ context: { id: child.id } }
);
}
const disposers: Array<() => void> = [];
disposers.push(
child.onAnyInput((state) => {
this.childStates.set(child.id, { ...state });
this.needsUpdate = true;
})
);
disposers.push(
child.onCombo((combo) => {
this.eventEmitter.emitCombo([...combo]);
})
);
this.children.push(child);
this.childDisposers.set(child.id, disposers);
this.childStates.set(child.id, child.getState());
this.needsUpdate = true;
}
/**
* Detach a child. Disposes both subscriptions and forgets the child's
* snapshot so subsequent merges treat it as absent. Throws
* `CONTROLLER.UNKNOWN_CHILD` when the child was never attached.
*/
public removeController(child: BaseController): void {
if (this.isDestroyed) {
return;
}
const index = this.children.indexOf(child);
if (index === -1) {
throw new ControllerError(
CONTROLLER_CODES.UNKNOWN_CHILD,
"Child controller not attached to this merger",
{ context: { id: child.id } }
);
}
const disposers = this.childDisposers.get(child.id) as Array<
() => void
>;
for (const dispose of disposers) {
dispose();
}
this.childDisposers.delete(child.id);
this.childStates.delete(child.id);
this.children.splice(index, 1);
this.needsUpdate = true;
}
/** Read-only snapshot of attached children, in insertion order. */
public getChildren(): MergerChildren {
return [...this.children];
}
/** Number of attached children. */
public get childCount(): number {
return this.children.length;
}
/**
* Drives the merger. Polls every child first (so their internal
* pipelines flush pending input), then reduces each
* `CONTROLLER_INPUT_KEYS` field via `Math.max` across the captured
* child states and routes diffs through `updateInput` so threshold /
* deadzone gating + event emission stay consistent with sibling
* adapters. The override deliberately bypasses `BaseController.update()`
* so children are polled even when the merger itself has no pending
* state of its own.
*/
public override update(): void {
if (this.isDestroyed || this.isMerging) {
return;
}
this.isMerging = true;
try {
for (const child of this.children) {
child.update();
}
if (!this.needsUpdate) {
return;
}
const now = this.clock();
const minInterval = 1000 / this.pollRate;
if (now - this.lastUpdate < minInterval) {
return;
}
this.lastUpdate = now;
const merged = this.calculateMerged();
let changed = false;
for (const key of CONTROLLER_INPUT_KEYS) {
const nextValue = merged[key];
if (nextValue !== this.lastMerged[key]) {
changed = true;
this.lastMerged[key] = nextValue;
this.updateInput(key, nextValue);
}
}
if (!changed) {
this.needsUpdate = false;
}
} finally {
this.isMerging = false;
}
}
/**
* Compute the merged state from the captured per-child snapshots.
* Iterates `CONTROLLER_INPUT_KEYS` so the reduction order is the
* canonical wire order; per-field reduction is `Math.max` so any
* source dominates a zero from another (the desired "any source
* wins" semantic for game input).
*/
private calculateMerged(): ControllerInput {
const out = {} as ControllerInput;
for (const key of CONTROLLER_INPUT_KEYS) {
let best = 0;
for (const snap of this.childStates.values()) {
const v = snap[key];
if (v > best) {
best = v;
}
}
out[key] = best;
}
return out;
}
/**
* Lifecycle: detach every child, dispose listeners, destroy each
* child (it owns its own resources), then defer to the base destroy
* which clears local state + the typed emitter.
*/
public override destroy(): void {
if (this.isDestroyed) {
return;
}
for (const disposers of this.childDisposers.values()) {
for (const dispose of disposers) {
dispose();
}
}
this.childDisposers.clear();
for (const child of this.children) {
child.destroy();
}
this.children.length = 0;
this.childStates.clear();
for (const key of CONTROLLER_INPUT_KEYS) {
this.lastMerged[key] = 0;
}
super.destroy();
}
}

View File

@@ -0,0 +1,24 @@
import type { BaseController } from "../base/base.js";
import type { BaseControllerOptions } from "../base/base.types.js";
/**
* Construction options for `ControllerMerger`.
*
* Extends every `BaseControllerOptions` field (clock, idGenerator,
* pollRate, device, strict) and adds an optional `children` seed so a
* fully populated merger can be created in a single statement.
*/
export interface ControllerMergerOptions extends BaseControllerOptions {
/**
* Initial child controllers attached eagerly during construction.
* Each child must have a unique `id`; duplicates throw
* `ControllerError(CONTROLLER.DUPLICATE_CHILD)`.
*/
children?: readonly BaseController[];
}
/**
* Public read-only view of the merger's child set, returned by
* `getChildren()` for inspection without exposing the internal storage.
*/
export type MergerChildren = readonly BaseController[];

View File

@@ -0,0 +1,8 @@
export {
inputsCounter,
patternMatchDurationHistogram,
recordInput,
recordPatternMatchDuration,
recordStateChange,
stateChangesCounter,
} from "./telemetry.js";

View File

@@ -0,0 +1,88 @@
# `telemetry` — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instruments emitted by `@suaveplan/controller`:
two counters tracking input and state-change throughput per device, and
one histogram for the pattern engine's match-attempt duration. Both
counters are exercised by `BaseController` via the `recordInput` and
`recordStateChange` helpers; the histogram is reserved for the pattern
matcher landing in Chunk B/C.
## Features
- Lazy-bound meter — instruments resolve against the globally registered
`MeterProvider` on first import and fall back to a no-op meter when
none is registered. Production callers pay zero cost for OTel.
- `controller_inputs_total` counter with `device` + `input` attributes
for fine-grained per-input throughput.
- `controller_state_changes_total` counter with the `device` attribute.
- `controller_pattern_match_duration_ms` histogram (unit `ms`) for the
pattern engine.
- Typed attribute bundles (`InputCounterAttributes`,
`StateCounterAttributes`) prevent string-typo bugs at the call site.
## Usage: Basic
```ts
import { recordInput, recordStateChange } from "@suaveplan/controller";
recordInput({ device: "keyboard", input: "up" });
recordStateChange({ device: "keyboard" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { recordInput } from "@suaveplan/controller";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
recordInput({ device: "gamepad", input: "a" });
await provider.forceFlush();
const points = exporter.getMetrics();
```
The same pattern is exercised in the package's polyfill setup; see
`tests/setup/polyfills.ts` for the canonical wire-up used by the
in-memory MeterProvider assertions.
## API Reference
- `inputsCounter: Counter` — OTel counter `controller_inputs_total`.
- `stateChangesCounter: Counter` —
`controller_state_changes_total`.
- `patternMatchDurationHistogram: Histogram` —
`controller_pattern_match_duration_ms`.
- `recordInput({ device, input }): void` — single-call helper.
- `recordStateChange({ device }): void` — single-call helper.
- `recordPatternMatchDuration(durationMs): void` — histogram helper.
## Implementation Notes
The instruments are module-scoped so the meter is resolved exactly once
per process. Tests inject their `MeterProvider` via
`metrics.setGlobalMeterProvider` BEFORE the source module is imported —
the package's `tests/setup/polyfills.ts` runs as a setup file and
guarantees the binding order. Switching to a deferred-resolve pattern
would require every adapter to plumb a meter object through its
constructor; the current shape keeps the device-adapter APIs free of
observability boilerplate while still allowing override via OTel's
`setGlobalMeterProvider` contract.

View File

@@ -0,0 +1,130 @@
import { metrics } from "@opentelemetry/api";
import {
afterAll,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
flushTestMetrics,
testInMemoryMetricExporter,
testMeterProvider,
} from "../../tests/setup/polyfills.js";
import {
inputsCounter,
patternMatchDurationHistogram,
recordInput,
recordPatternMatchDuration,
recordStateChange,
stateChangesCounter,
} from "./telemetry.js";
describe("telemetry instruments", () => {
beforeEach(() => {
testInMemoryMetricExporter.reset();
});
afterAll(async () => {
await testMeterProvider.shutdown();
metrics.disable();
});
it("exposes the three named instruments", { timeout: 5000 }, () => {
expect(inputsCounter).toBeDefined();
expect(stateChangesCounter).toBeDefined();
expect(patternMatchDurationHistogram).toBeDefined();
});
it("recordInput increments controller_inputs_total with device+input attrs", {
timeout: 5000,
}, async () => {
recordInput({ device: "keyboard", input: "up" });
recordInput({ device: "keyboard", input: "up" });
recordInput({ device: "gamepad", input: "a" });
const snapshots = await flushTestMetrics();
const points: { attributes: Record<string, unknown>; value: number }[] =
[];
for (const rm of snapshots) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === "controller_inputs_total") {
for (const dp of m.dataPoints) {
points.push({
attributes: dp.attributes,
value: dp.value as number,
});
}
}
}
}
}
expect(points.length).toBe(2);
const kbUp = points.find(
(p) =>
p.attributes.device === "keyboard" &&
p.attributes.input === "up"
);
const gpA = points.find(
(p) =>
p.attributes.device === "gamepad" && p.attributes.input === "a"
);
expect(kbUp?.value).toBe(2);
expect(gpA?.value).toBe(1);
});
it("recordStateChange increments controller_state_changes_total", {
timeout: 5000,
}, async () => {
recordStateChange({ device: "keyboard" });
recordStateChange({ device: "keyboard" });
const snapshots = await flushTestMetrics();
let value = 0;
for (const rm of snapshots) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (
m.descriptor.name === "controller_state_changes_total"
) {
for (const dp of m.dataPoints) {
value += dp.value as number;
}
}
}
}
}
expect(value).toBe(2);
});
it("recordPatternMatchDuration writes into the histogram", {
timeout: 5000,
}, async () => {
recordPatternMatchDuration(1.5);
recordPatternMatchDuration(2.5);
const snapshots = await flushTestMetrics();
let count = 0;
let sum = 0;
for (const rm of snapshots) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (
m.descriptor.name ===
"controller_pattern_match_duration_ms"
) {
for (const dp of m.dataPoints) {
const v = dp.value as {
count: number;
sum: number;
};
count += v.count;
sum += v.sum;
}
}
}
}
}
expect(count).toBe(2);
expect(sum).toBe(4);
});
});

View File

@@ -0,0 +1,67 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import type {
InputCounterAttributes,
StateCounterAttributes,
} from "../types/controller.types.js";
const METER_NAME = "@suaveplan/controller";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns a
* no-op meter, so instruments are safe to import in production code that
* does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/**
* Counter for input change events.
*
* Attributes: `device` (string, set by adapter — `keyboard`, `gamepad`,
* etc.) and `input` (the `ControllerInput` key whose value changed).
*/
export const inputsCounter: Counter = meter.createCounter(
"controller_inputs_total",
{
description:
"Total input-change events emitted by a controller, tagged with the device and input key",
}
);
/** Counter for full-state change events; attribute `device`. */
export const stateChangesCounter: Counter = meter.createCounter(
"controller_state_changes_total",
{
description:
"Total state-change events emitted by a controller, tagged with the device",
}
);
/**
* Histogram recording the duration of a pattern-match attempt in
* milliseconds. The instrument is owned by Chunk A so the pattern engine
* landing in a later chunk plugs in by name without redeclaring it.
*/
export const patternMatchDurationHistogram: Histogram = meter.createHistogram(
"controller_pattern_match_duration_ms",
{
description:
"Duration of an input-pattern match attempt in milliseconds",
unit: "ms",
}
);
/** Record a single input-change event with `device` + `input` tags. */
export function recordInput(attrs: InputCounterAttributes): void {
inputsCounter.add(1, { device: attrs.device, input: attrs.input });
}
/** Record a single state-change event with the `device` tag. */
export function recordStateChange(attrs: StateCounterAttributes): void {
stateChangesCounter.add(1, { device: attrs.device });
}
/** Record a pattern-match duration measurement. */
export function recordPatternMatchDuration(durationMs: number): void {
patternMatchDurationHistogram.record(durationMs);
}

View File

@@ -0,0 +1,93 @@
# `controller.types` — shared types
## Purpose
Defines the canonical TypeScript types shared across every module in
`@suaveplan/controller`. Centralising the public type vocabulary here
keeps the shape of `ControllerInput` consistent across `BaseController`,
the event emitter, the device adapters (Chunks B) and the merger
(Chunk C), and lets downstream consumers (`@suaveplan/ecs`,
`@suaveplan/game-server`) import the shape from a single subpath without
pulling the runtime in.
## Features
- `ControllerInput` — the canonical 37-field input shape every adapter
normalises into. Fields are integers in `[0, 255]` post-normalisation.
- `ControllerState` — backward-compatible alias for `ControllerInput`.
- `Vector2D` — return type of `BaseController.getDirectionalVector()`.
- `InputThresholds` — `{ threshold?, deadzone? }` gating bundle accepted
by `setThreshold(input, ...)`.
- `InputChangeCallback`, `StateChangeCallback`, `ComboCallback` — the
three listener signatures used by `InputEventEmitter`.
- `ControllerSnapshot` — `{ input, value, timestamp }` point-in-time
record used by the pattern engine that lands in a follow-up chunk.
- `InputCounterAttributes`, `StateCounterAttributes` — typed attribute
bundles for the OTel counters in `telemetry.ts`.
## Usage: Basic
```ts
import type { ControllerInput, Vector2D } from "@suaveplan/controller";
function magnitude(v: Vector2D): number {
return Math.hypot(v.x, v.y);
}
function isJumping(s: ControllerInput): boolean {
return s.a >= 200;
}
```
## Usage: Advanced
```ts
import type {
ControllerInput,
InputChangeCallback,
InputThresholds,
} from "@suaveplan/controller";
interface AbilityBinding<K extends keyof ControllerInput> {
input: K;
thresholds: InputThresholds;
onTrigger: InputChangeCallback;
}
function bind<K extends keyof ControllerInput>(b: AbilityBinding<K>): AbilityBinding<K> {
return b;
}
const jump = bind({
input: "a",
thresholds: { deadzone: 32, threshold: 200 },
onTrigger: (_input, value, prev) => {
if (value >= 200 && prev < 200) console.log("jump!");
},
});
```
## API Reference
- `interface ControllerInput` — 37-field numeric shape.
- `type ControllerState = ControllerInput` — alias.
- `interface Vector2D { x: number; y: number }`.
- `interface InputThresholds { threshold?: number; deadzone?: number }`.
- `type InputChangeCallback = (input, value, prev) => void`.
- `type StateChangeCallback = (state, oldState?) => void`.
- `type ComboCallback = (combo: string[]) => void`.
- `interface ControllerSnapshot { input, value, timestamp }`.
- `interface InputCounterAttributes { device, input }`.
- `interface StateCounterAttributes { device }`.
## Implementation Notes
Every type is declared with explicit field order so adding a new key
shows up as an intentional diff in code review. The 37 fields are split
into named comment-bands (directional, face, triggers, function, analog
sticks, stick buttons, analog triggers, motion, touch, special) so the
file scans like the spec's grouped description. The runtime iteration
order over these keys lives in `keys.ts` — keep the two files in lockstep
when adding or removing a key. All callback shapes accept the typed
`keyof ControllerInput` discriminator (rather than `string`) so misspelled
keys are caught at the call site.

View File

@@ -0,0 +1,128 @@
/**
* Canonical 37-field controller input shape.
*
* Every device adapter (keyboard, gamepad, touch, mouse) normalises into
* this exact shape. Field values are integers in `[0, 255]` after
* `BaseController` normalisation (clamp → round → deadzone → threshold).
*
* The 37 keys are stable wire identifiers; reordering is a breaking change.
*/
export interface ControllerInput {
// Directional inputs
up: number;
down: number;
left: number;
right: number;
// Face buttons
a: number;
b: number;
x: number;
y: number;
// Triggers / bumpers
t1: number;
t2: number;
t3: number;
t4: number;
// Function buttons
f1: number;
f2: number;
f3: number;
f4: number;
// Analog sticks
lx: number;
ly: number;
rx: number;
ry: number;
// Stick buttons (l3 / r3)
l3: number;
r3: number;
// Analog triggers (lt / rt)
lt: number;
rt: number;
// Motion (gyro + accelerometer)
gx: number;
gy: number;
gz: number;
mx: number;
my: number;
mz: number;
// Touch (tx/ty position, tp pressure)
tx: number;
ty: number;
tp: number;
// Special (vibration low/high, battery, player index)
vl: number;
vh: number;
bt: number;
pi: number;
}
/** Backward-compatible alias for callers that imported the old name. */
export type ControllerState = ControllerInput;
/** 2D vector used by `BaseController.getDirectionalVector()`. */
export interface Vector2D {
x: number;
y: number;
}
/**
* Per-input gating configuration.
*
* - `deadzone` — values with `|v| < deadzone` are snapped to 0.
* - `threshold` — values `0 < v < threshold` are snapped to 0.
*
* Both default to 0 (no gating). Configured via
* `BaseController.setThreshold(input, options)`.
*/
export interface InputThresholds {
threshold?: number;
deadzone?: number;
}
/** Listener fired when a single input value crosses normalisation. */
export type InputChangeCallback = (
input: keyof ControllerInput,
value: number,
prevValue: number
) => void;
/** Listener fired when any field of the controller state changes. */
export type StateChangeCallback = (
state: ControllerInput,
oldState?: Partial<ControllerInput>
) => void;
/** Listener fired when a press-state change emits a combo sequence. */
export type ComboCallback = (combo: string[]) => void;
/**
* Snapshot of an input value at a point in time, captured via the injected
* `Clock` (`@suaveplan/types`). Used by the buffer + pattern engine that
* lands in Chunks B+C.
*/
export interface ControllerSnapshot {
input: keyof ControllerInput;
value: number;
timestamp: number;
}
/** Telemetry attribute set for the `controller_inputs_total` counter. */
export interface InputCounterAttributes {
device: string;
input: string;
}
/** Telemetry attribute set for the `controller_state_changes_total` counter. */
export interface StateCounterAttributes {
device: string;
}

View File

@@ -0,0 +1,16 @@
export type {
ComboCallback,
ControllerInput,
ControllerSnapshot,
ControllerState,
InputChangeCallback,
InputCounterAttributes,
InputThresholds,
StateChangeCallback,
StateCounterAttributes,
Vector2D,
} from "./controller.types.js";
export {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "./keys.js";

View File

@@ -0,0 +1,80 @@
# Controller input keys
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the canonical iteration order over the 37 `ControllerInput` keys
and a `createDefaultControllerInput()` factory that returns a fresh
zero-initialised state object. Centralising the key list here prevents the
37-field shape from drifting across `BaseController`, the device adapters,
and the merger — every consumer that walks the shape iterates this same
constant.
## Features
- `CONTROLLER_INPUT_KEYS` — `readonly` tuple of the 37 keys in stable
documented order (directional → face → triggers → function → analog
sticks → stick buttons → analog triggers → motion → touch → special).
- `createDefaultControllerInput()` — factory returning a new object with
every key initialised to `0`.
- Deterministic ordering, suitable for the merger's per-field max
reduction and for any serialiser that needs reproducible output.
- Zero allocation cost beyond the returned object — the key list is
shared module state.
## Usage: Basic
```ts
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
} from "@suaveplan/controller";
const state = createDefaultControllerInput();
console.log(state.up); // 0
console.log(CONTROLLER_INPUT_KEYS.length); // 37
```
The shape has exactly 37 enumerated keys (4 directional + 4 face + 4
trigger + 4 function + 4 analog axes + 2 stick buttons + 2 analog
triggers + 6 motion + 3 touch + 4 special). The exported tuple
preserves the exact iteration order the merger relies on.
## Usage: Advanced
```ts
import {
CONTROLLER_INPUT_KEYS,
createDefaultControllerInput,
type ControllerInput,
} from "@suaveplan/controller";
function mergeMax(a: ControllerInput, b: ControllerInput): ControllerInput {
const out = createDefaultControllerInput();
for (const key of CONTROLLER_INPUT_KEYS) {
out[key] = Math.max(a[key], b[key]);
}
return out;
}
```
The merger that ships in Chunk C uses exactly this loop shape.
## API Reference
- `CONTROLLER_INPUT_KEYS: readonly (keyof ControllerInput)[]` — frozen
tuple of the 32 controller-input keys.
- `createDefaultControllerInput(): ControllerInput` — returns a brand-new
object with every field set to `0`.
## Implementation Notes
The key list is declared `as const` so TypeScript infers the literal
tuple type and downstream code can narrow `keyof ControllerInput` against
it without a runtime check. The factory iterates the same tuple to
guarantee that adding or removing a key flows through one source of
truth. Both exports are tree-shakeable; consumers that only need one pay
no cost for the other.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTROLLER_INPUT_KEYS, createDefaultControllerInput } from "./keys.js";
describe("CONTROLLER_INPUT_KEYS", () => {
it("contains exactly 37 unique keys in stable order", {
timeout: 5000,
}, () => {
expect(CONTROLLER_INPUT_KEYS.length).toBe(37);
const set = new Set<string>(CONTROLLER_INPUT_KEYS);
expect(set.size).toBe(CONTROLLER_INPUT_KEYS.length);
// First-group sanity: directional keys come first
expect(CONTROLLER_INPUT_KEYS[0]).toBe("up");
expect(CONTROLLER_INPUT_KEYS[CONTROLLER_INPUT_KEYS.length - 1]).toBe(
"pi"
);
});
});
describe("createDefaultControllerInput", () => {
it("returns a fresh zero-initialised object", { timeout: 5000 }, () => {
const state = createDefaultControllerInput();
for (const key of CONTROLLER_INPUT_KEYS) {
expect(state[key]).toBe(0);
}
expect(Object.keys(state).length).toBe(CONTROLLER_INPUT_KEYS.length);
});
it("returns a new instance on every call", { timeout: 5000 }, () => {
const a = createDefaultControllerInput();
const b = createDefaultControllerInput();
expect(a).not.toBe(b);
a.up = 200;
expect(b.up).toBe(0);
});
});

View File

@@ -0,0 +1,56 @@
import type { ControllerInput } from "./controller.types.js";
/**
* Canonical iteration order over `ControllerInput` keys.
*
* Order matters for `ControllerMerger`'s deterministic per-field max
* reduction and for any serialiser that walks the shape.
*/
export const CONTROLLER_INPUT_KEYS: readonly (keyof ControllerInput)[] = [
"up",
"down",
"left",
"right",
"a",
"b",
"x",
"y",
"t1",
"t2",
"t3",
"t4",
"f1",
"f2",
"f3",
"f4",
"lx",
"ly",
"rx",
"ry",
"l3",
"r3",
"lt",
"rt",
"gx",
"gy",
"gz",
"mx",
"my",
"mz",
"tx",
"ty",
"tp",
"vl",
"vh",
"bt",
"pi",
] as const;
/** Build a fresh zero-initialised `ControllerInput`. */
export function createDefaultControllerInput(): ControllerInput {
const out = {} as ControllerInput;
for (const key of CONTROLLER_INPUT_KEYS) {
out[key] = 0;
}
return out;
}

View File

@@ -0,0 +1,40 @@
/**
* Test setup for @suaveplan/controller.
*
* 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-controller",
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: 5000,
hookTimeout: 5000,
},
});

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]);
});
});

Some files were not shown because too many files have changed in this diff Show More