Files
skills/docs/gaming/games/game-server/src/state/state-sync/state-sync.md
2026-05-29 08:20:56 +01:00

4.3 KiB

StateSync

(no submodules)

Purpose

StateSync is the orchestration layer that turns StateManager mutations into wire packets and applies received packets back into the manager. It brokers between a single StateManager<TState> and a pluggable StateSyncTransport — WebSocket, WebRTC data channel, in-process pipe — deciding per call whether to send a delta, a full snapshot, or honour an inbound request. The state-sync algorithm is otherwise transport-agnostic.

The Wave B-3 port replaces the upstream's hard dependency on NetworkAdapter, GameEventEmitter, and Logger with a single StateSyncTransport interface and OTel-native instrumentation. Push cadence is left to callers — there is no internal setInterval — because game loops, dirty-flag policies, and bandwidth limiters belong to the application layer, not the sync engine.

Features

  • Three-message protocol (delta, full, request) over an abstract transport.
  • Automatic fallback to full snapshot when the delta exceeds maxDeltaSize.
  • Initial-sync handshake: when no previous state has been synced, sends a full snapshot.
  • Inbound request triggers an outbound full snapshot.
  • Per-kind OTel counters + bytes histogram.
  • Typed errors: transport faults surface as STATE_SYNC_TRANSPORT_FAILED.
  • Statistics snapshot and reset.
  • Disposal removes the transport listener.

Usage: Basic

import { StateManager } from "@suaveplan/game-server/state/state-manager";
import { StateSync } from "@suaveplan/game-server/state/state-sync";

interface World { tick: number }

const manager = new StateManager<World>({ tick: 0 });
const sync = new StateSync(manager, transport);

// On each game tick:
manager.update("tick", manager.get("tick") + 1);
await sync.sync();

Usage: Advanced

import type { StateSyncTransport, StateSyncMessage } from "@suaveplan/game-server/state/state-sync";

// In-process pipe for tests / single-process play
class InProcessTransport implements StateSyncTransport {
    private readonly handlers = new Set<(m: StateSyncMessage) => void>();
    public peer?: InProcessTransport;
    isConnected() { return this.peer !== undefined; }
    onMessage(h: (m: StateSyncMessage) => void) {
        this.handlers.add(h);
        return () => { this.handlers.delete(h); };
    }
    async send(m: StateSyncMessage) {
        for (const h of this.peer?.handlers ?? []) h(m);
    }
}

const t1 = new InProcessTransport();
const t2 = new InProcessTransport();
t1.peer = t2; t2.peer = t1;

const sync = new StateSync(manager, t1, {
    config: { maxDeltaSize: 4096 },
});

await sync.requestFullSync(); // ask peer for full state

API Reference

class StateSync<TState extends object>

constructor(manager, transport, options?)

options — { compressor?, clock?, meter?, config?: { maxDeltaSize? } }.

sync(): Promise<void>

Sends a delta or, on first call or when the delta exceeds maxDeltaSize, a full snapshot.

syncFull(): Promise<void> / requestFullSync(): Promise<void>

getStats(): SyncStats / resetStats(): void

dispose(): void

Types

  • StateSyncTransport — { isConnected, send, onMessage }.
  • StateSyncMessage — delta | full | request discriminated union.
  • SyncStats — counters for deltas/fulls/requests sent + received, bytes, ratio, last timestamp.
  • StateSyncConfig — { maxDeltaSize? }.

Implementation Notes

The snapshotCurrent step explicitly deep-clones via JSON.parse(JSON.stringify(...)) so the lastSyncedState does not retain references to the manager's deep-frozen snapshots — any subsequent attempt to compute a delta would otherwise compare two snapshots whose primitive leaves are identical but whose container references differ, producing spurious replace ops. JSON-clone is acceptable because the sync protocol already constrains the state to JSON-serialisable shapes.

Transport errors are wrapped in GameServerError(STATE_SYNC_TRANSPORT_FAILED) with the failing message kind on context so structured logs and observability dashboards can pinpoint the misbehaving leg of the connection. Inbound message handling does not catch transport-side errors — those are the transport's problem; the StateSync contract is that onMessage only delivers well-formed StateSyncMessage values.