Real-Time State Synchronization: Rollback Netcode and Reconciliation
Imagine shooting an enemy in your game: the bullet fires, the enemy seems to die, then suddenly "reappears" alive a few meters away. This is the classic symptom of poorly designed netcode. In a multiplayer game, every client sees a slightly different version of the world, offset in time due to network latency.
The fundamental problem of multiplayer synchronization is: how to maintain a coherent view of the game world across dozens of clients with different latencies, without introducing noticeable input lag and without allowing cheating? The answer is rollback netcode combined with client-side prediction and server reconciliation.
What You'll Learn
- Netcode architectures: lockstep vs rollback vs hybrid state sync
- Client-side prediction: how the client anticipates the server
- Server reconciliation: correcting client state divergence
- Rollback netcode: retroactive input resimulation
- Entity interpolation: smooth movement despite latency
- Delta compression to reduce state bandwidth
The Three Netcode Architectures
| Architecture | Mechanism | Pros | Cons | Used in |
|---|---|---|---|---|
| Lockstep | All clients wait for everyone's input before advancing | Perfect consistency, deterministic | Input lag = highest latency | Classic RTS (Age of Empires) |
| Rollback | Advance with predicted inputs, rollback if wrong | Zero perceived input lag | Visual flickering, CPU-intensive | Fighting games (Street Fighter, GGST) |
| State Sync + Prediction | Authoritative server, client predicts and reconciles | Scalable, secure, balanced | Complex to implement | FPS/TPS (CS2, Overwatch, Valorant) |
Client-Side Prediction
Client-side prediction immediately executes the local player's input without waiting for server confirmation. This eliminates perceived input lag. The client maintains a history of all inputs sent to the server, each tagged with a sequence number, then reconciles with the server's authoritative state.
// Client-side prediction with input history
interface PlayerInput {
sequence: number;
timestamp: number;
moveX: number; // -1, 0, 1
moveY: number;
actions: string[];
deltaTime: number;
}
interface PlayerState {
x: number;
y: number;
velocityX: number;
velocityY: number;
health: number;
sequence: number;
}
class ClientPrediction {
private pendingInputs: PlayerInput[] = [];
private predictedState: PlayerState;
constructor(initial: PlayerState) {
this.predictedState = { ...initial };
}
processInput(input: PlayerInput): void {
// 1. Apply locally immediately (no lag)
this.predictedState = this.simulate(this.predictedState, input);
// 2. Keep in pending history
this.pendingInputs.push(input);
// 3. Send to server
this.network.sendInput(input);
// 4. Render predicted position
this.renderer.updatePlayer(this.predictedState);
}
onServerState(serverState: PlayerState): void {
// 1. Discard confirmed inputs
this.pendingInputs = this.pendingInputs.filter(
i => i.sequence > serverState.sequence
);
// 2. Check for significant divergence
const drift = Math.hypot(
this.predictedState.x - serverState.x,
this.predictedState.y - serverState.y
);
if (drift > 0.5) {
// 3. Server reconciliation: rebase from server state
this.predictedState = { ...serverState };
// 4. Re-apply all pending inputs
for (const input of this.pendingInputs) {
this.predictedState = this.simulate(this.predictedState, input);
}
this.renderer.updatePlayer(this.predictedState);
}
}
// Deterministic physics simulation (must match server exactly)
private simulate(state: PlayerState, input: PlayerInput): PlayerState {
const SPEED = 200;
const FRICTION = 0.85;
const vx = (state.velocityX + input.moveX * SPEED * input.deltaTime) * FRICTION;
const vy = (state.velocityY + input.moveY * SPEED * input.deltaTime) * FRICTION;
return {
...state,
x: state.x + vx * input.deltaTime,
y: state.y + vy * input.deltaTime,
velocityX: vx,
velocityY: vy,
sequence: input.sequence
};
}
}
Rollback Netcode
Rollback handles remote player inputs. Instead of waiting for their input (adding lag) or using the last known input (causing glitches), rollback predicts the remote input, advances the simulation, then rolls back and resimulates if the prediction was wrong. At 60 FPS with 200ms latency, up to 12 frames may need resimulation within a single 16.6ms render frame.
// Rollback manager for remote inputs
interface GameFrame {
frameNumber: number;
states: Map<string, PlayerState>;
inputs: Map<string, PlayerInput>;
}
class RollbackManager {
private history: GameFrame[] = [];
private readonly MAX_HISTORY = 60; // 1 second at 60fps
private frame = 0;
saveFrame(states: Map<string, PlayerState>, inputs: Map<string, PlayerInput>): void {
this.history.push({ frameNumber: this.frame++, states: new Map(states), inputs: new Map(inputs) });
if (this.history.length > this.MAX_HISTORY) this.history.shift();
}
onRemoteInput(playerId: string, input: PlayerInput): boolean {
const frame = this.history.find(f => f.frameNumber === input.sequence);
if (!frame) return false;
const predicted = frame.inputs.get(playerId);
if (this.inputsMatch(predicted, input)) return false;
frame.inputs.set(playerId, input);
return true; // Rollback needed
}
rollback(
fromFrame: number,
simulate: (states: Map<string, PlayerState>, inputs: Map<string, PlayerInput>) => Map<string, PlayerState>
): Map<string, PlayerState> {
const startIdx = this.history.findIndex(f => f.frameNumber === fromFrame);
if (startIdx === -1) throw new Error('Frame not found in history');
let states = this.history[startIdx].states;
for (let i = startIdx; i < this.history.length; i++) {
states = simulate(states, this.history[i].inputs);
this.history[i].states = new Map(states);
}
return states;
}
predictRemoteInput(playerId: string): PlayerInput {
for (let i = this.history.length - 1; i >= 0; i--) {
const input = this.history[i].inputs.get(playerId);
if (input) return { ...input, sequence: this.frame };
}
return { sequence: this.frame, timestamp: Date.now(), moveX: 0, moveY: 0, actions: [], deltaTime: 1/60 };
}
private inputsMatch(a: PlayerInput | undefined, b: PlayerInput): boolean {
if (!a) return false;
return a.moveX === b.moveX && a.moveY === b.moveY &&
JSON.stringify(a.actions.sort()) === JSON.stringify(b.actions.sort());
}
}
Entity Interpolation
For remote players, instead of rendering them at their "current" position (always stale due to network delay), the client renders them 100ms in the past, smoothly interpolating between the last two received snapshots. This eliminates jitter at the cost of a small visual delay.
// Entity interpolation for remote players
class EntityInterpolation {
private snapshots: Array<{ timestamp: number; positions: Map<string, {x:number;y:number;rotation:number}> }> = [];
private readonly DELAY_MS = 100;
onSnapshot(ts: number, positions: Map<string, {x:number;y:number;rotation:number}>): void {
this.snapshots.push({ timestamp: ts, positions });
this.snapshots.sort((a, b) => a.timestamp - b.timestamp);
if (this.snapshots.length > 20) this.snapshots.shift();
}
getPositions(): Map<string, {x:number;y:number;rotation:number}> {
const renderTime = Date.now() - this.DELAY_MS;
const before = this.snapshots.filter(s => s.timestamp <= renderTime).pop();
const after = this.snapshots.find(s => s.timestamp >= renderTime);
if (!before || !after) return new Map();
const t = (renderTime - before.timestamp) / Math.max(after.timestamp - before.timestamp, 1);
const result = new Map<string, {x:number;y:number;rotation:number}>();
for (const [id, posA] of after.positions) {
const posB = before.positions.get(id);
if (!posB) continue;
result.set(id, {
x: posB.x + (posA.x - posB.x) * t,
y: posB.y + (posA.y - posB.y) * t,
rotation: posB.rotation + (posA.rotation - posB.rotation) * t
});
}
return result;
}
}
Determinism Requirement
Rollback only works if the simulation is deterministic: given the same inputs in the same order,
it produces exactly the same output. This means no unseeded Math.random(), no
non-deterministic floating-point operations across architectures, and no external entropy.
Always use a seeded pseudo-random number generator for game logic.
Conclusions
State synchronization in multiplayer games is one of the most complex network engineering problems. Client-side prediction, server reconciliation, and entity interpolation form the standard solution for modern FPS and TPS games, while rollback netcode remains irreplaceable for fighting games where every frame matters.
Next in the Game Backend Series
- Article 05: Anti-Cheat Architecture and Behavioral Analysis
- Article 06: Open Match and Nakama - Open-Source Game Backend







