Hitreg Lab
A first-person test range in Unreal Engine 5 and C++ for how shots feel and how they register. Every system is measured over a dedicated server with two clients, not just built.
- Engine
- Unreal Engine 5.8, C++ (game module and an editor module)
- Dates
- Nov 2026
- Tests
- 13 headless automation tests, plus three measurement harnesses that run on a dedicated server
- Code
- github.com/HamoodiM/HitregLab
Worth a look
- The results: hit registration with and without lag compensation, and peeker's advantage across tick rate and latency.
- The shot pipeline: prediction, the client's view time, and the server rewind.
- Three bugs the measurements caught.
What's in it
- Weapons as data assets: fire mode, RPM, damage falloff, spread bloom, recoil patterns and ADS. Firing is frame-rate independent: a fire clock works out which shots fall inside each frame at their exact times.
- Client-predicted firing. The shooter sees the hit marker and tracer immediately. The server validates fire rate, origin and timestamps, then confirms or retracts the marker.
- Lag compensation. The server records every character's pose each frame and traces shots against hitboxes rewound to the moment the shooter saw, capped at 200 ms.
- Controller aim assist: a radial dead zone and response curve, slowdown near targets, rotational pull that only acts while you're giving input, and bullet magnetism. An editor tab tunes it live and benchmarks it.
- A peeker's-advantage meter and a hit-registration bench that run unattended on a dedicated server.
- A kill cam: the last few seconds replayed from the killer's eyes, with scrubbing and slow motion. Replays save to a versioned file for bug reports.
Results
For the lag compensation bench, a client aims perfectly at a strafing target as it draws it and fires single taps. The chart shows how many of those on-screen hits the server confirmed. With compensation, every one registered up to 150 ms round trip. Without it, 68% registered even at 0 ms, because remote players are drawn about two ticks in the past, and 23–30% at real pings. At 250 ms the 200 ms rewind cap kicks in on purpose, so nobody can be shot more than 200 ms after reaching cover.

For the peeker's-advantage meter, a scripted peeker strafes past a corner while a holder watches it. Each client reports, in synced server time, when its own screen first showed the other player. The gap is the peeker's advantage: 160 trials across round trip, tick rate and interpolation delay. Lag compensation doesn't remove it. It decides who wins a trade of shots, not who sees whom first. Higher tick rates and a shorter interpolation delay help, but latency dominates.

The shot pipeline
Remote players are drawn from timestamped server snapshots at a fixed delay behind the newest snapshot that can have arrived. Because the client knows exactly when it is drawing them, it can send that time with each shot:
Source/HitregLab/Core/HLCharacter.cpp, lines 283–289// Snapshots stamped at server time T arrive here at about T + one-way latency, so the newest data we can
// have is (Now - RTT/2). Render a fixed interpolation delay behind that, so there's always a pair to blend.
const AHLGameState* GS = World->GetGameState<AHLGameState>();
const AHLPlayerController* PC = Cast<AHLPlayerController>(World->GetFirstPlayerController());
const int32 InterpTicks = PC ? PC->InterpTicks : 2;
const double OneWay = (PC && PC->Clock) ? PC->Clock->GetBestRttMs() / 2000.0 : 0.0;
return Now - OneWay - (GS ? GS->GetInterpDelaySeconds(InterpTicks) : 0.05);The server clamps that view time to the rewind cap and tests each character's hitboxes at the pose it had then. Hitboxes are spheres and boxes tested with plain ray maths, so rewinding means evaluating an old pose, not teleporting colliders. A shot costs tens of microseconds, and most of that is the world trace.
Source/HitregLab/Net/HLLagCompensationSubsystem.cpp, lines 81–112const double TraceTime = bRewind ? FMath::Clamp(ViewTime, Now - MaxRewind, Now) : Now;
...
for (TActorIterator<AHLCharacter> It(World); It; ++It)
{
AHLCharacter* Target = *It;
if (Target == Shooter || !Target->IsAlive())
{
continue;
}
FHLPose Pose = Target->GetPose();
if (bRewind && TraceTime < Now)
{
GetPoseAt(Target, TraceTime, Pose);
}
FHLHitboxHit Hit;
if (HLHitboxMath::RayHitboxes(Origin, Dir, Limit, Target->Hitboxes, Pose, Hit) && ...)The server doesn't trust the client's fire rate. A token bucket refills at the gun's rate plus 10% and holds up to three shots, so packets that arrive bunched together pass, but a modified client can't out-fire the gun. An automation test feeds it a client firing at double rate.
Source/HitregLab/Weapons/HLWeaponDefinition.h, lines 174–187bool TryConsume(double Now, float ShotsPerSecond)
{
if (LastTime >= 0.0)
{
Tokens = FMath::Min(Burst, Tokens + float(Now - LastTime) * ShotsPerSecond * 1.1f);
}
LastTime = Now;
if (Tokens < 1.f)
{
return false;
}
Tokens -= 1.f;
return true;
}The client's clock is its own NTP-style estimate: it pings the server and keeps the offset from the lowest-round-trip sample. In the same runs, the engine's built-in game-state clock was off by 40–70 ms, which is enough to rewind to the wrong place.
Three bugs the measurements caught
- Moves that never reached the server. I turned off the engine's replicated movement so remote players would only be drawn from my snapshots. The peek test showed the peeker moving on its own screen but never on the holder's. The engine's character movement only sends a client's moves to the server when the actor replicates movement. The fix: keep it on, and have remote players ignore the engine's replicated position.
- Drawing the future. Remote players were first drawn at
server time − interpolation delay. That is ahead of the newest snapshot a client can have, so they just froze on the latest one. The rewind test caught it once its client buffer only held snapshots that would actually have arrived. - A kill cam filmed from the wrong eyes. The first demo's kill cam jumped between viewpoints. Unreal assigns player ids only to player controllers, so every bot had id 0. The recorder keyed its tracks by player id, so the bots merged into one track. Bots now take ids from the game session, the recorder refuses to merge two characters, and each kill cam logs how far the killer's recorded aim was from the victim at the kill: 0.1 to 0.3 degrees after the fix.
Aim assist, measured
A scripted thumb (200 ms reaction delay, tremor, stick saturation) tries to put the crosshair on a strafing target's head 20 m away. Over 50 seeded trials, full assist cut the median time to acquire from 1408 to 1308 ms and raised tracking from 11% to 16%. It's a modest effect, and the point is that it's measured. The first version of the thumb never acquired in either mode; it needed to account for its own movement since the frame it was reacting to, which is how people aim too.
Kill cam

Each client records what it displayed (every character at 30 Hz, plus tracers) in an 8-second ring, quantized to 16 bytes per pose: about 670 bytes per second per player. On death, the last few seconds play back from the killer's eyes, with slow motion around the kill. Recording on the client costs no bandwidth. The trade-off is that the clip shows the victim's view of the killer, not the killer's own screen. A save/load round trip is tested to reproduce identical frames, and a file from a newer format version is refused rather than misread.
How I built this
I directed the project: what to build, what to measure and how to read the results. Claude Code wrote the implementation as my AI coding assistant, and the commit history says so. The walkthrough in the repo (docs/WALKTHROUGH.md) is how I check that I can explain every system.