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
requesttriggers 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 | requestdiscriminated 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.