home / blog / network
Published: 30 June 2026 13 min read network
Lag Compensation: Why Your Perfect Shot Did Not Count

Lag Compensation: Why Your Perfect Shot Did Not Count

How server-side lag compensation is actually built for shooters: the rewind buffer, the numbers that tune it, and the point where the window has to stop.

Three hits that vanished in playtest

Back in February we were running an internal playtest of a 32-player shooter prototype. One tester connected from Frankfurt with an RTT of 78 ms, and after every round he typed the same line: two shots on the head, neither counted. The server logs backed him up. At the moment he fired, the target sat dead centre on his screen, while in the world the server knew about it was 1.4 metres to the left.

In multiplayer game development this is not a bug, it is what latency does. The player always sees the past, the server only knows the present. Any shooter that does not close that gap punishes high-ping players systematically. The penalty scales with ping, and the player reads it as their own bad aim rather than a network problem.

The annoying part is that it never looks like a defect. Nobody files a crash report, nobody writes repro steps; players just say the game feels bad and leave. Until you measure it, all you own is the complaint text.

So the first job was turning that complaint into a number. We added a small server-side log that records which actors each shot ray passed through. For players above 40 ms of latency, 12 percent of shots intersected nothing at all on the server; for players on the local network the same figure was 2 percent. Those ten points were pure uncompensated latency.

Why the server keeps a hitbox history

The fix is lag compensation, built on server rewind. Every tick the server writes each character's hitboxes into a ring buffer, a record we called FHitboxSnapshot and managed inside LagCompensationComponent.cpp. When a shot packet arrives, the server rewinds those hitboxes to the moment that player's screen was showing, runs the ray test there, and immediately restores the present.

The buffer holds 64 ticks, which at a 60 Hz simulation is 1066 ms of history. A snapshot costs 18 capsules times 32 bytes, so 576 bytes per player, and with 32 players over 64 ticks that adds up to 1.1 MB. One megabyte per server instance is nothing next to what the missing hits cost.

During the rewind we do not move the whole world, only the actors whose volume can intersect the ray. A swept FBoxSphereBounds pre-filter dropped the number of rewound actors in a 32-player match to 2.3 on average. The first version, which restored everyone, spent 0.9 ms per shot; after the filter it was 0.08 ms.

When you take the snapshot matters as much as what you store. Our first version captured it at the start of the tick, before animation had updated, so on a sprinting target the arm and head capsules lagged one frame behind the mesh. Moving the capture into the PostUpdateWork tick group removed a systematic 6 to 9 cm offset on fast-moving targets.

LagCompensationComponent.cpp
// Rewinds every relevant hitbox to the shooter's view of the world. bool ULagCompensationComponent::RewindAndTrace(const FShotRequest& Shot, FHitResult& OutHit) { // rewind = RTT/2 + client interpolation buffer, clamped on the server const float Rewind = FMath::Clamp( 0.5f * GetMeasuredRtt(Shot.Shooter) + InterpolationDelay, 0.0f, MaxRewindSeconds); // MaxRewindSeconds = 0.200f const int32 Tick = ClampRewindTick(Shot.Tick, Rewind); if (Tick == INDEX_NONE) { // Requested tick sits outside the trusted window: reject and log it. ReportSuspiciousRewind(Shot.Shooter, Shot.Tick); return false; } // Only actors whose swept bounds touch the ray are worth restoring. TArray<AActor*, TInlineAllocator<8>> Candidates; GatherCandidates(Shot.Origin, Shot.Direction, Candidates); for (AActor* Actor : Candidates) Snapshots[Actor].ApplyAt(Tick); const bool bHit = TraceShot(Shot, OutHit); for (AActor* Actor : Candidates) Snapshots[Actor].RestoreCurrent(); return bHit; }
cppLagCompensationComponent.cpp

The RTT and interpolation delay math

How far you rewind is not decided by ping but by the sum of two terms: one-way latency and the client's interpolation buffer. The formula we use is rewind = RTT / 2 + interpolationDelay. Forgetting the second term is the most common mistake I run into here.

