Files
suaveplan/openspec/archive/2026-05-24-game-server-engine-core/specs/game-server/spec.md
2026-05-27 23:29:53 +01:00

10 KiB

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: <GameLoop.getStats()>, plugins: <PluginManager.getStats()> }

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"