Home › Multiplayer & game networking › Latency hiding
algorithm medium

Client-side prediction

Problem In a system where a server owns the state, the local user's actions show no effect until a full network round trip has passed, which feels sluggish. This hides that delay so input feels immediate and still converges on the server's result.

The client applies its own inputs immediately, then on each server state rewinds to it and replays unacknowledged inputs (reconciliation).

O(k) re-simulation per snapshot, k = inputs in flight ≈ RTT × input rate
predictionreconciliationlatency-hidingfpsinput
Overview

With an authoritative server, pressing "forward" would move your character only after a full round trip: 100 ms of mush. Prediction runs the same movement code locally the moment the input happens. Each input carries a sequence number; the server echoes the last one it processed with its state. The client resets to that state and re-applies every input the server hasn't seen yet, so its view stays both instant and convergent with the server. The same optimistic-apply-then-reconcile loop runs in collaborative editors, offline-first web apps and delayed teleoperation.

Key facts

Use when

  • Any real-time game with server authority where the local player's movement or actions must feel immediate.
  • Collaborative and offline-first apps: apply the edit locally, rebase pending edits on the server's state (Figma, Replicache).
  • Teleoperation over a delayed link: predict the operator's input one round trip ahead so the remote machine appears to respond at once (Smith and Jensfelt).

Avoid when

  • The client can't know the outcome (damage to others, loot rolls, physics with many remote bodies): show a tentative effect and wait for the server.
  • Lockstep games: use rollback, which predicts all players' inputs, not just state.

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.
  1. 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).
  2. 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.
  3. 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_seq and 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_seq and 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

No prediction (dumb terminal)fine for RTS-style click-to-move or slow games; latency shows as delayed response.
Rollback netcodepredicts remote inputs too and re-simulates the whole game; peer-to-peer fighting games.
Client authority over own movement with server checksno corrections, but speed and teleport cheats need detection.

In production

  • Games: QuakeWorld (1996, first widely used), Valve Source (cl_predict), Unreal Engine CharacterMovementComponent (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

namelangrepoinstallverdictlicencehealthas_ofnotes
lightyearRustcBournhonesque/lightyearcargo add lightyear————Bevy networking with client prediction, rollback and interpolation
naiaRustnaia-lib/naiacargo add naia-server————Server-authoritative entity replication; tick-based prediction and rollback
Netcode for GameObjectsC#Unity-Technologies/com.unity.netcode.gameobjectsUnity 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

Builds on

Used by

Systems (3):

Alternatives and related