Lag compensation
I had Claude Code improve and bring into the engine the lag compensation I use in my project, it makes FPS and Chivalry/Mount and Blade weapon trail swing games playable and fair even with some clients having 200+ ms pings (overseas players). I use ENET in my case but I kept it transport layer agnostic here.
Networking: Lag Compensation + Physics Rollback
Transport-agnostic server-authoritative simulation layer (lag-compensation branch)
A commercial-grade networking substrate built from scratch under *Engine/Net/*. Two independent tiers of temporal correction, built using shipped-game references:
- Tier 1 — selective hitbox rewind (Photon Fusion 2 / Source / CS:GO model). Each server tick, every replicated actor contributes a flat list of hitbox samples into a 256-tick ring buffer (~4.27 s @ 60 Hz). Spatial queries take a *(tick, frac)* pair, linearly interpolate the two bracketing snapshots, and test against the interpolated hitboxes. The live gameplay scene is never touched — perfect for hitscan / melee where you only need “where was this limb when the client fired”.
- Tier 2 — full PhysX rewind + resimulation (UE5 networked-physics model). Snapshots pose / linear + angular velocity / kinematic / sleeping flags for every tracked *Actor* each tick; on demand restores them and drives *Physics.stepOnce(dt)* forward to catch up to the current tick. A *RollbackGuard* (TLS depth counter) is held active for the resim window so gameplay / animation / audio code that checks *RollbackGuard::active()* can suppress side-effects (event fire, notifies, SFX) during replay.
All modules are unconditionally compiled — no CMake flag, no opt-out, no extra link cost (the dependency surface is zero — pure C++, no third-party libraries). The transport layer is abstract: the engine ships *LoopbackTransport* (in-process queue) and *FakeLatencyTransport* (wraps any transport with configurable latency / jitter / loss), plus sketches for *ENetTransport* and a multi-hop *ForwardingTransport* in *Engine/H/Net/NetTransport.h* header comments. User projects subclass *INetTransport* and forward to ENet / Steam Sockets / WebRTC / whatever fits their deployment.
Module layout:
- *Net/NetTick.h/cpp* — *TickSeq* (*ULong*) + *NetTick{seq, frac}* shared tick + sub-tick type
- *Net/NetClock.h/cpp* — NTP-style clock sync; rolling 5-sample median-of-offset + RTT + jitter; monotonic server timeline
- *Net/NetTransport.h/cpp* — *INetTransport* abstract + *LoopbackTransport* + *FakeLatencyTransport* + ENet / multi-hop sketches
- *Net/NetObject.h/cpp* — *INetReplicated* interface, *NetBase* convenience, *NET_REGISTER_CLASS* factory, *HitboxSample*, *NetWriter* / *NetReader* byte-buffer codecs with opt-in *putUIntPacked* (varint) / *putPosQ16* (quantized position, 6 B vs 12 B) compression helpers
- *Net/InputCommand.h/cpp* — *InputCommand* POD + *InputBuffer* (client redundancy — re-sends last N unacked cmds) + *InputQueue* (server per-connection, wrap-safe *UShort* seq compare)
- *Net/NetReplicator.h/cpp* — spawn / despawn / snapshot build + apply / input routing / ping-pong / per-connection relevance callback hook
- *Net/LagCompensation.h/cpp* — Tier 1 256-tick hitbox ring; *ray* / *sweep* / *sweepArc* / *overlap* / *fetchInterp* CPU-analytic queries (sphere + capsule + box) with *(tick, frac)* OR *(real_time)* addressing for clustered servers
- *Net/RollbackGuard.h/cpp* — thread-local depth counter; *RollbackGuard::active()* callable from gameplay / anim code
- *Net/NetRollback.h/cpp* — Tier 2 snapshot / restore *Actor* state, *Physics.stepOnce*-driven resim, 8-tick default cap (user-configurable), *InputReplayFn* hook, *AnimCaptureFn* / *AnimRestoreFn* hooks for bone-sourced hitboxes
~3,200 lines of new engine code across 18 files, zero third-party links, zero *#ifdef*s outside platform branches.
Quick start (single-process, both sides in one *Update()* — this is how every category-20 tutorial is structured):
Code:
// --- Replicated actor ---
struct Target : NetBase
{
Vec pos;
NetClassID netClassId()C override { return 0x54475431u; /* 'TGT1' */ }
void netSerialize (NetWriter &w, UInt fields)C override { w.putVec(pos); }
void netDeserialize(NetReader &r, UInt fields) override { pos = r.getVec(); }
void netHitboxes(Memc<HitboxSample> &out)C override
{
HitboxSample &s = out.New();
s.shape = HITBOX_SPHERE;
s.group = 0x0001;
s.half_ext.set(0.3f, 0.3f, 0.3f);
s.xform.identity(); s.xform.pos = pos;
}
};
NET_REGISTER_CLASS(Target, 0x54475431u);
// --- Server ---
NetReplicator SvrRep; NetClock SvrClk; LagComp SvrLag;
SvrClk.initServer(60);
SvrRep.serverInit(&transport, &SvrClk);
each_tick:
SvrRep.serverDrainInputs(); // apply client inputs via INetReplicated::netApplyInput
// ...gameplay simulation...
SvrLag.recordTick(SvrRep, tick, Time.curTime());
if(tick % 3 == 0) SvrRep.sendSnapshots(tick); // 20 Hz @ 60 tick
on_client_fire(from, to, client_tick):
LagHit h;
if(SvrLag.ray(client_tick, 0.0f, from, to, h))
apply_damage(h.owner, h.owner_idx);
// --- Client ---
NetReplicator CliRep; NetClock CliClk; InputBuffer CliInputs;
CliClk.initClient(60);
CliRep.clientInit(&transport, &CliClk, /*server_conn*/1);
each_frame:
CliRep.sendPingMaybe(); // periodic NTP sync
CliRep.update(Time.curTime()); // drains spawn/despawn/snapshot/pong
Vec input = sample_keyboard();
pawn->predict(input); // local prediction on owned pawn
CliInputs.push(input, buttons, CliClk.tick());
CliInputs.sendTo(CliRep); // redundant — includes last N unacked
CliInputs.ack(CliRep.lastAckedSeq());
on_fire(from, to):
// Ship the compensation tick with the fire cmd so the server can rewind
// to exactly what this client was seeing.
fire_cmd.compensation_tick = CliRep.lastSnapshotTick();
Tier 2 (physics rewind) — only when you actually need resim; most games won't:
NetRollback SvrRoll;
SvrRoll.init(1.0f/60.0f, /*max_resim=*/8);
for(Actor *a : dynamic_physics_actors) SvrRoll.track(*a);
on_physics_step_completed(tick):
SvrRoll.recordTick(tick);
on_authoritative_correction(target_tick):
SvrRoll.rewindAndResimulate(target_tick, currentTick, replayInputs, user);
Physics engine touch points — two small, surgical additions; everything else is pure add-on:
- *Physics.stepOnce(Flt dt)* — direct *simulate* + *fetchResults* with no accumulator, no *simulation_step_completed* callback. This is what drives Tier 2 resim. Safe only between frames (after *stopSimulation()* or inside *WorldManager::physics_update()*).
- *Time.rollbackDt(Flt dt)* — thread-local override for *Time.d()*. When *> 0*, *Time.d()* returns it instead of the real frame delta. Set automatically by *NetRollback::rewindAndResimulate* per resim tick to the recorded *dt_used*, so animation code (*Motion::updateAuto*, *AnimatedSkeleton::animate*) replays at the exact dt the forward sim used — deterministic bone poses during rewind.
Animation sync hooks for bone-driven hitboxes — if your game's hitboxes are attached to *AnimatedSkeleton* bones (limbs parented to bone slots), both forward-sim animation and rollback-resim animation must reproduce the same per-tick bone poses. Two engine hooks pair them:
- Forward sim — set *Time.rollbackDt(icf.timeDeltaPhysics)* before animating each player tick, so *Motion::updateAuto* uses the physics dt instead of the variable frame dt. Restore with *Time.rollbackDt(0)*.
- Rollback resim — pass *NetRollback::setAnimCallbacks(cap, rest, user)*. *AnimCaptureFn* serialises every tracked *Motion* (via *Motion::save* or direct field writes) into a small opaque blob per tick (~64 B per motion × N motions × 256 ticks — fits easily). *AnimRestoreFn* does the inverse before the user's *InputReplayFn* fires, so replayed animation code starts from the correct *Motion.time* / *Motion.blend*. Pair this with *if(RollbackGuard::active()) return;* at the top of anim-event consumers (weapon clash SFX, hit sparks) so they don't re-fire during replay.
Includes 7 more tutorials.
in this fork:
https://github.com/DrewGilpin/EsenthelEngine
(This post was last modified: 04-22-2026 02:31 AM by Fex.)
|