One Shot, Three Different Pasts: How Lag Compensation Works
Why the shooter, the server, and the target disagree about a hit—and how historical hitboxes, interpolation, and bounded rewind help resolve it.
You line up a moving opponent, click, and miss. On their screen, they were already somewhere else. On the server, the shot arrived after they had moved again.
Which position should count?
That question sits at the heart of lag compensation. Hit registration is the process of deciding whether an attack connected. Lag compensation changes which moment of the world the server uses to make that decision.
Valve’s Source Multiplayer Networking and lag compensation documentation are classic starting points for this subject. This article follows that historical-hitbox approach, with original diagrams and a worked example. Specific engines make different choices; the numbers below are illustrative, not settings for any particular Valve game.
The same target, on the same map, at one wall-clock instant when the shooter clicks. Outlines are explanatory hitbox overlays, not boxes normally visible in the game. The other client here belongs to the target; its local prediction is illustrative, not a fixed amount ahead of the server.
Ping does not tell the whole story. A displayed ping commonly measures a round trip: client to server and back. An 80 ms round trip might mean roughly 40 ms each way, but the two directions need not be equal. Input sampling, packet scheduling, server processing, and rendering add their own waits.
There are also several different rates involved. Display frame rate controls how often you draw. Server tick rate controls how often the simulation advances. Snapshot rate controls how often clients receive authoritative updates. Raising one does not automatically raise the others.
Meanwhile, clients deliberately present different parts of the world at different times. Your own movement can be predicted immediately, then corrected when authority disagrees. Remote players are often rendered from buffered snapshots, interpolating between known positions to avoid jitter. That smoothness costs some delay. Gabriel Gambetta’s explanation of entity interpolation walks through this division between local responsiveness and remote motion.
So the shooter’s screen is a mixture: a responsive local camera looking at opponents whose displayed poses come from the past. The server cannot judge that view accurately by looking only at where everyone is now.
Consider a simplified encounter on a shared server-time axis. Assume a stationary shooter, steady target movement, 40 ms of network delay in each direction, and a renderer deliberately displaying the remote world 50 ms behind the newest arriving snapshot. Ignore tick scheduling and packet jitter for this example.
At 1,000 ms, the shooter clicks. The freshest arriving snapshot represents 960 ms; the interpolation buffer is showing the target at 910 ms. The fire command then travels to the server and is processed at 1,040 ms.
The target pose the shooter aimed at is now 130 ms old.
In this deliberately simple model, the historical query spans 40 ms of incoming snapshot delay, 50 ms of additional buffering, and 40 ms of outgoing command delay. Engines define their clocks and interpolation offsets differently, so this is not a universal “subtract ping” formula.
Suppose the target moves sideways at six metres per second. Over 130 ms, it travels 0.78 metres. A ray through the centre of its old hitbox can completely miss its current one. Nothing needs to be broken for that disagreement to happen.
Historical hit testing addresses the disagreement by keeping a short record of target poses. When a fire command arrives, the server validates the action, selects an allowed historical time, reconstructs the relevant collision geometry, and tests the shot there. A successful result changes the authoritative game state when the command is processed. Gambetta’s lag compensation article describes this approach and the resulting tension between the shooter’s view and the target’s view.
One ray, two possible query times. Rewind changes the geometry used to judge this shot; it does not mean the target visibly walks backward.
Choosing that time is more subtle than accepting whatever timestamp the client sends. The server needs a relationship between command time, its own clock, and the client’s rendering delay. It also needs limits on how far back a request can reach.
Valve’s published Source SDK 2013 implementation provides a concrete example: it accounts for view interpolation when selecting a target tick, checks that selection against a latency-based estimate, and bounds compensation with a maximum window. It can interpolate between history records. Those are details of that implementation, not a claim about every current Source or Counter-Strike release.
In irt.io, the corresponding building block is room.rewind(tick, fn). A room opts into a bounded physics pose history, then runs a synchronous query against a separate scratch world at a recorded tick. The query uses the chosen physics engine’s own ray or shape tests. The live simulation stays in place, and the game applies any resulting damage to its current state.
There is a useful limitation to understand: our ctx.clientTick records the newest authoritative tick the client has applied. It is not an exact timestamp of the interpolated frame the player saw. Rewinding to that tick gives a historical query, but does not automatically reproduce arbitrary render buffering or sub-tick animation. Games with fast targets or small hitboxes should measure that difference explicitly.
What gets recorded matters just as much as when. A character’s visual mesh, movement collider, and damage hitboxes can be different shapes. Recording the character’s position alone cannot reconstruct a crouch, a leaning torso, or an animated arm. Moving doors and destructible cover introduce the same question for the environment: which version of the obstruction belongs in this query?
Our rewind stores body poses rather than a complete history of collider shapes. Optional history channels can carry values such as stance or animation phase for game-specific hitbox selection. Static geometry is queried as it stands now. A game that needs historical doors, changing collision shapes, or detailed skeletal hitboxes must account for those explicitly; pose rewind alone does not reconstruct them.
This also explains why a screenshot of the crosshair is incomplete evidence. The visible model might not match the damage geometry. The shot origin may come from a muzzle rather than the camera. Spread, recoil, range, a wall, or an invalid firing state can change the result before latency enters the picture. A local muzzle flash confirms that the client played an effect; it does not establish that authority accepted a hit.
The hardest outcome to explain to a player is being hit after reaching cover. In the worked example, the server receives the shot at 1,040 ms but tests the target at 910 ms. If the target crossed behind a wall between those times, the historical shot can still connect. Delivery of the damage update takes more time again. The target can honestly remember reaching safety before seeing the hit.
That is a policy tradeoff. Testing only current positions asks shooters to compensate for a delay they cannot precisely observe. Testing historical positions allows some attacks against a target that has since escaped. A longer accepted window accommodates more delay while permitting older views to affect the present. It does not make high ping a universal advantage: delayed information and delayed confirmation still hurt the player using that connection.
The history buffer and the rules for accepting old shots are related, but distinct. Keeping a pose does not oblige the game to accept every request that names it. Validate firing rate, ammunition, shot origin, and command ordering as well as timing. A bounded history limits how old a query can be; it does not make client-supplied timing trustworthy.
In irt.io, requests outside the retained history are clamped to an available tick, and the query exposes past.requested, past.tick, and past.clamped. The game can inspect those values to decide how to handle the fallback. That decision deserves testing too: a clamped query is no longer answering at the originally requested moment.
Tick rate affects precision, but cannot remove network travel time. At 20 Hz, samples are 50 ms apart; at 60 Hz, they are about 16.7 ms apart. For our target moving at six metres per second, that is 30 cm versus 10 cm of movement between samples. Those distances are not automatic hit errors—historical interpolation may recover intermediate poses—but they show why sampling, timestamp precision, and animation reconstruction matter for small targets.
Nor does one historical ray solve every weapon. A hitscan attack is usually resolved as an instantaneous trace at a chosen time. A rocket travels, and other players can react during that travel. A projectile system must decide whether to start late, simulate some missed travel, or apply another compensation policy, including how collisions along that path are handled. Melee has its own timing and swept-volume questions. Reusing a hitscan rule without considering these differences can produce surprising results.
For debugging, record the shot’s command identifier, arrival time, requested and actual query ticks, validated origin and direction, historical hit geometry, and final acceptance or rejection reason. Draw the historical hitbox beside the current one. Test with asymmetric delay, jitter, packet loss, crouching, teleports, and changing cover. A stationary target on localhost barely exercises the problem.
The useful question behind “that should have hit” is: which pose did the shooter see, which pose did the server test, and why did it choose that moment? Once those three answers are visible, hit registration becomes something you can explain and measure.