| name | engineering-game-state-sync |
| description | Use when implementing client-server state synchronization, delta compression, optimistic updates, rollback netcode, or real-time game state reconciliation. Triggers: state sync, netcode, delta, rollback, interpolation, prediction. |
Game State Sync
Purpose
Client-server state reconciliation, delta compression, optimistic updates, and rollback for multiplayer games.
When to Use
Trigger: state sync, client-server, delta compression, optimistic updates, rollback, netcode, lag compensation, snapshot, interpolation, reconciliation
Prerequisites
game-backend-architecture — server loop, tick system, authority model
redis-game-patterns — pub/sub for cross-server state distribution
Core Principles
- Server is authoritative — client predicts, server confirms or corrects. Never trust the client.
- Delta compression — send only what changed, not full state. Reduces bandwidth by 80-95% in typical games.
- Optimistic updates — apply input locally for instant feedback, revert if server disagrees.
- Snapshot + interpolation — render smoothly between discrete server updates by interpolating between two known states.
- Rollback — maintain a history buffer to rewind and replay when the server corrects a misprediction.
- Tick-aligned — all state changes happen at discrete tick boundaries. No floating-point time drift.
- Bandwidth budget — target < 10KB/s per player for typical games. Monitor and alert on spikes.
Step-by-Step
1. Define Your State Shape
Use a generic type parameter so the sync engine works with any game state. Keep state flat where possible — deep nesting increases delta computation cost.
interface MyGameState {
players: Record<string, PlayerState>;
projectiles: Record<string, ProjectileState>;
world: WorldState;
}
2. Set Up the Sync Engine
import { StateSyncEngine } from './state-sync';
const engine = new StateSyncEngine<MyGameState>({
tickRate: 20,
snapshotInterval: 3,
historyLength: 64,
interpolationDelay: 2,
});
3. Client Prediction Loop
On the client, apply inputs immediately without waiting for server confirmation.
const input: PlayerInput = { tick: currentTick, actions: getLocalInputs() };
engine.applyLocalInput(input);
sendToServer(input);
onServerState((serverState, serverTick) => {
engine.reconcile(serverState, serverTick);
});
4. Server Authority Loop
Server processes all inputs, advances the simulation, and broadcasts deltas.
const previousState = engine.snapshot();
processAllInputs(pendingInputs);
advanceSimulation(tickDelta);
const currentState = engine.snapshot();
const delta = engine.generateDelta(previousState, currentState);
if (delta) {
broadcastToClients(delta, currentTick);
}
5. Delta Compression
Use the delta encoder for bandwidth-efficient state transfer.
import { encodeDelta, decodeDelta } from './delta-encoder';
const delta = encodeDelta(previousState, currentState);
const reconstructed = decodeDelta(lastKnownState, delta);
6. Rollback on Misprediction
When server state diverges from client prediction, roll back and replay.
import { RollbackBuffer } from './rollback-buffer';
const buffer = new RollbackBuffer<MyGameState>(64);
buffer.push(currentTick, currentState);
const correctedState = buffer.rollbackTo(serverTick);
replayInputs(correctedState, serverTick, currentTick);
7. Snapshot Interpolation (Visual Smoothing)
Render between two known server states for smooth visuals even at low tick rates.
const renderTick = currentTick - interpolationDelay;
const prev = buffer.getAt(Math.floor(renderTick));
const next = buffer.getAt(Math.ceil(renderTick));
const alpha = renderTick % 1;
const renderState = interpolate(prev, next, alpha);
Code Examples
See boilerplate files:
boilerplate/state-sync.ts — Core StateSyncEngine class with prediction, reconciliation, and snapshot management
boilerplate/delta-encoder.ts — Deep object diff with encodeDelta / decodeDelta and binary encoding option
boilerplate/rollback-buffer.ts — Ring buffer with push, getAt, rollbackTo for memory-efficient history
Client Architecture
The sections above cover the server-authoritative sync model. This section covers the client-side architecture that complements it: input handling, prediction, dead reckoning, and visual smoothing.
Input Handling
Collect raw inputs (keyboard, mouse, gamepad) at display frame rate, then sample them at the simulation tick rate. Use a ring buffer to queue inputs for sending to the server.
const rawInputs: RawInput[] = [];
function onFrame() {
rawInputs.push(captureCurrentInput());
}
function onTick(tickId: number) {
const sampled = sampleInputs(rawInputs, tickId);
rawInputs.length = 0;
inputBuffer.push({ tickId, input: sampled });
sendToServer({ tickId, input: sampled });
}
Key rules:
- Never drop inputs. If the tick rate is lower than the frame rate, merge intermediate inputs (e.g., combine multiple mouse deltas).
- Tag every input with its tick ID so the server can process inputs in tick order.
- Keep the ring buffer sized to at least
RTT / tickDuration * 2 entries for replay during rollback.
Client-Side Prediction Loop
Apply inputs locally before waiting for server confirmation. This gives the player instant feedback while the server validates.
const input = captureTickInput(currentTick);
localState = applyInput(localState, input);
inputBuffer.push({ tick: currentTick, input, predictedState: localState });
sendToServer(input);
The prediction is "optimistic" — it assumes the server will agree. When the server sends its authoritative state, the client reconciles by replaying unacknowledged inputs on top of the server state (see Rollback section above).
Input Buffer
Store the last N inputs with their tick ID for replay during server reconciliation. The buffer must be large enough to cover the round-trip time to the server.
interface BufferedInput<TInput> {
tick: number;
input: TInput;
predictedState: GameState;
}
class InputRingBuffer<TInput> {
private buffer: (BufferedInput<TInput> | null)[];
private head = 0;
private capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
this.buffer = new Array(capacity).fill(null);
}
push(entry: BufferedInput<TInput>): void {
this.buffer[this.head] = entry;
this.head = (this.head + 1) % this.capacity;
}
getInputsSince(tick: number): BufferedInput<TInput>[] {
const results: BufferedInput<TInput>[] = [];
for (let i = 0; i < this.capacity; i++) {
const entry = this.buffer[i];
if (entry && entry.tick >= tick) results.push(entry);
}
return results.sort((a, b) => a.tick - b.tick);
}
clear(): void {
this.buffer.fill(null);
this.head = 0;
}
}
Dead Reckoning
When no server update has arrived for an entity, extrapolate its position based on the last known velocity and acceleration. This prevents entities from "freezing" during packet loss.
interface EntitySnapshot {
position: { x: number; y: number };
velocity: { x: number; y: number };
lastUpdateTick: number;
}
function deadReckon(
entity: EntitySnapshot,
currentTick: number,
tickDuration: number,
): { x: number; y: number } {
const elapsed = (currentTick - entity.lastUpdateTick) * tickDuration;
return {
x: entity.position.x + entity.velocity.x * elapsed,
y: entity.position.y + entity.velocity.y * elapsed,
};
}
Key rules:
- Cap extrapolation time (e.g., max 500ms). Beyond that, the entity is stale — show a visual indicator.
- When the server update finally arrives, blend from the dead-reckoned position to the corrected position over 2-3 frames to avoid snapping.
- For non-linear motion (e.g., turning vehicles), include angular velocity in the snapshot.
Visual Smoothing
Separate the render loop from the game logic loop. The game logic runs at a fixed tick rate, while rendering runs at the display refresh rate and interpolates between the previous and current game states.
let previous = performance.now();
let accumulator = 0;
const tickDuration = 1000 / TICK_RATE;
let prevState = initialState;
let currState = initialState;
function loop(timestamp: number) {
const dt = timestamp - previous;
previous = timestamp;
accumulator += dt;
while (accumulator >= tickDuration) {
prevState = currState;
currState = simulateTick(currState);
accumulator -= tickDuration;
}
const alpha = accumulator / tickDuration;
render(interpolate(prevState, currState, alpha));
requestAnimationFrame(loop);
}
This separation ensures:
- Game logic is deterministic and tick-aligned (no frame rate dependency)
- Rendering is smooth at any refresh rate (60Hz, 120Hz, 144Hz)
- Visual positions are always between two valid game states — no overshoot artifacts
Client Game Loop
See boilerplate/game-loop-client.ts for a full TypeScript implementation of the client game loop that combines all the above concepts: fixed timestep, input sampling, client-side prediction, server reconciliation, and requestAnimationFrame-based rendering.
import { ClientGameLoop } from './game-loop-client';
const gameLoop = new ClientGameLoop({
tickRate: 20,
inputBufferSize: 128,
interpolationDelay: 2,
maxExtrapolationMs: 500,
});
gameLoop.onTick = (state, tick) => {
return applyPhysics(applyInputs(state, getLocalInput()));
};
gameLoop.onRender = (state, alpha) => {
renderer.draw(state, alpha);
};
gameLoop.onServerUpdate = (serverState, serverTick) => {
};
gameLoop.start();
Cross-References
- game-backend-architecture — Server tick loop, authority model, input processing pipeline. The sync engine sits on top of the game loop.
- redis-game-patterns — Redis pub/sub for distributing state deltas across multiple server instances. Use sorted sets for leaderboard state that doesn't need tick-aligned sync.
Pitfalls
Desync Bugs
- Floating-point determinism — Different platforms compute slightly different float results. Use fixed-point arithmetic for physics or quantize values before comparison.
- Input ordering — Process inputs in tick order, not arrival order. Late inputs get applied to the correct historical tick via rollback.
- Missing state — If delta references a state key the client doesn't have, the client must request a full sync. Track state versions to detect gaps.
Bandwidth Explosion
- Array diffs are expensive — Prefer
Record<id, entity> over arrays. Array index shifts cause the entire array to diff as changed.
- Unchanged nested objects — Use reference equality checks before deep-diffing. If
prev.players === current.players, skip the diff entirely.
- High-frequency state — Position updates at 60Hz for 100 players = ~600 updates/tick. Batch and compress. Use spatial partitioning to only send nearby entities.
Interpolation Jitter
- Clock drift — Sync client clock to server via periodic time samples. Use a rolling average, not a single RTT measurement.
- Buffer underrun — If server packets arrive late, interpolation has no target state. Buffer at least 2 ticks ahead. Increase buffer on packet loss detection.
- Teleporting entities — If delta between snapshots is too large (entity moved > threshold), snap instead of interpolate to avoid visual artifacts.
Designer Philosophy
"A simulation that maintains consistency enables emergent gameplay — players can reason about the world because the world behaves predictably."
— Will Wright
State sync exists to maintain the illusion that all players share the same consistent simulation. The closer your sync gets to true consistency, the more emergent and surprising gameplay becomes, because players can trust the world's rules.
Sources
- GDC Talks: "Overwatch Gameplay Architecture and Netcode" (Tim Ford, GDC 2017), "Rocket League Physics and Networking" (Jared Cone, GDC 2018)
- Valve Source Engine: Source Multiplayer Networking — authoritative server, client prediction, entity interpolation, lag compensation
- Glenn Fiedler: Networked Physics, State Synchronization, Snapshot Interpolation
- Gabriel Gambetta: Fast-Paced Multiplayer — client prediction, server reconciliation, entity interpolation series