The Multiplayer Architecture chapter set up the client-server model: the server holds the one true copy of the game world (the authoritative server), clients send it their input, and the server decides what actually happened. That model is correct, but played straight it feels bad. Between the moment you press a key and the moment the server's answer reaches your screen sits a real delay measured in network time, not frames. This chapter is about the tricks that hide that delay: predicting your own moves before the server confirms them, quietly correcting mistakes when the server disagrees, smoothing out other players so their motion does not look like a slideshow, and — for anything involving hitting another player — rewinding time on the server so a shot that looked fair on the shooter's screen gets judged fairly too.
Say a client and server are 50 milliseconds apart in each direction — a one-way latency of 50ms, so a round trip (RTT, Round-Trip Time) is about 100ms. If the client does nothing but send input and wait for the server's answer, here is the timeline for a single keypress:
The player waited 100ms after pressing a key before anything moved on screen. On a good home connection that is already noticeable; on a worse connection (200-300ms RTT is common on mobile networks) it feels sluggish, like steering a boat instead of a character. And this is the best case — real packets do not arrive at even intervals (jitter, covered in section 5), so the delay is not a steady 100ms, it jumps around.
The server has to stay authoritative — a client that got to decide its own position for real, instantly, would be trivial to cheat with. So the fix cannot be "give up authority." The fix is to make the client's own screen update immediately with a guess, while the server keeps computing the real answer in the background.
Client-side prediction means the client applies its own input the instant it happens, without waiting for the server, using the exact same movement rule the server will eventually use. The player sees themselves move immediately. The client is guessing what the server will decide — and because both sides run the same rule on the same input, the guess is almost always exactly right.
Here is a minimal predicted player controller in Unity C#. It applies movement locally the moment input happens, and also sends that same input to the server so the server can run the identical rule authoritatively:
using UnityEngine;
using System.Collections.Generic;
public class PredictedInput
{
public int Sequence; // increasing counter, one per input this client has sent
public Vector3 MoveDir; // input direction for this tick
public float DeltaTime; // how much time this input covers
}
public class PredictedPlayer : NetworkBehaviour
{
public float speed = 5f;
int nextSequence = 0;
// every unacknowledged input we have applied locally, oldest first
Queue<PredictedInput> history = new Queue<PredictedInput>();
void Update()
{
if (!IsOwner) return; // only the owning client predicts its own player
Vector3 moveDir = new Vector3(
Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
PredictedInput input = new PredictedInput
{
Sequence = nextSequence++,
MoveDir = moveDir,
DeltaTime = Time.deltaTime
};
ApplyMove(input); // 1. move on screen right now, no waiting
history.Enqueue(input); // 2. remember it, in case the server disagrees
SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime); // 3. tell the server
}
void ApplyMove(PredictedInput input)
{
transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
}
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
// the server applies the SAME movement rule, authoritatively.
// covered in full in section 3.
}
}
Two things make this a real prediction instead of a disconnected guess:
ApplyMove is the exact same function the server will run for this input. Same rule, same input, same starting position -> same result, almost always.history, tagged with a rising Sequence number, instead of being thrown away after sending it. Section 3 needs that history to fix the cases where the guess turns out wrong.The server receives each SubmitInputServerRpc call, applies the same movement rule, and keeps track of the last input sequence number it has processed for that player. On a fixed schedule (once per tick — a fixed simulation step, covered in the Multiplayer Architecture chapter) it sends the authoritative result back to the owning client: the resulting position, plus that sequence number.
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
transform.position += moveDir.normalized * speed * dt; // authoritative move
lastProcessedSequence = sequence;
// tell the owning client: "this is your real position, I have processed
// everything up to and including this sequence number"
StateUpdateClientRpc(transform.position, lastProcessedSequence);
}
Back on the client, StateUpdateClientRpc is where reconciliation happens — making the predicted state agree with the authoritative one again. Three steps, every time a state update arrives:
history whose sequence number is less than or equal to the acknowledged sequence — the server has already accounted for those; replaying them again would double-apply them.history, in order, on top of the snapped position — those are the inputs the server had not seen yet when it sent this update.[ClientRpc]
void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
{
if (!IsOwner) return;
// 1. snap to the authoritative position
transform.position = serverPosition;
// 2. drop every input the server has already processed
while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
history.Dequeue();
// 3. replay everything the server had not seen yet
foreach (PredictedInput input in history)
ApplyMove(input);
}
If the client's guess matched the server exactly, step 3 replays inputs on top of a position that is identical to where the client already was — the player does not visibly move when this runs, because serverPosition plus the replayed inputs lands exactly back where the screen already showed. If the guess was slightly wrong (a collision the client did not predict correctly, for example), the replay corrects it, usually by a tiny, sub-pixel amount that nobody notices.
List<PredictedInput> and calling RemoveAt(0) in a loop works but is needlessly slow, and removing items while iterating with foreach throws an exception. A Queue<T> is the right shape here: you only ever remove from the front (Dequeue) and add to the back (Enqueue).Numbers make this concrete. Say the player only moves along one axis, one unit per tick, and starts this window at x = 10.0. The client has already predicted five inputs (sequence 41 to 45) before hearing back from the server:
The client's StateUpdateClientRpc now runs:
Now the interesting case: suppose tick 43 crossed an obstacle the client did not know about, so the server actually stopped the player at x = 12.6 instead of continuing to 13.0:
Vector3.Lerp from the currently displayed position to the reconciled position over 100-150ms — instead of assigning it in one step.Prediction solves movement for your own character, because you know your own input before the server does. It does not help for anyone else's character — you have no idea what another player is about to press. All you get for them is a stream of snapshots (position updates) arriving over the network, and that stream is never perfectly smooth. Packets do not all take the same amount of time to arrive; the variation in arrival timing is called jitter.
If the client just snapped the other player to whatever position the latest snapshot says the instant it arrives, the character would move in uneven little jumps — smooth, then a pause, then two updates almost on top of each other. Entity interpolation fixes this by deliberately rendering other players a little bit in the past, and smoothly blending between two known positions instead of jumping to each new one.
The client keeps a short buffer of the last few snapshots it received, each tagged with the server's timestamp. It picks a render time that is slightly behind the newest snapshot — typically 100-200ms, enough to reliably have at least two snapshots either side of it even with jitter — then finds the two buffered snapshots that straddle that render time and interpolates (blends) between them.
public struct Snapshot
{
public float ServerTime;
public Vector3 Position;
}
public class InterpolatedRemotePlayer : MonoBehaviour
{
List<Snapshot> buffer = new List<Snapshot>();
public float interpolationDelay = 0.12f; // seconds behind the newest snapshot
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
if (buffer.Count > 20) buffer.RemoveAt(0); // keep the buffer from growing forever
}
void Update()
{
if (buffer.Count < 2) return;
float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;
// find the two snapshots that straddle renderTime
for (int i = 0; i < buffer.Count - 1; i++)
{
Snapshot a = buffer[i];
Snapshot b = buffer[i + 1];
if (a.ServerTime <= renderTime && renderTime <= b.ServerTime)
{
float t = (renderTime - a.ServerTime) / (b.ServerTime - a.ServerTime);
transform.position = Vector3.Lerp(a.Position, b.Position, t);
return;
}
}
}
}
The cost is a fixed, deliberate delay — you are always looking at other players 100-200ms in the past. That is a fair trade: a small, constant, invisible delay instead of visible stutter. It is also why lag compensation (section 7) has to exist: the server's live picture of where everyone stands is not the same as what any client is currently seeing on screen.
interpolationDelay is a dial, not a fixed constant. Too small and the buffer runs dry constantly, forcing extrapolation (section 6) more often than intended. Too large and other players visibly lag further behind their true position, which matters more in a fast-paced shooter than in a slow-paced farming sim.Interpolation needs a snapshot after the render time to blend towards. If the network stalls — a lost packet, a spike in latency — the newest snapshot the client has might already be older than the render time, with nothing newer to interpolate towards. The buffer runs dry.
Extrapolation (sometimes called dead reckoning) is the fallback: instead of blending between two known points, keep moving the character using its last known velocity, guessing where it probably is now.
Vector3 lastPos = buffer[buffer.Count - 1].Position;
Vector3 velocity = EstimateVelocityFromLastTwoSnapshots(buffer);
float overrun = renderTime - buffer[buffer.Count - 1].ServerTime;
// guess: keep moving in the same direction at the same speed
Vector3 extrapolatedPos = lastPos + velocity * overrun;
Extrapolation is only safe for a short overrun — a few tens of milliseconds. The longer it runs unchecked, the more it drifts from reality: a real player turns, stops, or reverses direction constantly, and "keep going the same way" gets worse the longer it has to guess blind. When a real snapshot finally arrives, the extrapolated guess and the real position rarely match exactly, so the character has to visibly correct — the same kind of snap that section 4's tip warned about, just happening to someone else's character instead of your own.
Put prediction, reconciliation, and interpolation together and here is where every player actually stands in time, from your point of view:
Now think about aiming a shot. You aim at where you see the enemy, which, as just established, is not where the enemy actually is on the server right now — it is where the enemy was, somewhere between 100 and 300ms ago depending on your connection. If the server simply checked "is anyone standing where this shot was aimed, using the current authoritative positions," a shot that looked perfectly aimed on your screen would usually miss, because the server has already moved that enemy forward since the snapshot you were looking at.
Lag compensation (also called server-side rewind) fixes this by having the server keep a short history of everyone's hitboxes (the collision shapes used for hit detection) for the last several ticks — for example the last one second. When a shot arrives, the server does not check it against the current positions. It works out the shooter's latency, rewinds every other player's hitbox back to where it was at that point in the past, and checks the shot against that.
bool ServerValidateHit(Player shooter, Ray shotRay, float shooterLatencySeconds)
{
// 1. work out which point in the recent past the shooter was aiming at
float rewindTime = ServerTime.Now - shooterLatencySeconds;
foreach (Player target in AllPlayersExcept(shooter))
{
// 2. get that target's hitbox as it was back then, from the
// server's own rolling history buffer (same idea as
// the interpolation buffer in section 5, kept server-side)
Hitbox rewoundHitbox = target.HitboxHistory.SampleAt(rewindTime);
// 3. check the shot against the REWOUND hitbox, not the current one
if (rewoundHitbox.IntersectsRay(shotRay))
{
ApplyDamage(target, shooter);
return true;
}
}
return false;
}
This is why lag compensation is described as "the server rewinds time to judge the shot fairly for the shooter." From the shooter's point of view, aiming carefully and pulling the trigger reliably lands the hit they saw on screen, no matter their ping. Without it, players with any real latency would be constantly leading their shots to compensate for a delay they cannot even see directly, and would still miss shots that looked correct.
Lag compensation makes the shooter's experience fair at a direct cost to the victim's experience. Walk through the same rewind from the victim's side:
This is the famous "shot behind the wall" complaint in every competitive shooter with lag compensation. It is not a bug — it is the direct, unavoidable consequence of judging the shot fairly for the shooter. Someone has to lose the disagreement between "where the victim's own screen showed them" and "where the shooter's screen showed them," and lag compensation always resolves it in the shooter's favor, because the alternative — always favoring the victim — makes shooting nearly impossible for anyone with real ping.
Two variables control how bad this feels, and both are tunable by the people who ship the game:
There is no setting that makes both players feel like the sole authority on what happened — the network delay is real, and someone has to absorb it. Lag compensation is a deliberate choice about who.
Prediction is not free — every predictable action needs the client to run the same rule the server runs, and needs a reconciliation path for when it is wrong. It is worth it for high-frequency, cheap-to-recompute actions like movement. It is often not worth it, or actively wrong, for other kinds of actions:
Here is a single, complete example combining sections 2-4: local prediction, input history, and reconciliation, written as one player controller. This is close to what a real Unity NGO (Netcode for GameObjects) project looks like, simplified to keep the networking calls readable.
using UnityEngine;
using System.Collections.Generic;
public class PredictedInput
{
public int Sequence;
public Vector3 MoveDir;
public float DeltaTime;
}
public class FullPredictedPlayer : NetworkBehaviour
{
public float speed = 5f;
int nextSequence = 0;
Queue<PredictedInput> history = new Queue<PredictedInput>();
// ----- CLIENT SIDE -----
void Update()
{
if (!IsOwner) return;
Vector3 moveDir = new Vector3(
Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
var input = new PredictedInput
{
Sequence = nextSequence++,
MoveDir = moveDir,
DeltaTime = Time.deltaTime
};
ApplyMove(input);
history.Enqueue(input);
SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime);
}
[ClientRpc]
void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
{
if (!IsOwner) return;
transform.position = serverPosition;
while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
history.Dequeue();
foreach (PredictedInput queued in history)
ApplyMove(queued);
}
// ----- SHARED -----
void ApplyMove(PredictedInput input)
{
transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
}
// ----- SERVER SIDE -----
int lastProcessedSequence = -1;
[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
var input = new PredictedInput { Sequence = sequence, MoveDir = moveDir, DeltaTime = dt };
ApplyMove(input); // same rule, authoritative result
lastProcessedSequence = sequence;
StateUpdateClientRpc(transform.position, lastProcessedSequence);
}
}
Trace it end to end for one lag spike, using speed = 1 unit/tick to keep the numbers simple, starting at x = 0:
That is the entire point of this chapter's first half, in one trace: the player felt zero lag, and the server never lost authority — it just confirmed, a little late, something the client had already correctly guessed.
Prediction and reconciliation bugs share one symptom: the character jitters, snaps, or slowly drifts even on a good connection. A few causes come up over and over:
ApplyMove on the client and the equivalent code on the server are not exactly the same rule (a different gravity constant, a different collision radius, floating point computed in a different order), every replay produces a slightly different answer than what the player already saw, so the player sees a constant, permanent jitter instead of an occasional invisible correction.StateUpdateClientRpc messages somehow arrive out of order and the client reconciles against an older acknowledgment after a newer one already ran, it can un-drop inputs that were already correctly removed.interpolationDelay is smaller than the typical jitter, the buffer runs dry constantly and every other player extrapolates (section 6) far more often than intended, showing up as small stutters exactly under network load.history every frame. On a stable connection it should hover around RTT / tickInterval and stay roughly constant. A queue that grows without bound means acknowledgments are not arriving, or are not being matched to the right sequence numbers — the client is predicting further and further ahead with nothing ever confirming it.PredictedInput entries can be sitting in history at once, in the worst case, before an acknowledgment can possibly arrive? Why does the client need to keep at least that many, rather than just the very last one or two?One tick is about 33ms. A full round trip is 200ms, so the client keeps sending new predicted inputs for the entire 200ms before the very first acknowledgment for any of them can come back: 200ms / 33ms per tick ≈ 6 ticks. In the worst case the client could have that many un-acknowledged inputs in history at once — plus a small safety margin for jitter, so rounding up to 8-10 slots is a safe buffer size.
If the client only kept the last input or two, reconciliation (section 3) would have nothing left to replay after snapping to the server's position — every acknowledgment would erase inputs the server had not actually processed yet, and the player would visibly snap backward on every single update instead of staying put. The history has to cover the entire round trip, not just "the most recent thing."
x = 50.0. The client has sent and locally applied five inputs, sequence 200 to 204, each moving +2 along x. The server, due to a collision the client did not know about, only actually moved the player +2 for sequence 200 and 201, then +0 (blocked) for sequence 202, then reports back serverPosition = 54.0, acknowledgedSequence = 202. Work out: (a) what the client's screen showed right before this update arrived, (b) which entries remain in history after reconciliation, and (c) what position the screen shows immediately after reconciliation.(a) Before the update, the client had blindly applied all five +2 moves on top of 50.0: 50 + 2+2+2+2+2 = 60.0. That is what the player was seeing.
(b) Reconciliation drops every entry with Sequence <= 202, which removes 200, 201, and 202. What remains in history is [203, 204].
(c) Snap to the server's answer first: x = 54.0. Then replay the two remaining inputs, each +2: 54 + 2 = 56, then 56 + 2 = 58. The screen shows x = 58.0 after reconciliation — a visible correction of 2 units backward from the 60.0 it was showing a moment earlier, because the client's guess did not know about the collision that blocked sequence 202.
public class BuggyRemotePlayer : MonoBehaviour
{
Vector3 latestPosition;
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
latestPosition = pos;
}
void Update()
{
transform.position = latestPosition;
}
}
The bug: this code keeps no history at all, just the single most recent snapshot, and snaps straight to it every time a new one arrives. That is exactly the "just apply the latest snapshot" behavior section 5 described as producing uneven jumps — there is nothing to interpolate between, so between two arrivals the character sits frozen, then teleports the instant the next one lands.
public class FixedRemotePlayer : MonoBehaviour
{
struct Snapshot { public float ServerTime; public Vector3 Position; }
List<Snapshot> buffer = new List<Snapshot>();
public float interpolationDelay = 0.12f;
public void OnSnapshotReceived(float serverTime, Vector3 pos)
{
buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
if (buffer.Count > 20) buffer.RemoveAt(0);
}
void Update()
{
if (buffer.Count < 2) return;
float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;
for (int i = 0; i < buffer.Count - 1; i++)
{
if (buffer[i].ServerTime <= renderTime && renderTime <= buffer[i + 1].ServerTime)
{
float t = (renderTime - buffer[i].ServerTime) /
(buffer[i + 1].ServerTime - buffer[i].ServerTime);
transform.position = Vector3.Lerp(buffer[i].Position, buffer[i + 1].Position, t);
return;
}
}
}
}
The fix keeps a short buffer of snapshots instead of one, picks a render time slightly behind the newest arrival, and blends (Lerp) between the two snapshots that straddle it every frame — exactly the pattern from section 5, which is worth re-reading once this exercise clicks.