Networking: Netcode & Rollback
A player in New York presses jump. A server in London can't hear about it for at least 27 ms, because that's how long light takes through fiber, and real networks add delay, lose packets and deliver them out of order on top. No netcode beats those constraints; it hides them. This page builds the standard tools so your own character responds instantly, other players move smoothly, and shots land where the shooter aimed: client-side prediction and reconciliation, entity interpolation, lag compensation and rollback.
- Pick UDP or TCP for each channel of a game, and build acks and partial reliability on top of UDP.
- Choose lockstep, client-server or rollback for a genre, and say what each one costs.
- Implement client-side prediction with replay-based reconciliation so your character never waits for the server.
- Render other players smoothly with an interpolation buffer, and size its delay.
- Explain how lag compensation rewinds the world for a shot, and how many frames a rollback has to re-simulate.
01Why it's hard
Two players' machines need to agree on one game, but every message between them takes time and some never arrive. Netcode works against five constraints: latency (the delay before a packet arrives), jitter (variance in latency), packet loss, bandwidth, and cheating. Latency has a floor you cannot beat: light in fiber travels at roughly 200,000 km/s, so New York to London (~5,500 km great-circle) is about 55 ms round-trip of pure propagation, before cable detours, routing, or queuing add more. The network feeds each peer the inputs or state for the fixed tick it's about to run.
Worked example
New York to London is about 5,500 km. At 200,000 km/s in fiber, one way takes 5,500 / 200,000 = 0.0275 s, so 27.5 ms, and the round trip 55 ms. At 60 frames per second a frame lasts 16.7 ms, so even a perfect network puts the reply 3.3 frames after the press. Every technique on this page is a way of not making the player feel those frames.
02UDP vs TCP
A shooter sends the world's state 20 or more times a second, and a state that arrives late is already useless. Fast-paced games run that realtime stream over UDP, fire-and-forget packets with no delivery or ordering guarantee, not TCP, a reliable, in-order byte stream. TCP guarantees reliable, in-order delivery, and that guarantee is the problem: a single lost packet causes head-of-line blocking. TCP won't hand over any later data that already arrived until the retransmit lands (≥1 RTT later), freezing the stream in whichever direction lost the packet[2].
TCP is fine for non-realtime channels: login, matchmaking, chat, patch downloads, REST calls. Slower genres (MMOs, turn-based games) ship whole gameplay streams on it. The precise rule (Fiedler's) is never use TCP for time-critical data. For the realtime stream, fast-paced games build a thin partial-reliability layer on UDP: sequence numbers plus an ack bitfield (each packet carries the latest received sequence and a 32-bit field of the prior 32, so every ack is effectively sent 32 times for redundancy), with unacked reliable messages re-included in outgoing packets until they are acked, so a lost packet never blocks the realtime stream[3].
Worked example
A client receives 60 state packets a second over a 100 ms round trip. Over TCP, one lost packet holds back everything behind it until the resend lands, at least 100 ms later: 6 packets' worth of newer state sits in the receive buffer, and the game freezes, then jumps. Over UDP the game uses packet 1,001 as soon as it arrives and never misses 1,000. For the acks, a packet carrying "latest received: 1,000" plus a 32-bit field for 999 down to 968 acknowledges up to 33 packets at once, so the sender learns about packet 1,000 from any of the next 32 packets that get through.
03The three models
An RTS with 1,500 units, a 64-player shooter and a two-player fighting game have very different problems: too much state to send, players who might cheat, and timing tight enough that a few frames decide a match. Three archetypes, each suited to a different genre. They differ in what crosses the wire (inputs vs state), who is authoritative (peers vs a server), and whether a peer waits for remote inputs or predicts them.
A fourth model sits between the first two: state synchronization runs the sim on both sides and sends inputs plus state, so the state corrects drift and perfect determinism isn't required[14].
04Lockstep & determinism
In , every peer runs the identical simulation and only inputs (commands) cross the wire, so bandwidth is proportional to input size, not world-object count[4]. Age of Empires passed commands rather than per-unit state to hit its 1,500-unit target on a 28.8k modem; passing per-unit position and status would have capped it at about 250 moving units[5]. Commands are scheduled a couple of turns ahead so transmission overlaps simulation.
Worked example
A 28.8 kbit/s modem moves 28,800 / 8 = 3,600 bytes a second. Say each unit's position and status take 8 bytes: 1,500 units are 12,000 bytes, more than 3 seconds of modem time for a single update. A command such as "these 40 units attack that building" is a few dozen bytes, whatever the units then do for the next minute.
Lockstep runs at the speed of the slowest, laggiest peer (everyone needs turn N's inputs before simulating turn N), and a single non-deterministic divergence desyncs everyone, permanently, and compounds over time[1]. Across machines, the classic culprit is floating point: transcendentals not correctly rounded, non-associativity under compiler/SIMD reordering, -ffast-math, x87 vs SSE in legacy 32-bit builds, and FMA contraction and libm differences across x86/ARM (the Floating Point tutorial covers the mechanism)[6]. The fixes are fixed-point math or tightly controlled float. Floats aren't the only source: Age of Empires' programmers had to keep the number of calls to the (seeded, synchronized) random generator identical on every machine[5], and uninitialized memory or iteration over pointer-keyed containers diverge the same way. A fixed timestep is necessary but not sufficient, and using doubles doesn't fix it. Those two costs keep many engines off lockstep.
05Prediction & reconciliation
You press forward in a shooter with a 120 ms round trip to the server. If your character waits for the server's answer, it starts moving 120 ms after your finger did, which feels like steering through mud. Under an authoritative server, waiting for the round-trip means input lag of at least the full RTT (server processing and tick alignment add more). applies your own input immediately to your local copy, tagging each input with a sequence number and storing it in a pending buffer[7]. Each server update then describes a moment the client has already predicted past, so the two disagree until the client reconciles.
The server's state update carries the sequence number of the last input it processed. On receipt the client: (1) snaps to the authoritative state, (2) discards pending inputs up to that ack, (3) replays the still-unacknowledged inputs on top. Replaying is what keeps your predicted position correct when the server agrees. A correction/rubber-band appears only when it genuinely disagreed (a misprediction, e.g. you got shoved); some engines blend that correction over a short window instead of popping, as Source does with cl_smoothtime[9]. Prediction makes your character responsive; it does nothing for other players (that's interpolation, §6).
Worked example
At a 120 ms round trip and one input per 60 Hz frame, about 0.12 × 60 ≈ 7 inputs are in flight at any moment. A server update arrives acking input 500, and the client has inputs 501 to 507 pending. It snaps to the server's position for input 500, drops 500 and everything older from its buffer, and re-applies 501 to 507. If the server agreed, that lands exactly where the client already was and nothing moves. If another player shoved you at input 498, the replay starts from the shoved position and you see the correction.
The character follows a moving input; raise the latency. With no prediction it lags behind your input by the full round trip. Prediction with no reconciliation feels instant but never corrects its mispredictions, so it drifts off the authoritative position and never recovers. Reconciliation is the half that makes prediction usable: it snaps the character back onto authority and replays your unacked inputs:
#include <cstdint>
#include <deque>
std::deque<Input> pendingInputs; // unacknowledged inputs, in order
uint32_t inputSequence = 0;
State predictedState; // what we render locally
void onLocalInput(float dx, float dt) {
Input input{ ++inputSequence, dx, dt };
applyInput(predictedState, input); // PREDICT: apply now, don't wait for the server
sendToServer(input); // the server will ack it by sequence number
pendingInputs.push_back(input); // keep it until acked
}
void onServerState(const State& serverState, uint32_t lastProcessedInput) {
predictedState = serverState; // RECONCILE 1: snap to authority
while (!pendingInputs.empty() && pendingInputs.front().sequence <= lastProcessedInput)
pendingInputs.pop_front(); // drop what the server already ran
for (const Input& input : pendingInputs)
applyInput(predictedState, input); // RECONCILE 2: REPLAY unacked inputs
}
fn on_local_input(&mut self, dx: f32, dt: f32) {
self.input_sequence += 1;
let input = Input { sequence: self.input_sequence, dx, dt };
apply_input(&mut self.predicted_state, &input); // PREDICT immediately
send_to_server(&input); // send before the buffer takes ownership
self.pending_inputs.push_back(input); // keep it until acked
}
fn on_server_state(&mut self, server_state: State, last_processed: u32) {
self.predicted_state = server_state; // RECONCILE: snap to authority
while self.pending_inputs.front().is_some_and(|i| i.sequence <= last_processed) {
self.pending_inputs.pop_front(); // drop acked
}
for input in &self.pending_inputs {
apply_input(&mut self.predicted_state, input); // REPLAY unacked
}
}
06Interpolation
Other players' positions arrive 20 times a second, but your screen draws 60 or more frames a second. Draw each update as it arrives and they hop. The snapshot model doesn't predict other players: they stop, turn, and accelerate unpredictably, so every misprediction would pop (rollback in §9 and dead reckoning accept that cost). Instead, buffer their timestamped snapshots and render them at now − interpolationDelay, between the two snapshots that bracket that render time[8]. Source defaults to cl_interp 0.1 = 100 ms of view delay, derived from its 20 snapshot/s default and sized so a single lost snapshot still leaves two to interpolate between. The effective period is max(cl_interp, cl_interp_ratio / cl_updaterate), so with cl_interp set to 0, raising the update rate shrinks the delay well below 100 ms[9].
Interpolation delay is latency added on purpose, bought in exchange for smooth motion. It's interpolation between two real snapshots, not extrapolation, so it only coasts/extrapolates (and can overshoot) when snapshots are lost or arrive later than the buffer allows; Source caps that extrapolation at 0.25 s[9]. And it's coupled to lag compensation: the server subtracts this same delay when it rewinds time to validate your shots (§8).
Worked example
At Source's default 20 snapshots a second, snapshots are 50 ms apart, say stamped 0, 50, 100 and 150 ms (ignore travel time for the moment). With 100 ms of delay, at 175 ms the client draws the world as it was at 75 ms, halfway between the 50 and 100 snapshots. If the 100 ms snapshot is lost, the client blends between 50 and 150 instead, a 100 ms gap but still two real positions. Lose two in a row and it has nothing newer to blend toward, so it coasts.
A remote entity moving: compare raw snapshots (teleporting) to interpolation (smooth, but lagging), and drop packets to see it coast:
07Snapshots & delta
A 64-player match has hundreds of moving objects, and most of them didn't change since the last packet. Sending the whole world 20 times a second to every client wastes most of the bandwidth. A client-server engine takes a snapshot of world state and sends each client the delta against the last snapshot that client acknowledged[10]. The snapshot rate can sit below the tick rate: Counter-Strike: Source and TF2 simulate at 66 ticks/s, but Source clients request 20 snapshots/s by default (cl_updaterate 20)[9]. Quake 3 keeps the last 32 snapshots per client and deltas against the client's last acked one; if none is acked (heavy loss), it deltas against a zeroed baseline, which is just a full update. A lost ack self-heals: the next delta is computed from an older baseline (bigger, but correct) rather than a forced full resend.
Then quantize each field to the bits it needs (cross-ref Bit Shifting and Compression): map a position float over a known range to an integer (Fiedler's example packs x,y,z into 18/18/14 bits, ~2 mm precision, instead of 96) and orientation into a smallest-three quaternion (2 bits for the largest-component index + 3×9 bits = 29 bits vs 128)[11]. Quantization is lossy but bounded: picking the range and precision is the design choice, and out-of-range values clamp.
Worked example
At 1/512 m steps (about 2 mm), 18 bits give 218 = 262,144 steps, which covers 262,144 / 512 = 512 m; 14 bits cover 16,384 / 512 = 32 m, enough for height. That's 18 + 18 + 14 = 50 bits for a position instead of 96 for three floats, and 29 bits for an orientation instead of 128, so 79 bits per object instead of 224, about 35% of the size, before delta encoding drops the objects that didn't move at all.
08Lag compensation
You line up a headshot on a player running past a doorway and click. Your screen showed them in the doorway, but by the time your shot reaches the server they're behind the wall. Should it hit? When validating a hit, the authoritative server rewinds the other players to where the shooter saw them. To do that it needs the moment the shooter was looking at, and Valve estimates it as:
Command Execution Time = Current Server Time − Packet Latency − Client View Interpolation
In words: start from the server's clock now, go back by the time the shot's packet took to arrive, then go back again by the interpolation delay the shooter was seeing other players with.
The formula subtracts the interpolation delay from §6: the two systems are coupled[9]. The server keeps about 1 second of position history, moves the candidates back, tests the hit, and restores them.
Worked example
The shot arrives at server time 10.000 s. It took 40 ms to get there, and the shooter renders other players 100 ms in the past, so the server rewinds everyone to 10.000 − 0.040 − 0.100 = 9.860 s. The target stepped behind the wall at 9.900 s, but at 9.860 s they were still in the doorway, so the hit counts, even though on the server the target had been behind the wall for 100 ms.
Lag compensation makes you hit what your screen showed, at the cost of the target: you can be killed after you have already ducked behind cover, because on the shooter's machine you were still exposed. Valve is explicit that this "can't be solved in general because of the relatively slow packet speeds." The trade is shooter feel against target fairness. Source rewinds only players; every other entity stays at its current position[9].
09Rollback
In a fighting game a punch can be active for only three frames, so a few frames of delay change which hits land. Waiting for the opponent's input before simulating each frame adds that delay to every frame. (GGPO, the fighting-game standard) runs a deterministic sim and predicts the remote player's input (assume they keep doing what you last heard, "carry-forward"), simulating forward immediately so the game feels offline-responsive. When the real input arrives and differs, it rolls back to the saved state at that frame and re-simulates forward to the present with the corrected input[12].
Rollback hides remote latency but doesn't erase it: a misprediction produces a visible correction/teleport. It requires both a fully deterministic sim and the ability to save/restore the entire game state cheaply every frame (ideally one contiguous block you can memcpy). The cost scales with the misprediction window (latency in frames, minus any input delay): at 60 fps with three frames of input delay, supporting 300 ms of one-way latency means re-simulating up to 15 frames inside one 16.6 ms display frame; overrun that budget and the resim becomes its own spiral of death[13]. It predicts the remote input; your own is applied directly. Full-state rollback best fits 2-player P2P with small state. In a 64-player shooter the state to save is large, each resim steps every player, and with 63 remote inputs to guess, some guess is wrong on most frames, so a near-full-window resim runs almost every frame. The idea survives there in narrower form: §5's snap-and-replay reconciliation is a rollback of your own predicted state.
Worked example
At 60 fps, 300 ms of one-way latency is 0.3 × 60 = 18 frames. Three frames of input delay cover 3 of them, leaving a 15-frame window: when a late input turns out to differ from the guess, the game restores the state from 15 frames ago and steps the simulation 15 times, plus the current frame, inside one 16.7 ms display frame. That's about 1 ms per simulation step, before rendering gets any time at all.
The widget predicts the remote input; when a real input arrives that differs, the sim rolls back N frames and re-simulates. Raise latency to grow the rollback window:
constexpr int MAX_ROLLBACK = 8; // prediction window; a real implementation stalls the sim past this
GameState savedStates[MAX_ROLLBACK]; // ring of per-frame saves (must be cheap to copy)
Input remoteInputs[MAX_ROLLBACK]; // remote input each frame was SIMULATED with (predicted or real)
GameState currentState; // the live simulation state
int confirmedFrame = -1; // last frame with a REAL remote input
int presentFrame = 0; // newest frame advance() has simulated
Input predictRemote() { // carry-forward: keep doing what we last heard
return remoteInputs[(confirmedFrame >= 0 ? confirmedFrame : 0) % MAX_ROLLBACK];
}
void advance(int frame, Input local) {
presentFrame = frame;
savedStates[frame % MAX_ROLLBACK] = currentState; // SAVE before stepping
Input predicted = predictRemote();
remoteInputs[frame % MAX_ROLLBACK] = predicted; // RECORD the guess so onRemoteInput can compare
simulate(currentState, local, predicted); // step with the PREDICTED remote input
}
void onRemoteInput(int frame, Input real) {
if (real == remoteInputs[frame % MAX_ROLLBACK]) { confirmedFrame = frame; return; } // guessed right
remoteInputs[frame % MAX_ROLLBACK] = real;
confirmedFrame = frame; // move BEFORE resim: carry-forward uses the new input
currentState = savedStates[frame % MAX_ROLLBACK]; // ROLL BACK to that saved frame
for (int f = frame; f <= presentFrame; ++f) { // RE-SIMULATE forward to now
savedStates[f % MAX_ROLLBACK] = currentState; // RE-SAVE: a later rollback needs corrected states
Input remote = (f == frame) ? real : predictRemote();
remoteInputs[f % MAX_ROLLBACK] = remote; // re-record what this frame actually ran with
simulate(currentState, localInputAt(f), remote);
} // the re-simulated present renders next: the visible pop
}
const MAX_ROLLBACK: usize = 8; // prediction window; a real implementation stalls the sim past this
struct Rollback {
saved_states: [GameState; MAX_ROLLBACK], // ring of per-frame saves (must be cheap to copy)
remote_inputs: [Input; MAX_ROLLBACK], // remote input each frame was SIMULATED with (predicted or real)
current_state: GameState, // the live simulation state
confirmed_frame: i32, // last frame with a REAL remote input (-1: none yet)
present_frame: usize, // newest frame advance() has simulated
}
impl Rollback {
fn predict_remote(&self) -> Input { // carry-forward: keep doing what we last heard
self.remote_inputs[self.confirmed_frame.max(0) as usize % MAX_ROLLBACK]
}
fn advance(&mut self, frame: usize, local: Input) {
self.present_frame = frame;
self.saved_states[frame % MAX_ROLLBACK] = self.current_state; // SAVE before stepping
let predicted = self.predict_remote();
self.remote_inputs[frame % MAX_ROLLBACK] = predicted; // RECORD the guess so on_remote_input can compare
simulate(&mut self.current_state, local, predicted); // step with the PREDICTED remote input
}
fn on_remote_input(&mut self, frame: usize, real: Input) {
if real == self.remote_inputs[frame % MAX_ROLLBACK] { self.confirmed_frame = frame as i32; return; } // guessed right
self.remote_inputs[frame % MAX_ROLLBACK] = real;
self.confirmed_frame = frame as i32; // move BEFORE resim: carry-forward uses the new input
self.current_state = self.saved_states[frame % MAX_ROLLBACK]; // ROLL BACK to that saved frame
for f in frame..=self.present_frame { // RE-SIMULATE forward to now
self.saved_states[f % MAX_ROLLBACK] = self.current_state; // RE-SAVE: a later rollback needs corrected states
let remote = if f == frame { real } else { self.predict_remote() };
self.remote_inputs[f % MAX_ROLLBACK] = remote; // re-record what this frame actually ran with
simulate(&mut self.current_state, local_input_at(f), remote);
} // the re-simulated present renders next: the visible pop
}
}
10Choosing a model
You're starting a multiplayer game and have to commit to a model before writing the simulation, because lockstep and rollback both demand determinism from day one. Each model fits a genre's constraints:
- Lockstep → RTS: huge unit counts, P2P, bandwidth-bound, can pay the determinism price.
- Client-server + prediction/interpolation/lag-comp → FPS: needs an authoritative, cheat-resistant server.
- Rollback → fighting games: 2-player P2P, small deterministic state, latency-hiding above all.
One common arrangement: a network thread receives and parses packets and hands inputs/snapshots to the simulation thread over a lock-free SPSC queue, so wire I/O never stalls the fixed-timestep loop; plenty of shipped games instead poll non-blocking sockets at the top of each frame. Either way, every model here sits on top of that fixed tick and the determinism discipline from the Floating Point tutorial.
11Pitfalls
12What's next
Two tooling modules remain before the finale. Debugging in Release covers reading the optimized binary, sanitizers and crash dumps; Tooling & Profiling adds the in-engine dev UI and profilers to see and tune everything you've built. Then the 3D-game capstone assembles the whole engine into a game. The full path is on the series hub.
- Glenn Fiedler. "What Every Programmer Needs To Know About Game Networking." gafferongames.com. The P2P→client-server history, the lockstep-waits-for-the-slowest-peer point, and that one tiny difference desyncs everyone.
- Glenn Fiedler. "UDP vs. TCP." gafferongames.com. Head-of-line blocking; "never use TCP for time-critical data"; TCP for non-critical services.
- Glenn Fiedler. "Reliable Ordered Messages." gafferongames.com. Sequence numbers + the 32-bit ack bitfield, and priority-based partial reliability over UDP.
- Glenn Fiedler. "Deterministic Lockstep." gafferongames.com. Inputs-only, bandwidth proportional to input not object count, and the cross-platform determinism warning.
- Paul Bettner and Mark Terrano. "1500 Archers on a 28.8: Network Programming in Age of Empires and Beyond." GDC 2001. gamedeveloper.com. The lockstep RTS case: pass commands, 1,500 units vs a 250-unit cap for passing state, commands scheduled two turns ahead, identical random-call counts on every machine, and running as fast as the slowest machine.
- Glenn Fiedler. "Floating Point Determinism." gafferongames.com. Why cross-machine float results diverge and the forced-precision / wrapped-transcendental fixes (also cited in the Floating Point tutorial).
- Gabriel Gambetta. "Client-Side Prediction and Server Reconciliation." gabrielgambetta.com. Per-input sequence numbers, the server echoing the last-processed input, and replaying unacked inputs.
- Gabriel Gambetta. "Entity Interpolation." gabrielgambetta.com. Rendering other entities in the past by interpolating between two bracketing snapshots.
- Valve. "Source Multiplayer Networking" / "Lag Compensation." developer.valvesoftware.com. The 100 ms
cl_interpdefault and the max(cl_interp, cl_interp_ratio/cl_updaterate) period, the 20 snapshot/s default vs tick 66, the Command-Execution-Time formula, the 1 s history, the favor-the-shooter "behind cover" tradeoff, prediction-error smoothing (cl_smoothtime), and the 0.25 s extrapolation cap. - Fabien Sanglard. "Quake 3 Source Code Review: Network." fabiensanglard.net. The 32-snapshot ring, delta against the last acked snapshot, and the zeroed baseline as a full update.
- Glenn Fiedler. "Snapshot Compression." gafferongames.com. Position quantization over a bounded range and the smallest-three quaternion.
- GGPO. ggpo.net (open source: github.com/pond3r/ggpo). Input prediction + speculative execution and re-simulation from the point of divergence; the deterministic-sim + save/load requirement.
- SnapNet. "Netcode Architectures Part 2: Rollback." snapnet.dev. State ideally one contiguous block you can memcpy, carry-forward prediction, input decay, and the 15-frames-in-16.6 ms resim budget.
- Glenn Fiedler. "State Synchronization." gafferongames.com. Sending input and state together, so the simulation needn't be perfectly deterministic to stay in sync.