Home › Multiplayer & game networking › Latency hiding
algorithm low

Entity interpolation

Problem Clients that display remote objects from state updates arriving at irregular intervals, sometimes lost, see them stutter or jump. The client needs to show smooth motion despite jitter and loss.

Render remote entities a fixed delay in the past, blending the two buffered snapshots around that time: ~2 snapshot intervals of latency buy smooth motion.

O(1) per entity per frame (binary or linear search over a buffer of 3-32 snapshots)
interpolationsnapshotsjitter-bufferhermitesmoothingclock-sync
Overview

Snapshots arrive 20-64 times a second, jittered and sometimes lost; drawing each on arrival stutters. Games, distributed military simulation and robot software all show remote or sensed objects this way. Interpolation renders remote entities at server_time − delay, a moment for which the client already holds snapshots on both sides, and blends between them. With the delay set to about two snapshot intervals, one lost packet still leaves a pair to interpolate. The local player stays predicted; the server's lag compensation accounts for the delay.

Key facts

Use when

  • Displaying any entity the client does not control: other players, NPCs, projectiles from others, vehicles.
  • Distributed simulation: DIS and HLA federates display remote entities from sparse state updates and smooth the jump when a new update arrives.
  • Robotics: look up a sensor or joint pose at a past timestamp by interpolating between buffered transforms (ROS 2 tf2).

Avoid when

  • The local player (use prediction).
  • Lockstep/rollback games where the simulation itself runs on the client.
  • Objects that teleport or have discontinuous state (respawn, doors): flag discontinuities and snap.

Deep dive

How it works

Parts:

  • Snapshot buffer — per entity, last N (server_time, state) sorted by time.
  • Clock estimator — smoothed server time from timestamps and local receive time.
  • Blender — finds the pair around rt = server_now − delay and blends (lerp, Hermite, slerp).
  • Underflow handler — limited extrapolation, then hold.
  1. Buffer: per entity keep the last N snapshots (server_time, state) sorted by time; drop older ones beyond the window.
  2. Clock: estimate server_now from snapshot timestamps and local receive time (smoothed offset), so render time advances smoothly with the local clock, not in jumps when packets arrive.
  3. Render time rt = server_now − delay. Find snapshots a.t ≤ rt < b.t; s = (rt − a.t)/(b.t − a.t); blend.
  4. Underflow: if rt is past the newest snapshot, extrapolate with the newest velocity for up to max_extrapolation, then hold. When new data arrives, the correction is absorbed by the following blends.
  5. Adaptive delay: measure jitter (e.g. 95th percentile of arrival deviation) and set delay = interval × 2 + jitter, changing it slowly (time dilation of a few %) to avoid visible speed jumps.
server snapshots:  |S10    |S11    |S12   (S13 lost) |S14
client render time:            ^ rt = now - 100 ms : blend S11..S12
newest received ----------------------------------------^

Code

use std::collections::VecDeque;

#[derive(Clone, Copy, Debug)]
pub struct Snap { pub t: f64, pub pos: [f32; 3], pub vel: [f32; 3], pub rot: [f32; 4] } // t = server seconds

pub struct Interp { buf: VecDeque<Snap>, max_extrap: f64 }

impl Interp {
    pub fn new() -> Self { Interp { buf: VecDeque::with_capacity(32), max_extrap: 0.25 } }

    pub fn push(&mut self, s: Snap) {
        // Insert in time order; drop duplicates and anything older than what we've rendered past.
        let i = self.buf.partition_point(|x| x.t < s.t);
        if self.buf.get(i).is_some_and(|x| x.t == s.t) { return; }
        self.buf.insert(i, s);
        if self.buf.len() > 32 { self.buf.pop_front(); }
    }

