Deep dive
How it works
Parts:
- History ring — per entity, one hitbox state per tick (tick, position, orientation, stance, alive).
- Time validator — clamps the client-reported render time to
[now − max_unlag, now]. - Rewinder — interpolates the two entries around that time the way the client did.
- Hit test — ray or shape test against the rewound boxes and the current static world.
- Record: after every server tick, push each entity's hitbox state (tick, position, orientation, stance, alive flag) into its ring buffer.
- Client sends with each fire command the render time it was showing:
(interp_tick_a, interp_tick_b, fraction)or a float server time. Source computes it asserver_time − latency − interpolation; modern engines let the client report it and validate it. - Server on fire: clamp the time to the allowed window; for each candidate target (cheap broad-phase first), look up the two history entries bracketing the time and interpolate exactly like the client did.
- Ray-cast against the rewound hitboxes (and the current static world); pick the nearest hit.
- Apply damage in the present. Nothing is permanently rewound; implementations either move entities temporarily and restore them, or test against copies.
shooter's screen (t = now - 120 ms): server now:
target here X <- crosshair target has moved here -> X
server rewinds target 120 ms, tests ray at the old position -> hit
Code
#[derive(Clone, Copy, Default)]
pub struct Hitbox { pub tick: u32, pub center: [f32; 3], pub radius: f32, pub alive: bool }
/// Per-entity ring of the last N ticks of hitbox state.
pub struct History<const N: usize> { ring: [Hitbox; N] }
impl<const N: usize> History<N> {
pub fn new() -> Self { History { ring: [Hitbox::default(); N] } }
pub fn record(&mut self, h: Hitbox) { self.ring[h.tick as usize % N] = h; }
/// Hitbox at fractional tick `t`, interpolated like the client's entity interpolation.
pub fn at(&self, t: f64) -> Option<Hitbox> {
let t0 = t.floor() as u32;
let a = self.ring[t0 as usize % N];
let b = self.ring[(t0 as usize + 1) % N];
if a.tick != t0 || !a.alive { return None; } // overwritten or dead at that time
if b.tick != t0 + 1 { return Some(a); } // newest entry: no blend partner yet
let s = (t - t0 as f64) as f32;
let c = [0, 1, 2].map(|k| a.center[k] + (b.center[k] - a.center[k]) * s);
Some(Hitbox { center: c, ..a })
}
}
/// Distance along a normalized ray to the sphere, if hit.
fn ray_sphere(o: [f32; 3], d: [f32; 3], c: [f32; 3], r: f32) -> Option<f32> {
let oc = [c[0] - o[0], c[1] - o[1], c[2] - o[2]];
let along = oc[0] * d[0] + oc[1] * d[1] + oc[2] * d[2];
let d2 = oc.iter().map(|x| x * x).sum::<f32>() - along * along; // squared distance to the ray
if along < 0.0 || d2 > r * r { return None; }
Some(along - (r * r - d2).sqrt())
}
pub struct Shot { pub shooter: usize, pub origin: [f32; 3], pub dir: [f32; 3], pub view_tick: f64 }
/// Returns (target index, distance) of the closest compensated hit.
pub fn resolve_shot<const N: usize>(
shot: &Shot, histories: &[History<N>], now_tick: u32, tick_rate: f64, max_unlag_s: f64,
world_hit_dist: f32, // distance to the first static obstacle along the ray, tested in the present
) -> Option<(usize, f32)> {
let oldest = now_tick as f64 - max_unlag_s * tick_rate;
// Clamp to the allowed window: a client cannot claim it saw the far past or the future.
let t = shot.view_tick.clamp(oldest.max(0.0), now_tick as f64);
let mut best: Option<(usize, f32)> = None;
for (i, h) in histories.iter().enumerate() {
if i == shot.shooter { continue; }
let Some(hb) = h.at(t) else { continue };
if let Some(dist) = ray_sphere(shot.origin, shot.dir, hb.center, hb.radius) {
if dist < world_hit_dist && best.map_or(true, |(_, b)| dist < b) { best = Some((i, dist)); }
}
}
best
}
#[test]
fn hits_where_the_shooter_saw_it() {
let mut h: Vec<History<64>> = (0..2).map(|_| History::new()).collect();
for tick in 0..=100u32 {
let x = tick as f32 * 0.1; // target 1 strafes along +x at 6.4 m/s (0.1 m per tick at 64 Hz)
h[1].record(Hitbox { tick, center: [x, 0.0, 10.0], radius: 0.4, alive: true });
h[0].record(Hitbox { tick, center: [0.0, 0.0, 0.0], radius: 0.4, alive: true });
}
// Shooter aimed at x = 9.2 (tick 92), shot processed at tick 100 when the target is at x = 10.
let d = 10.0f32.hypot(9.2);
let shot = Shot { shooter: 0, origin: [0.0; 3], dir: [9.2 / d, 0.0, 10.0 / d], view_tick: 92.0 };
assert_eq!(resolve_shot(&shot, &h, 100, 64.0, 1.0, f32::INFINITY).map(|x| x.0), Some(1));
let late = Shot { view_tick: 100.0, ..shot };
assert!(resolve_shot(&late, &h, 100, 64.0, 1.0, f32::INFINITY).is_none()); // uncompensated miss
}
Complexity and constants
- Memory: entities × ticks × hitbox size. 100 players × 64 ticks × (19 bones × 32 B) ≈ 3.9 MB; spheres or capsules per player are 100x smaller.
- Per shot: broad-phase against current bounds inflated by max speed × rewind time, then O(1) lookups and ray tests for candidates; order of µs.
- Rewind amount actually needed: shooter's one-way latency + interpolation delay, typically 30-150 ms; caps of order of 200-300 ms are common,
sv_maxunlag1 s is the Source upper bound.
Efficiency
Cost: memory is entities × ticks × hitbox size: 100 players × 64 ticks × (19 bones × 32 B) ≈ 3.9 MB; spheres or capsules per player are about 100x smaller. Per shot, a broad-phase against bounds inflated by max speed × rewind time, then O(1) history lookups and ray tests for the few candidates: order of µs. Bound: memory for history, CPU only per shot.
Levers:
- Capsule or sphere hitboxes instead of per-bone boxes — about 100x less history (see above) — when headshot precision is not needed.
- Test against history copies instead of moving real entities — resolves shots in parallel — many shots per tick.
- Cap rewind at order of 200-300 ms — bounds both memory and victim unfairness (
sv_maxunlagis 1 s in Source) — high-ping players. - Higher tick rate and lower interpolation delay — shrinks peeker's advantage (Riot, VALORANT) — competitive modes.
Naive → standard → state of the art:
- Lead your target by your ping · no server cost, hitscan unplayable at high ping
- Rewind hitboxes to the shooter's render time from a tick-indexed ring · MBs of history, µs per shot
- Same, plus validated client time and per-player caps against backtracking · adds one comparison per shot
Implementation notes
- Rewind exactly what the client rendered: same interpolation delay, same blend (linear vs Hermite), same animation pose for per-bone hitboxes. Mismatch shows up as "I hit him on my screen".
- Record after the tick's simulation, keyed by tick; never index by wall-clock time.
- Rewinding the world for every shot by moving real entities is simple but serializes; testing against history copies lets you resolve shots in parallel.
- The shooter's own position is not rewound: their predicted position at fire time is authoritative within movement validation.
- Validate client-reported view time against the server's estimate of its latency + interpolation (± tolerance); cheaters send old times to hit targets that already took cover ("backtracking").
- High-ping fairness: shrink the compensation beyond a threshold so a 300 ms player cannot shoot people far behind cover.
Pitfalls
- Shot behind the corner: the victim reached cover, then dies. Inherent; limit with caps and keep interpolation delay small.
- Peeker's advantage: the peeker sees the defender before the defender's client receives the peeker's movement (Riot measured and tuned this for VALORANT). Lower it with higher tick rates, lower interpolation delay and lower RTT.
- Rewinding only positions but not crouch/animation state: headshots on standing players who already crouched.
- Lag-compensating projectiles by rewinding the target for the whole flight: spawn the projectile forward-simulated by the shooter's latency instead.
- Ring buffer smaller than
max_unlag × tick_rate: valid lookups silently fail and shots miss.
Variants and alternatives
In production
Valve Source and Source 2 (CS2, Team Fortress 2), Overwatch, VALORANT, Call of Duty, Unreal Engine games (custom "rewind" components), Halo.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| lightyear | Rust | cBournhonesque/lightyear | cargo add lightyear | — | — | — | — | Lag compensation so predicted entities can hit interpolated ones |
| Mirror | C# | MirrorNetworking/Mirror | Unity Package Manager / Asset Store | — | — | — | — | Lag compensation listed as beta: rolls back state to what the player saw |
Sources
- Bernier, Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization, GDC, 2001. page
- Valve Developer Community, Lag Compensation and Source Multiplayer Networking. page · page
- Riot Games Technology, Peeking into VALORANT's Netcode, 2020. post
- Ford, Overwatch Gameplay Architecture and Netcode, GDC, 2017. talk