Deep dive
How it works
Parts:
- Input sampler — assigns
seq, applies input to local state with fixed dt. - Pending buffer — ring of unacknowledged
(seq, input). - Reconciler — on
{ack_seq, state}resets to the server state and replays the pending buffer. - Error smoother — decays the visual offset to zero; snaps when the error is huge.
- Each client frame: sample input, assign
seq, apply it to the local predicted state with fixed dt, push(seq, input)into a pending buffer, send the input (plus the previous 2-3 for redundancy). - On a server snapshot
{ack_seq, state}:- Drop pending inputs with
seq ≤ ack_seq. - Remember the currently displayed position
old. - Set predicted state = server state; replay all remaining pending inputs.
error = old − new. If small, add it to a visual offset that decays to zero; if huge (teleport, respawn), snap.
- Drop pending inputs with
- Render at
predicted + visual_offset.
t=0 client: input 1..6 applied locally (pos 6) server: has nothing yet
t=50 server applies 1..3 -> pos 3, sends {ack 3, pos 3}
t=100 client gets {ack 3, pos 3}: reset to 3, replay 4..12 -> pos 12 (no visible jump if prediction was right)
Code
use std::collections::VecDeque;
#[derive(Clone, Copy, Default, PartialEq, Debug)]
pub struct State { pub x: f32, pub y: f32, pub vx: f32, pub vy: f32 }
#[derive(Clone, Copy, Default)]
pub struct Input { pub seq: u32, pub ax: f32, pub ay: f32, pub jump: bool }
pub const DT: f32 = 1.0 / 60.0;
/// Shared with the server: same code, same dt, same order of operations.
pub fn simulate(s: &State, i: &Input) -> State {
let mut n = *s;
n.vx = (n.vx + i.ax * 40.0 * DT) * 0.9; // accel + friction
n.vy = if i.jump && n.y == 0.0 { 8.0 } else { n.vy - 20.0 * DT };
n.x += n.vx * DT;
n.y = (n.y + n.vy * DT).max(0.0);
if n.y == 0.0 { n.vy = 0.0; }
n
}
pub struct Predictor {
pub predicted: State,
pending: VecDeque<Input>,
next_seq: u32,
offset: (f32, f32), // visual correction, decays to zero
}
impl Predictor {
pub fn new(start: State) -> Self {
Predictor { predicted: start, pending: VecDeque::with_capacity(128), next_seq: 1, offset: (0.0, 0.0) }
}
/// Called every client tick. Returns the input to send to the server.
pub fn local_input(&mut self, ax: f32, ay: f32, jump: bool) -> Input {
let i = Input { seq: self.next_seq, ax, ay, jump };
self.next_seq += 1;
self.predicted = simulate(&self.predicted, &i);
if self.pending.len() == 128 { self.pending.pop_front(); } // server unreachable for ~2 s
self.pending.push_back(i);
i
}
/// Server snapshot for our own entity: authoritative state after input `ack_seq`.
pub fn on_server_state(&mut self, ack_seq: u32, server: State) {
while self.pending.front().is_some_and(|i| i.seq <= ack_seq) { self.pending.pop_front(); }
let old = (self.predicted.x + self.offset.0, self.predicted.y + self.offset.1);
let mut s = server;
for i in &self.pending { s = simulate(&s, i); } // replay unacknowledged inputs
self.predicted = s;
let err = (old.0 - s.x, old.1 - s.y);
if err.0 * err.0 + err.1 * err.1 > 4.0 {
self.offset = (0.0, 0.0); // > 2 m: teleport/respawn, snap
} else {
self.offset = err; // hide the correction, then blend it out
}
}
/// Render position; call once per rendered frame with the frame time.
pub fn render_pos(&mut self, frame_dt: f32) -> (f32, f32) {
let k = (-frame_dt / 0.1).exp(); // 100 ms time constant, frame-rate independent
self.offset = (self.offset.0 * k, self.offset.1 * k);
(self.predicted.x + self.offset.0, self.predicted.y + self.offset.1)
}
}
#[test]
fn no_correction_when_prediction_matches() {
let mut p = Predictor::new(State::default());
let mut server = State::default();
let mut sent = Vec::new();
for t in 0..30 { sent.push(p.local_input(1.0, 0.0, t == 5)); }
for i in &sent[..20] { server = simulate(&server, i); } // server is 10 inputs behind
p.on_server_state(20, server);
assert!(p.offset.0.abs() < 1e-6 && p.offset.1.abs() < 1e-6);
}
Complexity and constants
- Replay cost per snapshot: k × cost(simulate); k = RTT / input interval (6 at 100 ms and 60 Hz, 25 at 200 ms and 128 Hz). Player movement is order of µs, so even 30 replays per snapshot are negligible; full physics replays are not.
- Pending buffer: 64-128 inputs (1-2 s at 60 Hz) bounds memory and replay time; beyond that the connection is effectively lost.
- Correction smoothing: exponential decay with a 50-150 ms time constant; snap above order of 1-3 m.
Efficiency
Cost: one snapshot triggers k replays with k = RTT × input rate (6 at 100 ms and 60 Hz); player movement is order of µs per step, so 30 replays stay negligible, while full-physics replays do not. Memory is the pending ring of 64-128 inputs. The bound is CPU on the client.
Levers:
- Compare server state with the stored predicted state at
ack_seqand skip the replay when equal — saves all k steps (order of) — when mispredictions are rare. - Quantize predicted state as the server serializes it (state-quantization) — avoids constant tiny corrections — always.
- Exponential error smoothing, 50-150 ms time constant, snap above order of 1-3 m — hides corrections — local player correction.
- Cap the pending buffer at 1-2 s of inputs — bounds replay time — lossy links.
Naive → standard → state of the art:
- Wait for the server (no prediction) · one RTT of input delay
- Predict, reset and replay pending inputs on each snapshot · zero perceived delay, k fixed-dt steps per snapshot
- Prediction of remote inputs too, with saved state and re-simulation (rollback-netcode) · O(r) frames per misprediction, r ≤ about 8
Implementation notes
- Quantize the state the same way the server serializes it (state quantization) before replaying, or tiny float differences produce constant small corrections.
- The server must process exactly the inputs the client predicted, in order, with the same dt. If it skips or duplicates inputs (late packets), echo that in the ack so the client replays the same sequence.
- Predict own weapon fire locally (muzzle flash, sound, ammo count), but let the server confirm hits (lag compensation).
- Predicting interactions with remote entities (pushing, collisions) uses their interpolated positions, which are in the past: expect corrections there and keep them gentle.
- Store the full predicted state per input (ring buffer indexed by seq) if you want to compare server state to the exact predicted state at
ack_seqand skip replay when they match.
Pitfalls
- Running movement on variable frame dt locally but fixed dt on the server: permanent divergence. Simulate in fixed steps; interpolate rendering.
- Forgetting to replay after reset: the player snaps back by one RTT on every snapshot (rubber-banding).
- Smoothing the predicted state itself instead of a render offset: the error feeds back into the simulation.
- Mispredicted events (jumping onto a platform another player moved) need the replay to include those entities' server state at the right time, which is usually not available; accept the correction.
- Randomness in movement (spread, recoil) must use a seed derived from seq so client and server agree.
Variants and alternatives
In production
- Games: QuakeWorld (1996, first widely used), Valve Source (
cl_predict), Unreal EngineCharacterMovementComponent(saved moves and replays), Overwatch, Rocket League (physics prediction), Unity Netcode for Entities (built-in client prediction). - Collaborative design tools: Figma clients apply property changes at once and discard server values that conflict with unacknowledged ones.
- Offline-first web apps: Replicache runs mutators locally, then rewinds to the server patch and replays pending mutations on top.
- Robotics: predictors of operator input for time-delayed teleoperation (minimum-jerk motion model, tested with 100 ms round-trip delay; Smith and Jensfelt, Mechatronics 2010).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| lightyear | Rust | cBournhonesque/lightyear | cargo add lightyear | — | — | — | — | Bevy networking with client prediction, rollback and interpolation |
| naia | Rust | naia-lib/naia | cargo add naia-server | — | — | — | — | Server-authoritative entity replication; tick-based prediction and rollback |
| Netcode for GameObjects | C# | Unity-Technologies/com.unity.netcode.gameobjects | Unity Package Manager (com.unity.netcode.gameobjects) | — | — | — | — | Unity networking; AnticipatedNetworkVariable and AnticipatedNetworkTransform for client anticipation |
Sources
- Valve Developer Community, Source Multiplayer Networking and Prediction. page · page
- Gambetta, Fast-Paced Multiplayer, Part II: Client-Side Prediction and Server Reconciliation, gabrielgambetta.com. article
- Fiedler, Networked Physics and What Every Programmer Needs To Know About Game Networking, gafferongames.com. series · post
- Ford, Overwatch Gameplay Architecture and Netcode, GDC, 2017. talk
- Smith, Jensfelt, A predictor for operator input for time-delayed teleoperation, Mechatronics, 2010. paper
- Figma, How Figma's multiplayer technology works, 2019. post
- Replicache, How Replicache Works. docs