Lag Compensation with Historian
In many server-authoritative games, each client has two useful timelines:
- A confirmed/interpolated view of remote server state, usually rendered
RTT/2 + interpolation_bufferbehind the server. - A predicted view for locally controlled entities, often running ahead of the last confirmed server tick.
Lag compensation cares about what the client was seeing when it acted. When a client fires a weapon, it sends the tick at which the shot was taken. By the time that packet arrives, the server has advanced. If the server tests the shot against the current world state, the target may have moved and the shot can miss even though it was visually accurate on the client.
The solution is rewinding the server world to the tick the client was
seeing, performing hit detection there, and then fast-forwarding back. naia’s
Historian is the rolling per-tick snapshot buffer that makes rewinding
possible. Among Rust game networking libraries, this is one of naia’s sharper
edges: the snapshot buffer is a library primitive instead of a pattern every
project has to reinvent in a slightly different, slightly haunted way.
Historian snapshot timeline
sequenceDiagram
participant C as Client (tick 47)
participant S as Server (tick 51)
Note over C: fires weapon — seeing tick 44's positions
C->>S: FireCommand(fire_tick=44)
Note over S: receives at tick 51
S->>S: historian.snapshot_at_tick(44)
S->>S: hit detection against positions from tick 44
S->>C: HitConfirmed / Missed
Enabling the Historian
#![allow(unused)]
fn main() {
// server startup — retain up to 64 ticks of history
// 64 ticks ≈ 3.2 s at 20 Hz, ≈ 1.1 s at 60 Hz
server.enable_historian(64);
}
The Historian is disabled by default. Call enable_historian once at startup
before the first tick runs.
Recording snapshots
#![allow(unused)]
fn main() {
// Inside your per-tick update, after game-state mutation,
// before server.send_all_packets():
server.record_historian_tick(&world, current_tick);
}
record_historian_tick clones every replicated component on every replicated
entity and stores the result keyed by (Tick, GlobalEntity, ComponentKind).
Old snapshots are automatically evicted once they exceed max_ticks age.
Warning: Record after mutation so the snapshot reflects the authoritative state for that tick. Recording before mutation captures the previous tick’s state and will cause off-by-one errors in hit detection.
Looking up a snapshot
#![allow(unused)]
fn main() {
fn handle_fire(server: &Server<E>, fire_tick: Tick) {
let Some(historian) = server.historian() else { return };
let Some(world_at_fire) = historian.snapshot_at_tick(fire_tick) else {
// Tick has been evicted — reject or use closest available
return;
};
// world_at_fire: &HashMap<GlobalEntity, HashMap<ComponentKind, Box<dyn Replicate>>>
for (entity, components) in world_at_fire {
if let Some(pos_box) = components.get(&ComponentKind::of::<Position>()) {
let pos = pos_box.downcast_ref::<Position>().unwrap();
// perform sphere/AABB hit test against `pos` ...
}
}
}
}
You can also query by elapsed time instead of by tick:
#![allow(unused)]
fn main() {
// Snapshot from ~150 ms ago, given 50 ms ticks
let snap = historian.snapshot_at_time_ago_ms(150, current_tick, 50.0);
}
snapshot_at_time_ago_ms converts the time offset to ticks, finds the closest
snapshot, and falls back to the oldest retained snapshot rather than returning
None when the offset is large.
Component filtering
By default the Historian clones every replicated component on every entity each tick. On a busy server this can be significant.
If your hit detection only needs Position and Health, use
enable_historian_filtered to limit snapshotting to those kinds:
#![allow(unused)]
fn main() {
server.enable_historian_filtered(
64,
[ComponentKind::of::<Position>(), ComponentKind::of::<Health>()],
);
}
Tip: Always use
enable_historian_filteredin production. Snapshotting only the components you query for reduces per-tick allocation by the ratio of (filter_size / total_components_per_entity).
Choosing max_ticks
max_ticks is the maximum lag (in ticks) you will compensate for:
| Tick rate | Target max lag | max_ticks |
|---|---|---|
| 20 Hz | 500 ms | 10 |
| 20 Hz | 3 s (generous buffer) | 64 |
| 60 Hz | 200 ms | 12 |
| 60 Hz | 500 ms | 30 |
Memory cost is roughly max_ticks × entity_count × filter_size × avg_component_size.
Caveats
- The Historian does not back-fill past snapshots when an entity is spawned; the entity first appears in the snapshot taken on the tick after spawn.
- Despawned entities disappear from the snapshot on the tick they are removed.
- naia does not re-apply the rewound snapshot to the live world — you query the historical data and perform hit detection logic yourself.
- Anti-cheat: reject fire commands whose
fire_tickis older thanmax_ticks. Without this check a malicious client can query arbitrarily old state.
Danger: Always clamp the look-back window server-side. Accept
fire_tickonly ifserver_tick - fire_tick <= max_ticks. A client that sends a very oldfire_tickcan otherwise causesnapshot_at_tickto return stale data or trigger unnecessary computation.