A client deliberately delays incoming snapshots so it can interpolate between them smoothly. If you send snapshots at 20 Hz, the safe buffer is two packets, so 100 ms. For our 78 ms tester the correct rewind was 39 + 100 = 139 ms; the first version rewound only 39 ms, and that alone explained the vanished hits.

Never take that number from the client. A modified client can inflate its own interpolationDelay to demand a shot resolved even further in the past. We use the RTT the server measures itself plus a fixed buffer derived from the snapshot rate that client subscribed to; the only thing the client sends is the tick of the shot, and even that gets clamped.

RTT is not a constant either, it is a noisy series. Instead of a single sample we average the 25th to 75th percentile of the last 20 pongs; one spike used to push the rewind to 300 ms and make hit results feel random. On connections with more than 15 ms of jitter we round the result down rather than up, because the cost of over-rewinding is paid by the player being shot at.

Where the rewind window has to stop

We capped ours at 200 ms. Below that threshold hit registration behaves the way players expect; above it, latency turns into an advantage and you start killing targets that already broke the corner and are standing behind a wall.

The complaint of dying behind cover is the direct price of the compensated player's gain. Zero compensation punishes the high-ping player, unlimited compensation punishes the low-ping one. For us 200 ms was where the two complaint curves crossed: at 250 ms the cover complaints doubled, at 150 ms the hit rate of overseas players fell by 6 percent. Keep the value in configuration, because map scale and projectile speed both move that crossing point.

The window is also an attack surface. If the tick comes from the client, a cheating client can send a tick from three seconds ago and shoot the target's old position. ClampRewindTick() checks both the absolute limit and a moving average built from that client's last 32 packets; if the deviation exceeds 60 ms the request is rejected and the event is logged.

Then there is the shot that lands after death. In the rewound world the target is alive, in the present world it died 40 ms ago. We accept that: if the shooter was alive when the packet reached the server, the hit stands. The opposite rule turns every bullet a high-ping player fires into an invisible dice roll.

How it relates to prediction and reconciliation

Lag compensation does not stand on its own; it has to share a timeline with client prediction. If the client is predicting its own movement for tick N while the server is processing tick N-8, the tick used for the rewind has to account for that offset. Bake that offset in as a constant and compensation drifts the moment server load rises, with nobody able to say why.

In our build both run off a single counter. UPredictedMovementComponent stamps a tick number on every input, the same number rides along in the shot packet, and during server reconciliation the server uses it to validate movement and to rewind hitboxes. In an earlier attempt with two separate time sources, the one-to-two tick drift produced roughly 30 cm of aiming error during fast strafes.

Do not confuse any of this with rollback netcode. Rollback rewinds and replays the entire simulation, which is affordable in a fighting game. In a shooter we roll back hitboxes only; physics, projectiles and game state keep moving forward. When we tried full rollback with 32 players the server tick went from 4.1 ms to 19 ms, which ended the discussion.

When the server answers is part of the same chain. You cannot unplay a tracer the client has already drawn for a shot the server rejects, so we send hit confirmation in its own small packet and only draw the hitmarker once the server has agreed. On a 78 ms connection that means the marker arrives 40 ms late, which still generates far fewer complaints than a marker that lies.

Where to start and what to measure

The first thing to build is a diagnostic you can look at with your own eyes. A DrawDebugCapsule layer that draws both the present and the rewound hitboxes at the moment of a hit showed me more than any of my guesses: about 70 percent of the missing hits came from ignoring the interpolation buffer, and the rest from hitboxes binding to the animation pose one frame late.

After that, test under synthetic latency. With clumsy we add 60, 120 and 250 ms of one-way delay and replay the same scripted shooting scenario 200 times; if the hit rate stays inside a 3 percent band across all three profiles, compensation is doing its job. Put that run in the pre-release checklist, because any change to movement code can break it.

Store every rejected rewind request together with the account that sent it. That log surfaces four or five accounts a month for us, and not one of them had been caught by client-side detection. Bounding the window does not only keep the game fair, it turns cheat detection into something you read off data instead of guessing at.

One last note: explaining the compensation window to players worked better than hiding it. Since we added a single line on the death screen showing the killer's latency, the cover complaints did not get fewer, but their tone changed. People stopped assuming it was cheating.

← All posts