An opponent who teleports, a shot you saw land that doesn’t count, a player your screen shows behind you when they’re already ahead: anyone who has played online knows these desyncs. They all have one cause, the time it takes for information to cross the network, and every netcode technique is a way of hiding it. Some hide it by adding delay, and your character jumps a little after you press the button. Others guess what the other players are doing, then rewind when they guessed wrong: that’s rollback. This article takes them one by one, in the order they stack up, all the way to the rollback used by fighting games, the one that runs Runner Party, a racing platformer for four players.
Every step has its demo. They simulate two players and a server right in the page, with a latency you control. The demo code is deliberately simple JavaScript; when there’s C#, it’s the actual game code.
Time in a networked game
A game moves forward in ticks. On every tick, it reads the players’ inputs and advances the simulation by a fixed step. At 60 ticks per second, a tick lasts 16.7 ms. As long as everything happens on a single machine, the input read on tick 100 is played on tick 100.
Here is the game used by the demos, stripped to the bone: a track, and players who run and jump.
const DT = 1 / 60;
// One game tick: the same code everywhere, so the same result everywhere for the same inputs.
function step(s, input) {
let x = s.x + input.dir * RUN_SPEED * DT;
x = Math.min(WORLD_WIDTH - 0.5, Math.max(0.5, x));
let vy = s.vy;
if (input.jump && s.grounded) vy = JUMP_SPEED;
vy -= GRAVITY * DT;
let y = s.y + vy * DT;
let grounded = false;
if (y <= 0) { y = 0; vy = 0; grounded = true; }
return { x, y, vy, grounded };
}
Over a network, two durations matter:
- the one-way trip, the time a message takes to get from one machine to another;
- the round trip, or ping, the time for a question and its answer. Roughly two one-way trips.
Take a 100 ms ping, so 50 ms one way. At 60 ticks per second, an input takes 3 ticks to reach the other player, and the answer 3 more. Three ticks isn’t much. It’s also how long it takes a character running at 8 units per second to cover 0.4 units, or a jumping character to already be half a body off the ground.
Everything that follows answers one question: while the inputs are on their way, what does each screen show?
Two players’ PCs can’t always talk to each other directly: most of them sit behind a home router that refuses connections it didn’t ask for. So the traffic goes through a relay, a server on the internet that everyone can reach. Each machine sends it its packets, and the relay forwards them to the others. It works everywhere, but it’s a detour: the ping depends on the route to the relay, and two relays can give very different pings for the same players.
The authoritative server
When four machines simulate the same match, they end up disagreeing: about who hit whom, about who picked up the box. You need a referee. The classic answer is to pick one machine whose version is authoritative: the server. It receives everyone’s inputs, simulates, and sends back the result. In a game between friends, it’s often one of the players, the host.
This model also protects against some cheating. A player only sends their inputs, “I’m going right”, “I’m jumping”, never the outcome. They can’t claim they hit someone, or move faster than the game allows: the server plays their inputs with the real rules, and decides what happened.
The simplest client simulates nothing. It sends its inputs, and shows whatever the server sends back.
With a 100 ms ping:
- at t = 0, the player presses right. The client sends the input;
- at t = 50 ms, the server receives it and plays it: the character moves;
- at t = 100 ms, the client receives the new state and shows it.
The player pressed at 0 and sees their character move at 100 ms. The delay they feel is a full round trip.
- A
- B
- real position
- latency: one way
Two flaws show up right away. The delay first: in a platformer, 160 ms between the button press and the jump is enough to miss every gap. Then the picture stutters, because the server doesn’t send its state every tick: the demo sends it 20 times a second, like many games, to save bandwidth.
Yet this client has one quality that none of the next techniques will have until the last one: every screen shows the same match. Late, but the same.
Predicting your own player
The client knows its own player’s inputs before anyone else, and it has the same simulation code as the server. Nothing stops it from playing them right away, without waiting for the answer. That’s client-side prediction.
- at t = 0, the player presses right. The client sends the input and moves its character immediately;
- at t = 50 ms, the server receives the input and plays it, as before;
- at t = 100 ms, the server’s state arrives, and confirms what the client is already showing.
The felt delay drops to zero. But step three hides a problem.
The trouble with late states
The state arriving at t = 100 ms describes the world at t = 50 ms, right after the first input. Meanwhile the client kept predicting: it played 6 more ticks. With a character running at 8 units per second:
- tick 0: the client plays input #1, the character goes from x = 0 to x = 0.13;
- ticks 1 to 5: inputs #2 to #6, the character reaches x = 0.80;
- tick 6: the server’s state arrives. It says “x = 0.13”, because it has only played input #1 so far.
If the client applies that state as is, its character jumps back 0.67 units, then forward again on the next state. It jitters all the time.
Reconciliation
The fix is called reconciliation. The client numbers its inputs and keeps them until the server has played them. Along with its state, the server sends back the number of the last input it played. The client starts again from the server’s state, and replays on top of it every input the server hasn’t seen yet:
- the server’s state says “x = 0.13, last input played: #1”;
- the client drops input #1 from its list. Inputs #2 to #6 are left;
- it starts again from x = 0.13 and replays those five inputs: x = 0.80.
The result lands exactly on what the client was showing. Nothing moves on screen.
// Every tick: my input goes to the server, numbered, and I keep it until the server has played it.
pending.push({ seq: seq++, input });
// On every state received: start from what the server confirmed, replay what it hasn't seen yet.
pending = pending.filter((m) => m.seq > snapshot.acked[me]);
let s = snapshot.states[me];
for (const m of pending) s = step(s, m.input);
own = s;
If the server played the inputs differently, the client gets corrected, and that’s on purpose: the server is authoritative. It happens when an input arrives too late for its tick, because of jitter, the variation in travel time from one packet to the next. The server then plays that tick without it.
- A
- B
- real position
- latency: one way
Prediction solves the problem for your own player. Now look at B on A’s screen in the demo above. A’s client doesn’t have B’s inputs, so it can’t predict B. It shows B where the server saw them, and B hops from one received state to the next, 20 times a second.
Interpolating the other players
For the other players, the standard technique is interpolation. The client doesn’t show the latest state it received. It deliberately places itself a little in the past, and draws the other player between the two received states around that moment.
The server sends a state every 50 ms, and the client draws 100 ms in the past:
- the client has received the states for t = 1,000 ms and t = 1,050 ms;
- at 1,125 ms, it draws the world as of t = 1,025 ms;
- 1,025 sits halfway between 1,000 and 1,050: B is drawn halfway between their two known positions.
As long as the chosen delay is longer than the gap between two states, there are always two states around the moment being drawn, and the movement is smooth.
// The other player, drawn `interpolationMs` in the past, between the two received states around it.
const renderTick = serverTickEstimate() - interpolationMs / TICK_MS;
for (let i = snaps.length - 1; i > 0; i--) {
const a = snaps[i - 1], b = snaps[i];
if (a.tick <= renderTick && renderTick <= b.tick) {
const t = (renderTick - a.tick) / (b.tick - a.tick);
return lerp(a.states[player], b.states[player], t);
}
}
- A
- B
- real position
- latency: one way
Three moments on one screen
Interpolation has a price, and it’s hidden. On A’s screen, three different moments live side by side:
- player A, in the present, thanks to prediction;
- the other players, in the past: one trip for their state to arrive, plus the interpolation delay;
- contacts, decided by the server on its timeline.
With a 100 ms ping and 100 ms of interpolation, B is shown about 150 ms in the past. If B runs at 8 units per second, they’re really 1.2 units further than where A sees them. When A shoots at B right next to them, A aims at a B who isn’t there anymore. The server sees the shot arrive at the real B, somewhere else. The shot A saw land misses. Worse: a player A sees behind them may in fact already be ahead.
In games where each player mostly minds their own business, that’s acceptable. In a game where people shoot each other at point-blank range, the core of the gameplay breaks.
Lag compensation
Shooters have a fix for this: lag compensation. The server keeps a history of every player’s positions. When a shot comes in, it rewinds to the moment the shooter saw it, and checks the shot in that world. The shot A saw land does land.
The other player pays the price: B, who had already reached cover on their screen, gets hit behind a wall. The technique always favours the shooter. It also only handles shots, not everything else: two items meeting, a player picking up a box the other still sees full. Runner Party doesn’t use it.
Everyone in the present
The other option is to stop showing anyone in the past. The client shows the other players in the present, by extending their last known state: it repeats their last input up to the present moment. That’s extrapolation, also called dead reckoning, after the way sailors used to estimate their position.
With a 100 ms ping, the last state of B that A received is 6 ticks old, and it says “B is running right”:
- on tick 100, B is at x = 10 and running right. That’s the last state received;
- A’s client is on tick 106. It extends: six ticks to the right, B is shown at x = 10.8;
- in reality, B turned around on tick 102. On tick 106, they’re at x = 9.73.
When the state for tick 102 finally arrives, the extrapolation was off by 1.07 units. Teleporting B in one go is visible. So the gap is kept as an error, and faded out on screen: the simulation is corrected immediately, but the drawing catches up with the real position over a few tenths of a second.
// The other player, extended from their last known state to my present by repeating their last input.
let s = snapshot.states[other];
for (let i = 0; i < ticksAhead; i++) s = step(s, { dir: lastInput.dir, jump: false });
// New information contradicts the old extrapolation: the gap is kept, then faded out.
error.x += before.x - s.x;
error.y += before.y - s.y;
error.x *= Math.pow(0.5, DT / 0.1); // half-life of 0.1 s
shown = { x: s.x + error.x, y: s.y + error.y };
- A
- B
- real position
- latency: one way
Extrapolation works well when objects have inertia. A car doing 100 km/h will be, 100 ms from now, roughly where you expect it. That’s the Rocket League model: an authoritative server, and prediction of everything that moves.
A platformer character has almost no inertia. It jumps, turns around and throws an item in the same second, and each of those decisions makes the extrapolation wrong. And there’s worse than movement: a shot can’t be extrapolated. You can’t make up that a player fired. The shot arrives late, and leaves from a spot its shooter has already left.
Both approaches have the same flaw, from opposite sides. In the past, everyone sees the others late. In the present, everyone guesses the others, and gets it wrong at every decision. Either way, two screens show two different matches.
A shared clock and input delay
Fighting games have this problem even worse: two players right next to each other, deciding everything frame by frame. Their answer is known as GGPO, after the library that made it popular. It rests on two ideas the previous techniques didn’t have.
The first: a shared world clock. Every machine plays tick T at the same time. No more client running ahead of the server, no more other players in the past: everyone is on the same tick.
The second: an input delay, the same for everyone. An input pressed on tick T isn’t played on tick T, but on tick T + D, on every machine, the player’s own included. The delay gives the input time to travel.
With a 3-tick delay (50 ms) and 40 ms one way (2.4 ticks):
- on tick 100, A presses jump. The input is stamped “tick 103” and sent;
- around tick 102.4, it reaches B;
- on tick 103, A and B play A’s jump, on the same tick.
Nobody had to guess anything, and both screens show exactly the same match. The price: A sees their character jump 50 ms after pressing the button, like every other player for their own inputs. The delay is the same for everyone, and that’s what makes it acceptable: it favours no one.
It’s the idea behind Steam Remote Play, taken all the way. With Remote Play, a single PC runs the game and everyone watches the same picture, with some delay. Nobody sees a different match from the others. François, who I work with at Creartstudio, was the first to bring it up: despite the delay, it would be great to have that kind of result.
Rollback
The delay doesn’t always cover the trip. A slower connection, a packet that lags behind, and the other player’s input isn’t there when its tick has to be played. The machine can’t wait: the whole game would freeze. It guesses the missing input, plays the tick, and fixes things later. That’s rollback:
- when a player’s input for tick T hasn’t arrived, guess it: repeat their last known input;
- keep a save of the world’s state on every tick;
- when the real input arrives, compare it with the guess. If they match, there’s nothing to do. Otherwise, restore the state from before tick T, and replay every tick from T up to the present, with what you now know.
Back to the example, with 80 ms one way (4.8 ticks) and a 3-tick delay:
- on tick 100, B presses jump, for tick 103;
- on ticks 103 and 104, the input hasn’t reached A yet. A guesses: B keeps doing what they were doing, on the ground;
- around tick 104.8, the input arrives. On tick 105, A sees that it differs from the guess;
- A restores the world as of the end of tick 102, replays ticks 103 and 104 with the jump, then plays tick 105.
On screen, B suddenly appears 0.37 units off the ground: two ticks of jump, played at once. That’s the only trace of the rollback. The match itself is the same as if there had been no lag.
Tick timeline seen from your machine, now at tick 18. Your inputs are known; the button you press now is played at tick 21, 3 ticks later. The opponent’s inputs are known up to tick 15, the one for tick 16 has just arrived, and ticks 17 and 18 were guessed: the game rolls back to tick 16 and replays up to 18.
- known
- guessed
- just arrived
- pressed now
The guess is right far more often than you’d think. Most of the time, a player does on tick T what they did on tick T − 1. You only need to replay when they change their mind.
// An input arrives for a tick already played with a guess.
function receiveInput(player, tick, input) {
inputs[player].set(tick, input);
const used = usedInputs[player].get(tick);
if (tick <= currentTick && used && !sameInput(used, input)) {
// Back to the state before that tick, and replay up to the present with what we now know.
for (let t = tick; t <= currentTick; t++) simulate(t);
}
}
// Play a tick: the real input if it's there, otherwise the last known one, repeated.
function simulate(tick) {
const before = history.get(tick - 1);
const next = {};
for (const p of players) {
const input = inputs[p].get(tick) ?? guess(p, tick);
usedInputs[p].set(tick, input);
next[p] = step(before[p], input);
}
history.set(tick, next);
}
- A
- B
- real position
- input guessed at this tick
- latency: one way
Delay and latency share the work:
- delay longer than the trip: nothing is ever guessed, nothing is replayed, and every screen is identical. The player pays for it all in felt delay;
- delay shorter than the trip: the latest ticks are guessed, and every time a player changes their mind it costs a rollback and a small correction on screen;
- no delay: you get pure rollback, with no felt delay at all, but a correction at every decision of the other players.
The whole art of tuning is placing that slider, and we’ll come back to it below.
In Runner Party, the same idea lives in WorldLoop.Rollback. Here’s a simplified version: the real one also restores the items, and forgets the contacts and hits decided since that tick, so they can be decided again during the replay.
// Simplified: without items, boxes and hits already decided.
void Rollback(uint from)
{
if (from > _tick + 1 || _tick + 1 - from >= History - 1)
{
// Too old to go back to: this machine keeps what it played, the server's word will put it right.
return;
}
foreach (Lane lane in _lanes)
{
Record before = lane.Records[(from - 1) % History];
if (before.Tick == from - 1) lane.Simulation.Restore(in before.State);
}
IsReplayingWorld = true;
for (uint tick = from; tick <= _tick; tick++)
{
PlayTick(tick, replay: true);
StepItems(tick);
}
IsReplayingWorld = false;
}
History is 64: each player keeps a little over a second of states, enough to go back over any late input in a normal match.
What to guess?
“Repeat the last input” holds for the stick direction. For buttons, it’s wrong: repeating a jump press would make a player who jumped once jump twice. So the guess keeps the direction and drops the presses.
You can try to do better. On Runner Party, wrong guesses were measured one by one. The most frequent one is about how far the stick is pushed without a change of direction, which doesn’t show on screen. Then come presses, turnarounds, held buttons. What shows is precisely what can’t be guessed, and the simplest guess stayed.
What the screen draws
The game runs at 60 ticks per second, but many screens show 144 or 165 frames. So the character is drawn between two tick positions, half a tick behind the latest one. Drawing closer to the present, by extending the last movement, was measured: it doubled the hitches.
When a rollback moves a player, the extrapolation principle comes back. The simulation is corrected immediately, because it’s the one deciding contacts. The drawing keeps the gap and fades it out over a tenth of a second. Only a real teleport, like a position swap or a respawn, is shown in one go.
What rollback asks of the rest of the game
Rollback fits in thirty lines. The work is elsewhere: everything that affects the match must be able to be saved, restored and replayed identically.
Objects. A projectile in flight, a trap on the ground, a bubble gum trapping a player, a boomerang flying back to its thrower: everything has to rewind with the players. Otherwise the replay starts from an inconsistent world, where the player is back on tick 103 but the projectile stayed on tick 105. In Runner Party, a homing missile computes its route to its target at every moment. Every machine had to compute the same route, and the route history had to be restored too.
Effects, in silence. A jump sound mustn’t play again on every replay, nor an explosion go off again. So replayed ticks mute one-off effects: sounds, particles and animation triggers. They only fire the first time a tick is played. In Runner Party, that’s FxGate.Mute().
Decisions, timestamped. Who’s eliminated, which item comes out of a box, when the round ends: these are facts, not movements. A wrong guess about an elimination can’t be smoothed out on screen. Only one machine makes these decisions, the host, and it timestamps them on the shared clock, a little in the future. The message has time to reach everyone, and every machine applies the decision on the same tick.
- on tick 500, the host sees that a player fell off the screen;
- it announces: “elimination on tick 508”. That’s the decision tick, plus the delay, plus the longest possible guess, plus one tick of margin;
- every machine receives the announcement before tick 508, and removes the player on tick 508.
The same goes for drawing an item from a box. The item depends on the standings, and the standings change from one tick to the next. So the host draws the item from the standings as they were 30 ticks earlier, standings every machine already knows.
Cheating. A sneaky player could cut their connection for a second, see where the others are, then send “late” inputs, stamped as if they had reacted in time. That’s a lag switch. The fix: the host rejects any input that arrives too long after its tick, 15 ticks in Runner Party. An honest player never gets close.
Sending inputs around
Rollback sends almost nothing but inputs, and an input is small. In Runner Party, it fits in 3 bytes: the stick direction and the buttons.
Redundancy. Each packet doesn’t just carry the new input, but the last 8. If a packet is lost, the next one carries everything that was missing:
- the packet for tick 200 carries inputs 193 to 200. It’s lost;
- the packet for tick 201 carries inputs 194 to 201. It arrives, and input 200 with it.
A loss only costs the time of one packet. Eight 3-byte inputs, 60 times a second: about 1.4 KB per second per player, before headers.
Empty inputs. A player standing still sends an empty input, and a network layer trying to save bandwidth may stop sending it. That’s what Runner Party’s network layer did after a few repeats. The other side then plays that player without knowing which tick what it plays belongs to. As long as the player doesn’t move, nothing shows. The day something happens to them while they stand still, the two machines no longer agree on when it happened.
Game messages, on tick boundaries. Reading the network every frame rather than every tick saves a few milliseconds. But the simulation expects its messages, an item announcement or an elimination, within a tick. A message read between two ticks is put aside and applied at the start of the next tick. Without that, in Runner Party, an item announced between two ticks was drawn at its spawn point, then jumped 13 units.
Keeping a shared clock
Two PCs share no common time. Each machine estimates the host’s clock from the messages it receives, taking travel time into account. The network layer already makes that estimate, and Runner Party follows it. Measured, the estimate lands within about one tick on every machine. That’s enough: the delay margin covers the gap.
The estimate moves, though. When the network slows down, the estimated clock can go back a tick or two. A world that goes back replays ticks it already played, and inputs already sent end up stamped twice. The rule: the world never goes back. It follows the estimated clock by slowing down or speeding up a little, never rewinding, with one tick of tolerance.
Two traps, seen in Runner Party, show how little a wrong clock looks like a clock problem on screen:
- dropped ticks. The network layer caps how many ticks can run in a single frame. On a host running 120 ticks per second with bots, a slow frame held more than the cap (25 ms worth of ticks), and the extra ticks were dropped. The host only played 99 to 106 ticks per second, and clients were pulled back 10 to 23 ticks every second. On screen, a client that froze now and then;
- the first tick. On the very first tick, the clock still read 0, and the previous tick, “now minus one”, went below zero. On an unsigned integer, that gives the largest possible value, and no tick was played anymore: players stayed frozen in mid-air. Only fast hosts (6 ms frames, a 165 Hz screen) fell into it. Those at 16.7 ms started normally.
Tuning the delay
The whole trade-off of the model comes down to one number. Too much delay, and the controls feel sluggish. Not enough, and every machine guesses more often, so it corrects more often.
What a player feels
Felt delay isn’t the ping. For a client, it’s the round trip of its inputs, plus a margin on each trip, minus the base delay, which everyone already pays, host included. In a real two-player test on Unity Relay:
- trip from the client to the host: 1.5 ticks. Trip back: 3.7 ticks;
- margin: 0.5 ticks per trip. Total: 1.5 + 3.7 + 1 = 6.2 ticks;
- base delay: 4 ticks. That leaves 6.2 − 4 = 2.2 ticks, rounded up to the next whole tick: the client feels 3 ticks, 50 ms.
One detail matters. When a client’s outbound trip is shorter than the base delay, its inputs arrive early, and that time is wasted. So it’s allowed a negative share: its inputs leave a little less early. Without that, the same client felt 5 ticks instead of 3.
Who chooses, and when
- Before each round, never during one, the host picks the base delay from the measured trips: half of the clients’ median round trip, between 2 and 4 ticks. The median rather than the worst: a client far away doesn’t make the others pay for their distance.
- Each client gets its own share, computed from its own trips. Its inputs leave a little earlier so they reach the others in time, and it plays the world a little behind so that theirs reach it in time.
- A ceiling: beyond 4 felt ticks, 67 ms, it’s too much. Past that threshold, the client stops waiting: it guesses the rest, 3 ticks at most, and rollback fixes it.
- Mid-round, there’s a single exception. A client that guesses wrong too often (more than one visible correction per second, or more than 30% of ticks guessed) takes one more tick, on its own side only, and gives it back after ten quiet seconds. The other machines see nothing: its inputs keep their timestamps.
The goal is a consistent delay: not too much felt delay, and above all not one round where you feel it a lot and another where you don’t feel it at all.
Two players, four players
With two players, the client only waits for the host. With four, if every input goes through the host, inputs from one client to another make two trips: one client’s trip to the host, then the host’s trip to the other. That’s almost a full round trip, which no base delay covers. So clients send each other their inputs directly, and the route through the host stays as a fallback.
- ticks per second
- 60
- ticks of delay, picked by the host
- 2–4
- max felt delay
- ≤ 67 ms
- inputs per packet
- 8
Determinism
GGPO takes the idea all the way: it sends only inputs, never the state of the world. Each machine rebuilds exactly the same match on its own from the same inputs. That requires a bit-for-bit deterministic simulation: same inputs, same numbers, on every machine.
A general-purpose engine doesn’t guarantee that. Floating-point maths can give slightly different results from one processor to another (x64 on PC, ARM64 on Switch and on recent Macs) depending on the instructions the compiler picks. Maths functions (sine, square root, power) don’t necessarily have the same implementation everywhere. And a physics engine adds its own calculations on top.
So there are two ways to run rollback:
- strict determinism, GGPO style. Only inputs are sent, plus a fingerprint of the state (a hash) now and then to detect a desync;
- rollback with a reference. One machine is authoritative and regularly sends the real state, which the others converge to. It’s heavier, but it tolerates small differences.
To move towards strict determinism in Unity, there are two routes:
- Burst’s deterministic floats. The Burst compiler has a
FloatMode.Deterministicmode, which swaps maths functions for versions that are identical everywhere and turns off optimisations that vary between platforms. It’s limited to 64-bit architectures, and the whole simulation then has to run through Burst-compiled code; - fixed point. Positions and velocities are stored as integers, with a fixed number of decimals. Integer maths gives the same result on every machine, without exception. It’s the safest route, and the most expensive, since all the gameplay maths has to be rewritten.
Either way, Unity’s and Corgi’s physics have to leave the simulation, replaced by deterministic collision queries.
In short
- An authoritative server gives a single version of the match, at the cost of a round trip of delay.
- Prediction removes that delay for your own player, and reconciliation replays unconfirmed inputs on top of the server’s state.
- Interpolation makes the other players smooth by showing them in the past, and puts several moments on the same screen.
- Extrapolation shows everyone in the present, and gets it wrong at every decision of a player with no inertia.
- The shared clock and the input delay put everyone on the same tick, and rollback fixes the inputs that arrived too late.
- The rest of the game has to follow: rewindable objects, silent effects on replay, decisions timestamped by the host.