    pub fn sample(&mut self, rt: f64) -> Option<([f32; 3], [f32; 4])> {
        while self.buf.len() >= 3 && self.buf[1].t <= rt { self.buf.pop_front(); } // keep one before rt
        let a = *self.buf.front()?;
        if rt <= a.t { return Some((a.pos, a.rot)); } // not enough history yet
        if let Some(&b) = self.buf.iter().find(|s| s.t > rt) {
            let a = *self.buf.iter().rev().find(|s| s.t <= rt).unwrap();
            let dt = (b.t - a.t) as f32;
            let s = ((rt - a.t) as f32) / dt;
            return Some((hermite(&a.pos, &a.vel, &b.pos, &b.vel, s, dt), nlerp(a.rot, b.rot, s)));
        }
        // Past the newest snapshot: extrapolate briefly, then hold.
        let last = *self.buf.back().unwrap();
        let e = ((rt - last.t).min(self.max_extrap)) as f32;
        Some(([0, 1, 2].map(|k| last.pos[k] + last.vel[k] * e), last.rot))
    }
}

/// Cubic Hermite with endpoint velocities (units/s); dt = time between the two snapshots.
fn hermite(p0: &[f32; 3], v0: &[f32; 3], p1: &[f32; 3], v1: &[f32; 3], s: f32, dt: f32) -> [f32; 3] {
    let (s2, s3) = (s * s, s * s * s);
    let (h00, h10, h01, h11) = (2.0 * s3 - 3.0 * s2 + 1.0, s3 - 2.0 * s2 + s, -2.0 * s3 + 3.0 * s2, s3 - s2);
    [0, 1, 2].map(|k| h00 * p0[k] + h10 * dt * v0[k] + h01 * p1[k] + h11 * dt * v1[k])
}

fn nlerp(a: [f32; 4], mut b: [f32; 4], s: f32) -> [f32; 4] {
    let dot: f32 = (0..4).map(|k| a[k] * b[k]).sum();
    if dot < 0.0 { b = b.map(|x| -x); } // q and -q are the same rotation: take the short way
    let q = [0, 1, 2, 3].map(|k| a[k] + (b[k] - a[k]) * s);
    let n = q.iter().map(|x| x * x).sum::<f32>().sqrt();
    q.map(|x| x / n)
}

/// Smoothed server clock: offset = server_time - local_time, filtered against jitter.
pub struct ServerClock { offset: Option<f64> }
impl ServerClock {
    pub fn new() -> Self { ServerClock { offset: None } }
    pub fn on_snapshot(&mut self, server_t: f64, local_now: f64) {
        let sample = server_t - local_now;
        self.offset = Some(match self.offset {
            None => sample,
            Some(o) if (sample - o).abs() > 0.25 => sample, // big jump: resync (server hitch, sleep)
            Some(o) => o + (sample - o) * 0.05,             // slow EWMA: no visible speed changes
        });
    }
    /// Render time for remote entities.
    pub fn render_time(&self, local_now: f64, delay: f64) -> Option<f64> {
        self.offset.map(|o| local_now + o - delay)
    }
}

Complexity and constants

  • Per entity per frame: O(log N) or O(N) over a 3-32 entry buffer, a few dozen flops; 1000 entities cost order of 10s of µs.
  • Delay choices: 64 Hz snapshots × ratio 2 ≈ 31 ms; 20 Hz × 2 = 100 ms; add 10-30 ms for jitter on Wi-Fi/mobile.
  • Total latency for seeing another player's move: their input delay + one-way to server + server tick + one-way to you + interpolation delay. At 50 ms one-way each way, 64 Hz, that is order of 150 ms.
  • Memory: 32 snapshots × ~48 bytes per entity, order of 1.5 KB.

Efficiency

Cost: per entity per frame a search over a 3-32 entry buffer plus a few dozen flops; 1000 entities cost order of 10s of µs. Memory is order of 1.5 KB per entity (32 snapshots × ~48 B). The price is latency, not CPU: delay = ratio × snapshot interval (31 ms at 64 Hz, 100 ms at 20 Hz with ratio 2).

