# Capability Spec Delta: game-server (game-server-engine-core) **Package**: `@suaveplan/game-server` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: GameLoop deterministic ticking The package SHALL provide `GameLoop` driving a deterministic fixed-step loop with `tick-based`, `event-driven`, and `hybrid` modes. All wall-clock reads SHALL route through an injected `Clock`; all interval scheduling SHALL route through an injected `TimersLike`. The loop SHALL NOT call `Date.now()` or `setTimeout`/`setInterval` directly. #### Scenario: tick-based mode schedules at the configured tick rate - **WHEN** `start()` is called with `mode: "tick-based", tickRate: 30` - **THEN** the loop schedules a recurring timer via `TimersLike.setTimer` with delay `1000 / 30` ms and invokes registered fixed-update callbacks each tick #### Scenario: pause halts ticking, resume restarts without retroactive deltas - **WHEN** `pause()` then later `resume()` is called - **THEN** the resume call refreshes `lastTickTime` from the injected `Clock` and the next tick's `deltaTime` is bounded by `maxDeltaTime` #### Scenario: manual `tick()` invokes all callbacks in registration order - **WHEN** `tick()` is called with two registered fixed-update callbacks and one update callback - **THEN** both fixed-update callbacks fire (in insertion order) then the update callback fires once #### Scenario: invalid options reject with INVALID_GAME_LOOP_OPTIONS - **WHEN** the loop is constructed with `tickRate: 0` or a negative `maxDeltaTime` - **THEN** a `GameServerError(GAME_SERVER.INVALID_GAME_LOOP_OPTIONS)` is thrown #### Scenario: starting a running loop rejects with GAME_LOOP_ALREADY_RUNNING - **WHEN** `start()` is called twice in a row - **THEN** the second call throws `GameServerError(GAME_SERVER.GAME_LOOP_ALREADY_RUNNING)` #### Scenario: pause/resume/stop on idle loop rejects with GAME_LOOP_NOT_RUNNING - **WHEN** `pause()`, `resume()`, or `stop()` is called before `start()` - **THEN** each call throws `GameServerError(GAME_SERVER.GAME_LOOP_NOT_RUNNING)` #### Scenario: callback errors propagate via injected EventBus - **WHEN** a registered update callback throws inside `tick()` - **THEN** the loop emits a `loop:error` event with the original error and continues processing remaining callbacks #### Scenario: OTel histogram records each tick duration - **WHEN** `tick()` completes - **THEN** the `game_server.loop.tick_duration_ms` histogram receives one sample tagged with the current `mode` **Verified by:** `src/engine/game-loop/game-loop.test.ts::"schedules tick-based ticks via injected TimersLike at the configured rate"` AND `src/engine/game-loop/game-loop.test.ts::"pause halts ticking and resume refreshes lastTickTime"` AND `src/engine/game-loop/game-loop.test.ts::"manual tick invokes fixed-update then update callbacks in order"` AND `src/engine/game-loop/game-loop.test.ts::"rejects construction with invalid options as INVALID_GAME_LOOP_OPTIONS"` AND `src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_ALREADY_RUNNING on second start"` AND `src/engine/game-loop/game-loop.test.ts::"throws GAME_LOOP_NOT_RUNNING when paused/resumed/stopped before start"` AND `src/engine/game-loop/game-loop.test.ts::"callback errors emit loop:error and do not stop the loop"` AND `src/engine/game-loop/game-loop.test.ts::"records a tick_duration_ms histogram sample per tick"` --- ### Requirement: PluginManager lifecycle registry The package SHALL provide `PluginManager` accepting a typed `EnginePlugin` contract. Install SHALL reject duplicates with `DUPLICATE_PLUGIN`, lookup SHALL reject unknown names with `PLUGIN_NOT_FOUND`, and every plugin lifecycle hook (`onInstall`, `onUninstall`, `onEnable`, `onDisable`) that throws SHALL be wrapped in `PLUGIN_LIFECYCLE_FAILED` carrying the plugin name as context. #### Scenario: install registers and emits OTel counter - **WHEN** `install(plugin)` is called with a fresh plugin - **THEN** `getPlugin(name)` returns the plugin, `onInstall` was invoked once with the engine handle, and the `game_server.plugin.lifecycle_total` counter increments with `op: "install"` #### Scenario: duplicate install rejects - **WHEN** `install(plugin)` is called twice with the same plugin name - **THEN** the second call throws `GameServerError(GAME_SERVER.DUPLICATE_PLUGIN)` #### Scenario: unknown plugin name on uninstall/enable/disable/get rejects - **WHEN** `uninstall("nope")`, `enable("nope")`, `disable("nope")`, or `get("nope")` is called - **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_NOT_FOUND)` #### Scenario: enable then disable toggles state and fires hooks - **WHEN** an installed plugin is enabled then disabled - **THEN** `onEnable` and then `onDisable` each fire once and `isEnabled(name)` returns the matching state #### Scenario: plugin lifecycle errors wrap to PLUGIN_LIFECYCLE_FAILED - **WHEN** a plugin's `onInstall` throws - **THEN** `install` rejects with `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` whose `cause` is the original error and `context` includes the plugin name and hook name #### Scenario: dependency topological install order - **WHEN** `installAll([pluginA depends on B, pluginB])` is called - **THEN** `pluginB.onInstall` fires before `pluginA.onInstall` #### Scenario: missing dependency rejects - **WHEN** `installAll([pluginA depends on Z])` is called with no `pluginZ` - **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` with the missing dependency name in `context` #### Scenario: circular dependency rejects - **WHEN** `installAll([A depends on B, B depends on A])` is called - **THEN** the call throws `GameServerError(GAME_SERVER.PLUGIN_LIFECYCLE_FAILED)` with `cycle` in `context` **Verified by:** `src/engine/plugin-manager/plugin-manager.test.ts::"install registers the plugin and fires onInstall + OTel counter"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"second install with the same name rejects with DUPLICATE_PLUGIN"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"unknown plugin name rejects with PLUGIN_NOT_FOUND on uninstall/enable/disable/get"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"enable then disable toggles state and fires both hooks"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"hook errors wrap into PLUGIN_LIFECYCLE_FAILED with cause and context"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll honours topological dependency order"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects when a declared dependency is missing"` AND `src/engine/plugin-manager/plugin-manager.test.ts::"installAll rejects on circular dependency"` --- ### Requirement: GameEngine top-level orchestrator The package SHALL provide `GameEngine` wiring a `DIContainer`, a `GameLoop`, and a `PluginManager` plus opaque service slots (`logger`, `events`, `sessionManager`, `playerManager`, `stateManager`, `gameLogic`). Construction SHALL Zod-validate options and reject with `INVALID_ENGINE_OPTIONS`. The engine SHALL refuse `tick()`/`stop()` before `start()` (`ENGINE_NOT_STARTED`), refuse double `start()` (`ENGINE_ALREADY_STARTED`), and refuse any method after `dispose()` (`ENGINE_DISPOSED`). #### Scenario: constructor registers core services in DIContainer - **WHEN** the engine is constructed with a logger, events, gameLoop, pluginManager, and IdGenerator - **THEN** each is resolvable from `engine.container` under its canonical identifier #### Scenario: start emits OTel + transitions running state - **WHEN** `start()` is called on a fresh engine - **THEN** the underlying `GameLoop.start()` is invoked, `isRunning()` returns `true`, and `game_server.engine.lifecycle_total{op: "start"}` increments #### Scenario: double start rejects - **WHEN** `start()` is called a second time - **THEN** the call throws `GameServerError(GAME_SERVER.ENGINE_ALREADY_STARTED)` #### Scenario: tick without start rejects - **WHEN** `tick()` is called before `start()` - **THEN** the call throws `GameServerError(GAME_SERVER.ENGINE_NOT_STARTED)` #### Scenario: dispose clears container, stops loop, blocks future calls - **WHEN** `dispose()` is called then `start()` is called - **THEN** the second `start()` throws `GameServerError(GAME_SERVER.ENGINE_DISPOSED)` #### Scenario: registerPlugin delegates to PluginManager - **WHEN** `registerPlugin(plugin)` is called - **THEN** the underlying `PluginManager.install(plugin)` runs and `getPlugin(name)` returns it #### Scenario: invalid options rejected at construction - **WHEN** the engine is constructed with `playerTimeout: -1` - **THEN** the constructor throws `GameServerError(GAME_SERVER.INVALID_ENGINE_OPTIONS)` #### Scenario: getStats reports lifecycle + child stats - **WHEN** `getStats()` is called on a running engine - **THEN** the result includes `{ running: true, disposed: false, loop: , plugins: }` **Verified by:** `src/engine/game-engine/game-engine.test.ts::"constructor registers logger/events/loop/plugins in the container"` AND `src/engine/game-engine/game-engine.test.ts::"start invokes loop.start, transitions running, increments OTel"` AND `src/engine/game-engine/game-engine.test.ts::"second start throws ENGINE_ALREADY_STARTED"` AND `src/engine/game-engine/game-engine.test.ts::"tick before start throws ENGINE_NOT_STARTED"` AND `src/engine/game-engine/game-engine.test.ts::"dispose blocks future start with ENGINE_DISPOSED"` AND `src/engine/game-engine/game-engine.test.ts::"registerPlugin delegates to the underlying PluginManager"` AND `src/engine/game-engine/game-engine.test.ts::"invalid options throw INVALID_ENGINE_OPTIONS"` AND `src/engine/game-engine/game-engine.test.ts::"getStats reports running/disposed/loop/plugins"` --- ### Requirement: Subpath exports The package SHALL expose the three feature directories via additive `exports` map entries so consumers can tree-shake. #### Scenario: each feature dir is independently importable - **WHEN** a consumer imports from `@suaveplan/game-server/engine/game-engine`, `@suaveplan/game-server/engine/game-loop`, and `@suaveplan/game-server/engine/plugin-manager` - **THEN** the import resolves to the corresponding feature barrel without pulling sibling code **Verified by:** `packages/gaming/games/game-server/package.json::"exports./engine/game-engine"` AND `packages/gaming/games/game-server/package.json::"exports./engine/game-loop"` AND `packages/gaming/games/game-server/package.json::"exports./engine/plugin-manager"`