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 − delayand blends (lerp, Hermite, slerp). - Underflow handler — limited extrapolation, then hold.
- Buffer: per entity keep the last N snapshots
(server_time, state)sorted by time; drop older ones beyond the window. - Clock: estimate
server_nowfrom snapshot timestamps and local receive time (smoothed offset), so render time advances smoothly with the local clock, not in jumps when packets arrive. - Render time
rt = server_now − delay. Find snapshotsa.t ≤ rt < b.t;s = (rt − a.t)/(b.t − a.t); blend. - Underflow: if
rtis past the newest snapshot, extrapolate with the newest velocity for up tomax_extrapolation, then hold. When new data arrives, the correction is absorbed by the following blends. - 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
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
TimeCacheinterpolates translation and rotation between the two stored samples around the requested time.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| lightyear | Rust | cBournhonesque/lightyear | cargo add lightyear | — | — | — | — | Bevy networking with snapshot interpolation of replicated components |
| Mirror | C# | MirrorNetworking/Mirror | Unity Package Manager / Asset Store | — | — | — | — | Snapshot interpolation listed as a stable feature |
| Netcode for GameObjects | C# | Unity-Technologies/com.unity.netcode.gameobjects | Unity Package Manager (com.unity.netcode.gameobjects) | — | — | — | — | NetworkTransform with interpolation |
| tf2 | C++ | ros2/geometry2 | apt 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