Levers:

  • Raise the snapshot rate — halves the delay for a fixed ratio (order of) — when bandwidth allows (snapshot-delta-compression).
  • Hermite blending with sent velocities — keeps motion smooth at 10-20 Hz where linear looks like a polyline — low send rates.
  • Adaptive delay from measured jitter, changed slowly (playback 0.95-1.05x) — avoids both late feel and extrapolation — mobile and Wi-Fi.
  • Cap extrapolation at order of 100-250 ms — bounds visible error — packet loss.

Naive → standard → state of the art:

  • Draw each snapshot on arrival · stutter at the network's jitter
  • Render at server_time minus two snapshot intervals, lerp between buffered pairs · 31-100 ms of added latency, no stutter
  • Adaptive delay with Hermite blending and bounded extrapolation · latency tracks measured jitter

Implementation notes

  • Timestamp snapshots with the server tick (integer) and convert to time; floats for absolute times lose precision after hours (use f64 or ticks).
  • Interpolate in the space you'll render (world space after parent transforms) and mark discontinuities (teleport, respawn, spawn) with a flag or epoch counter so the buffer is cleared instead of blended.
  • Animation state: interpolate blend weights and playback time, not just transforms; send animation phase or start time.
  • Hermite needs velocity in the snapshot (or estimate it from neighbours); it removes the "polyline" look at low rates and keeps motion C¹-continuous.
  • Adjust playback speed (e.g. 0.95-1.05x) to keep buffer depth on target rather than jumping render time.

Pitfalls

  • Using local receive time as the timestamp: network jitter becomes visible jitter.
  • Delay too small for the actual jitter: constant extrapolation and corrections (warping). Too large: everything feels late and lag compensation must rewind further.
  • Extrapolating without a limit: dead players keep running through walls during packet loss.
  • Interpolating quaternions without the sign check: entities spin the long way around.
  • Interpolating angles as Euler degrees across 359° → 0°: a full spin. Use quaternions or shortest-angle math.

Variants and alternatives

Extrapolation / dead reckoningrender at the present by projecting with velocity (DIS, flight sims, racing); lower latency, frequent corrections, great for predictable motion.
Snapshot interpolation of physicsinterpolate the whole physics state; Gaffer's recommended default for networked physics.
Projective velocity blendingblend from the old extrapolated path to the new one over a short window (racing, vehicles).

In production

  • Games: Valve Source and Source 2 (cl_interp, cl_interp_ratio), Quake 3, Unreal Engine (smoothed simulated proxies), Overwatch, Unity Netcode for Entities (interpolated ghosts).
  • Distributed simulation: DIS dead reckoning extrapolates remote entities between Entity State PDUs; the NPS smoothing design blends linearly from the previous dead-reckoned position to the new one over time.
  • Robotics: ROS 2 tf2 keeps a 10 s history of transforms by default and its TimeCache interpolates translation and rotation between the two stored samples around the requested time.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
lightyearRustcBournhonesque/lightyearcargo add lightyear————Bevy networking with snapshot interpolation of replicated components
MirrorC#MirrorNetworking/MirrorUnity Package Manager / Asset Store————Snapshot interpolation listed as a stable feature
Netcode for GameObjectsC#Unity-Technologies/com.unity.netcode.gameobjectsUnity Package Manager (com.unity.netcode.gameobjects)————NetworkTransform with interpolation
tf2C++ros2/geometry2apt install ros-<distro>-tf2————Timestamped transform buffer with interpolation

Sources

  • Valve Developer Community, Source Multiplayer Networking (Entity interpolation section). page
  • Fiedler, Snapshot Interpolation, gafferongames.com, 2014. post
  • Gambetta, Fast-Paced Multiplayer, Part III: Entity Interpolation, gabrielgambetta.com. article
  • Bernier, Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization, GDC, 2001. page
  • Naval Postgraduate School, Smoothing Design (dis-java-vrml), DIS dead-reckoning smoothing note. pdf
  • ROS 2, tf2 TimeCache. code

Used by

Systems (5):

Alternatives and related