From game-dev
Implements client-server game state sync with delta compression, optimistic updates, rollback netcode, interpolation, and reconciliation for multiplayer games.
npx claudepluginhub fcsouza/agent-skills --plugin standalone-skillsThis skill uses the workspace's default tool permissions.
Client-server state reconciliation, delta compression, optimistic updates, and rollback for multiplayer games.
Network synchronization, lag compensation, client prediction, and state consistency for responsive multiplayer games.
Synchronizes multiplayer state in Godot 4.3+ using MultiplayerSynchronizer: replication intervals, delta/full sync, visibility filters, interpolation, prediction, lag compensation.
Provides expertise in real-time multiplayer game networking: lag compensation, state synchronization, authoritative servers, rollback netcode, matchmaking, and anti-cheat.
Share bugs, ideas, or general feedback.
Client-server state reconciliation, delta compression, optimistic updates, and rollback for multiplayer games.
Trigger: state sync, client-server, delta compression, optimistic updates, rollback, netcode, lag compensation, snapshot, interpolation, reconciliation
game-backend-architecture — server loop, tick system, authority modelredis-game-patterns — pub/sub for cross-server state distributionUse a generic type parameter so the sync engine works with any game state. Keep state flat where possible — deep nesting increases delta computation cost.
// Your game defines the shape, the engine stays generic
interface MyGameState {
players: Record<string, PlayerState>;
projectiles: Record<string, ProjectileState>;
world: WorldState;
}
import { StateSyncEngine } from './state-sync';
const engine = new StateSyncEngine<MyGameState>({
tickRate: 20, // 20 ticks/second = 50ms per tick
snapshotInterval: 3, // snapshot every 3 ticks
historyLength: 64, // keep 64 ticks of history (~3.2s at 20Hz)
interpolationDelay: 2, // render 2 ticks behind for smooth interpolation
});
On the client, apply inputs immediately without waiting for server confirmation.
// Client sends input + applies locally
const input: PlayerInput = { tick: currentTick, actions: getLocalInputs() };
engine.applyLocalInput(input);
sendToServer(input);
// When server state arrives, reconcile
onServerState((serverState, serverTick) => {
engine.reconcile(serverState, serverTick);
});
Server processes all inputs, advances the simulation, and broadcasts deltas.
// Server tick
const previousState = engine.snapshot();
processAllInputs(pendingInputs);
advanceSimulation(tickDelta);
const currentState = engine.snapshot();
// Generate and broadcast delta
const delta = engine.generateDelta(previousState, currentState);
if (delta) {
broadcastToClients(delta, currentTick);
}
Use the delta encoder for bandwidth-efficient state transfer.
import { encodeDelta, decodeDelta } from './delta-encoder';
// Server: compute minimal diff
const delta = encodeDelta(previousState, currentState);
// Client: reconstruct from base + delta
const reconstructed = decodeDelta(lastKnownState, delta);
When server state diverges from client prediction, roll back and replay.
import { RollbackBuffer } from './rollback-buffer';
const buffer = new RollbackBuffer<MyGameState>(64);
// Store every tick
buffer.push(currentTick, currentState);
// On server correction at tick N
const correctedState = buffer.rollbackTo(serverTick);
// Replay all inputs from serverTick to currentTick
replayInputs(correctedState, serverTick, currentTick);
Render between two known server states for smooth visuals even at low tick rates.
// Keep two recent server snapshots
const renderTick = currentTick - interpolationDelay;
const prev = buffer.getAt(Math.floor(renderTick));
const next = buffer.getAt(Math.ceil(renderTick));
const alpha = renderTick % 1;
// Interpolate positions, rotations, etc.
const renderState = interpolate(prev, next, alpha);
See boilerplate files:
boilerplate/state-sync.ts — Core StateSyncEngine class with prediction, reconciliation, and snapshot managementboilerplate/delta-encoder.ts — Deep object diff with encodeDelta / decodeDelta and binary encoding optionboilerplate/rollback-buffer.ts — Ring buffer with push, getAt, rollbackTo for memory-efficient historyThe 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.
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.
// Collect at display FPS, sample at tick rate
const rawInputs: RawInput[] = [];
// In your render loop (60+ FPS)
function onFrame() {
rawInputs.push(captureCurrentInput());
}
// In your tick loop (e.g., 20 Hz)
function onTick(tickId: number) {
const sampled = sampleInputs(rawInputs, tickId);
rawInputs.length = 0; // Clear after sampling
inputBuffer.push({ tickId, input: sampled });
sendToServer({ tickId, input: sampled });
}
Key rules:
RTT / tickDuration * 2 entries for replay during rollback.Apply inputs locally before waiting for server confirmation. This gives the player instant feedback while the server validates.
// Apply locally, then send to server
const input = captureTickInput(currentTick);
// Predict: apply to local state immediately
localState = applyInput(localState, input);
inputBuffer.push({ tick: currentTick, input, predictedState: localState });
// Send to server for authoritative processing
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).
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;
}
}
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:
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.
// Fixed timestep game loop with interpolated rendering
let previous = performance.now();
let accumulator = 0;
const tickDuration = 1000 / TICK_RATE; // e.g., 50ms for 20Hz
let prevState = initialState;
let currState = initialState;
function loop(timestamp: number) {
const dt = timestamp - previous;
previous = timestamp;
accumulator += dt;
// Fixed timestep: advance simulation in discrete steps
while (accumulator >= tickDuration) {
prevState = currState;
currState = simulateTick(currState);
accumulator -= tickDuration;
}
// Render: interpolate between prev and current state
const alpha = accumulator / tickDuration;
render(interpolate(prevState, currState, alpha));
requestAnimationFrame(loop);
}
This separation ensures:
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) => {
// Your game simulation step
return applyPhysics(applyInputs(state, getLocalInput()));
};
gameLoop.onRender = (state, alpha) => {
// Your rendering code
renderer.draw(state, alpha);
};
gameLoop.onServerUpdate = (serverState, serverTick) => {
// Automatic reconciliation + input replay
};
gameLoop.start();
Record<id, entity> over arrays. Array index shifts cause the entire array to diff as changed.prev.players === current.players, skip the diff entirely."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.