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

7.9 KiB

Capability Spec Delta: game-server (game-server-enhanced-infra)

Package: @suaveplan/game-server Status: PROPOSED

ADDED Requirements

Requirement: Game instance registry with TTL eviction and load-balancing

The package SHALL expose a GameInstanceRegistry<TCustom> class that registers live game-server instances, evicts them when their last heartbeat is older than instanceTTL milliseconds (as reported by an injected Clock), supports filter-based discovery, and selects one healthy instance per request using one of four load-balancing strategies (least-players, random, region-based, round-robin). All clock and randomness reads SHALL flow through injected Clock and Rng dependencies.

Scenario: register / discover / deregister round-trip

  • WHEN the caller registers an instance, then calls discover() with no filter
  • THEN the registered instance appears in the result, defaulted to HEALTHY
  • WHEN the caller deregisters
  • THEN the next discover() returns an empty array

Scenario: heartbeat expiry triggers eviction

  • WHEN the injected clock advances past instanceTTL * 2 since the last heartbeat
  • THEN the registry transitions the instance to UNHEALTHY first, then deregisters it on the second sweep
  • AND the corresponding GameInstanceEvent.HEALTH_CHANGED and GameInstanceEvent.DEREGISTERED events fire

Scenario: least-players strategy picks lowest player count

  • WHEN two healthy instances exist with playerCount = 5 and playerCount = 2
  • THEN selectInstance(..., 'least-players') returns the second one with score = 2

Scenario: region-based strategy honours preferred region

  • WHEN the caller passes filter.region = "eu-west" and at least one EU instance exists
  • THEN the selected instance has metadata.region === "eu-west"

Scenario: round-robin rotates across calls

  • WHEN three instances are available and selectInstance(..., 'round-robin') is called three times
  • THEN each call returns a different instance, then the rotation repeats

Verified by: src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"registers and discovers an instance" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"evicts after TTL expiry" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"least-players selects lowest count" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"region-based prefers configured region" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"round-robin rotates across calls"


Requirement: BitPacker-backed binary state synchronisation

The package SHALL expose a BitPackerStateSync<TState, TSchema extends BitPackerSchema> class that packs TState snapshots to Uint8Array via @suaveplan/bitpacker, supports client-side prediction with a bounded history window, supports server reconciliation against a tick-tagged authoritative state, supports linear interpolation between two history snapshots, and aggregates wire-byte counters into a SyncStats view. All timestamps SHALL flow through an injected Clock. Pack/unpack failures SHALL throw GameServerError with BITPACK_ENCODE_FAILED / BITPACK_DECODE_FAILED.

Scenario: pack then unpack round-trip preserves state

  • WHEN the caller packs a TState value and unpacks the resulting Uint8Array
  • THEN the unpacked value equals the original

Scenario: prediction records a history frame

  • WHEN predict(state, input, simulate) is called
  • THEN the returned state equals simulate(state, input.data)
  • AND getStats().predictionsMade increments by one

Scenario: reconciliation re-applies pending inputs above server tick

  • WHEN the client has predicted three frames at ticks 11, 12, 13 and the server delivers a state at tick 10
  • AND the client's snapshot at tick 10 differs from the server state beyond the threshold
  • THEN reconcile(...) returns needed: true with framesResimulated = 3 and a state derived from re-applying the three pending inputs

Scenario: interpolation lerps between snapshots

  • WHEN the caller passes two snapshots at timestamps 100 and 200 and a render time bracketing the targetTime
  • THEN the returned state equals lerp(before, after, t) for some t ∈ [0, 1]

Scenario: bitpack failure routes to typed error

  • WHEN BitPacker.pack throws
  • THEN packState re-throws a GameServerError carrying code GAME_SERVER.BITPACK_ENCODE_FAILED

Verified by: src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"packs and unpacks state" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"prediction records a history frame" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"reconciliation re-applies pending inputs above the server tick" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"interpolation lerps between two snapshots" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"throws BITPACK_ENCODE_FAILED when bitpacker throws"


Requirement: Vector-clock distributed action ordering

The package SHALL expose a VectorClockSync<TEventData> class that records game actions per session, tags each action with a vector-clock snapshot, detects concurrent actions, resolves conflicts via one of three strategies (last-write-wins, merge, custom), produces a causality chain in happens-before order, and exposes request/response sync round-trips between nodes. The internal vector clock SHALL be the createVectorClock primitive from @suaveplan/timing. Action IDs SHALL be minted through an injected IdGenerator; timestamps SHALL flow through an injected Clock.

Scenario: recordAction tags with current vector clock

  • WHEN recordAction(sessionId, "move", data, tick) is called
  • THEN the returned GameAction.vectorClock carries this node's counter at >= 1
  • AND getStats().totalActions equals the count of recordings

Scenario: receiveAction detects concurrent conflict

  • WHEN two actions are recorded by different nodes with vector clocks that are mutually concurrent (compare === "concurrent")
  • THEN receiveAction(remote) returns a ConflictResolution with conflictDetected: true
  • AND the chosen winner under last-write-wins is the one with the larger timestamp

Scenario: custom resolver overrides default strategy

  • WHEN the registry is constructed with a conflictResolver
  • THEN the resolver function is called with each (winner, candidate) pair and its return value becomes the resolved action
  • AND ConflictResolution.strategy === "custom"

Scenario: causality chain sorts by happens-before

  • WHEN action A is recorded, then action B is recorded on the same node
  • THEN getCausalityChain(sessionId).actions[0] is A and [1] is B

Scenario: sync request returns missing actions

  • WHEN node X has recorded actions [a1, a2] and node Y has recorded none
  • AND Y issues requestSync(X, sessionId) with its current (empty) vector clock
  • THEN X's handleSyncRequest(request) returns a response carrying both a1 and a2

Verified by: src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"recordAction tags with the current vector clock" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"receiveAction detects concurrent conflict and resolves last-write-wins" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"custom resolver overrides default strategy" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"causality chain sorts by happens-before" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"sync request returns missing actions"