Home › Multiplayer & game networking › Latency hiding
algorithm medium

Lag compensation

Problem In an online shooter, players aim at where enemies appear on their screen, but by the time the shot reaches the server the targets have moved, so accurate shots miss. The server must judge hits fairly despite latency.

The server rewinds other players' hitboxes to the moment the shooter saw them, so hits land where the shooter aimed despite latency and interpolation delay.

per shot O(targets) rewinds (O(1) each from a tick-indexed ring) plus ray tests; memory O(entities × history ticks)
hit-detectionrewindfavor-the-shooterfpshistory-buffer
Overview

A client sees enemies where they were about one-way latency + interpolation delay ago. Without compensation you must lead targets by your ping. The server keeps a short history of every hitbox, and when a shot command arrives it moves targets back to the time the shooter was rendering, tests the ray there, then restores them. "Favor the shooter": what you saw is what you hit.

Key facts

Use when

  • Hitscan weapons and melee in client/server shooters with interpolated remote players.
  • Any "did the player click on it" check against moving targets (abilities, interactions).

Avoid when

  • Lockstep/rollback games: everyone shares the same timeline.
  • Very high pings: cap compensation and make the high-ping player lead, otherwise everyone else suffers.

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.
  1. Record: after every server tick, push each entity's hitbox state (tick, position, orientation, stance, alive flag) into its ring buffer.
  2. 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 as server_time − latency − interpolation; modern engines let the client report it and validate it.
  3. 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.
  4. Ray-cast against the rewound hitboxes (and the current static world); pick the nearest hit.
  5. 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_maxunlag 1 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_maxunlag is 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

No compensation (lead your target)Quake 1-era; fair to victims, terrible for hitscan.
Client-side hit detection with server validationthe client reports hits, the server sanity-checks distance, line of sight and timing (Battlefield-style); cheaper, easier to cheat.
Projectile forward simulationcompensate projectiles by advancing them by the shooter's latency at spawn.

In production

Valve Source and Source 2 (CS2, Team Fortress 2), Overwatch, VALORANT, Call of Duty, Unreal Engine games (custom "rewind" components), Halo.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
lightyearRustcBournhonesque/lightyearcargo add lightyear————Lag compensation so predicted entities can hit interpolated ones
MirrorC#MirrorNetworking/MirrorUnity 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

Builds on

Used by

Systems (3):

Alternatives and related