Audience: a Rust game developer who has read the README and is about to write their first multiplayer game with naia.
Both the server and the client must agree on the complete set of replicable
component types, message types, channel configurations, and protocol-level
settings. In naia this agreement is expressed as a Protocol value.
Conventionally you put Protocol construction in a shared crate:
use naia_shared::{Protocol, ChannelMode, ChannelDirection};
pub fn protocol() -> Protocol {
Protocol::builder()
.tick_interval(std::time::Duration::from_millis(40)) // 25 Hz
.add_component::<Position>()
.add_component::<Health>()
.add_message::<ChatMessage>()
.add_channel::<GameChannel>(
ChannelDirection::Bidirectional,
ChannelMode::OrderedReliable(Default::default()),
)
.build()
}Both the server and the client call this same function and pass the result to
Server::new / Client::new. naia derives a deterministic ProtocolId from
the registered types and channel configuration; a client whose ID does not
match the server's will be rejected during the handshake.
The shared crate typically contains:
Protocolconstruction- All
#[derive(Replicate)]component types - All
#[derive(Message)]/#[derive(Request, Response)]types - Custom
#[derive(Channel)]marker types
naia is ECS-agnostic. An entity is any value that satisfies
Copy + Eq + Hash + Send + Sync — for instance a u32, a bevy::Entity, or
a custom newtype. naia never allocates entities itself; the WorldMutType<E>
you pass to spawn_entity does.
Replicated components must derive Replicate:
#[derive(Replicate)]
pub struct Position {
pub x: Property<f32>,
pub y: Property<f32>,
}Property<T> is naia's change-detection wrapper. When a field inside
Property<T> is mutated the containing entity is marked dirty and the
diff is queued for transmission on the next send_all_packets call. Only
changed fields are sent — naia tracks per-field diffs for each in-scope user.
Every frame the server must execute these five steps in order:
receive_all_packets – read UDP/WebRTC datagrams from the OS
process_all_packets – decode packets; apply client mutations
take_world_events – drain connect/disconnect/spawn/update/message events
take_tick_events – advance the tick clock; collect elapsed tick events
(mutate replicated components here)
send_all_packets – serialise diffs + messages; flush to network
Why this order is mandatory:
receive_all_packetsfills the internal receive queue; nothing downstream can run until bytes are available.process_all_packetsconsumes that queue and converts bytes intoEntityEventobjects thattake_world_eventslater drains.take_world_eventsmust come afterprocess_all_packetsso that events produced by the latest batch of packets are visible this frame.take_tick_eventsmust come aftertake_world_eventsto avoid ordering anomalies between world-state events and tick-boundary events.send_all_packetsmust come last so that all mutations made during the current frame are included in the outbound batch.
The same five-step contract applies to the client, with the difference that the client processes packets from a single server connection rather than from many users.
Entity replication uses a two-level scoping model.
A user and an entity must share at least one room before replication is possible. This is the broad spatial or logical partition — a game "zone", a match instance, a lobby.
let room = server.create_room();
let room_key = room.key();
// …
server.room_mut(&room_key).add_user(&user_key);
server.room_mut(&room_key).add_entity(&entity);Within a shared room you can further restrict which entities replicate to which users. The canonical pattern is a visibility callback:
// In your game loop — call scope_checks_pending() for the incremental set,
// or scope_checks_all() for a full re-evaluation:
for (room_key, user_key, entity) in server.scope_checks_pending() {
let mut scope = server.user_scope_mut(&user_key);
if is_visible(entity, user_key) {
scope.include(&entity);
} else {
scope.exclude(&entity);
}
}
server.mark_scope_checks_pending_handled();The basic demo ships the x ∈ [5, 15] example: only entities whose x
coordinate is between 5 and 15 are included in a user's scope. Entities
outside that window are either despawned on the client (ScopeExit::Despawn,
the default) or frozen in place (ScopeExit::Persist).
All messages and entity actions are routed through typed channels. A channel
is a named type that derives Channel and is registered in the Protocol
with a ChannelMode and ChannelDirection.
| Mode | Ordering | Reliability | Typical use |
|---|---|---|---|
UnorderedUnreliable |
None | None | Fire-and-forget telemetry |
SequencedUnreliable |
Newest-wins | None | Position updates (drop stale) |
UnorderedReliable |
None | Guaranteed | One-off notifications |
OrderedReliable |
FIFO | Guaranteed | Chat, game events |
TickBuffered |
Per tick | Guaranteed | Client input (tick-stamped) |
| Bidirectional + Reliable | FIFO | Guaranteed | Requests and responses |
#[derive(Channel)]
pub struct PlayerInputChannel;
// In protocol builder:
.add_channel::<PlayerInputChannel>(
ChannelDirection::ClientToServer,
ChannelMode::TickBuffered(Default::default()),
)TickBuffered stamps every message with the client tick at which the input
occurred. The server buffers them and delivers them via
receive_tick_buffer_messages(tick) when the server tick matches. This
enables tick-accurate input replay and is the foundation of client-side
prediction.
Dynamic entities (the default) use per-field delta tracking. When any
Property<T> field changes, only the changed fields are sent to each in-scope
user on the next send_all_packets call.
Static entities skip delta tracking entirely. When a static entity enters a user's scope, naia sends a full component snapshot. After that no further updates are transmitted — static entities are assumed to be immutable for the lifetime of the session.
Create a static entity via the as_static() builder method:
server.spawn_entity(&mut world)
.as_static() // must be called BEFORE insert_component
.insert_component(tile);Use static entities for map tiles, level geometry, or any entity that is written once and never changes. They save significant CPU time by eliminating diff tracking.
A replicated resource is a server-side singleton that is automatically visible to all connected users, without room membership or scope management. Internally naia creates a hidden one-component entity to carry the value.
// Insert a dynamic (diff-tracked) resource:
server.insert_resource(&mut world, ScoreBoard::new(), false)?;
// Insert a static (immutable) resource:
server.insert_resource(&mut world, MapMetadata::new(), true)?;
// Remove it later:
server.remove_resource::<ScoreBoard, _>(&mut world);On the client:
if client.has_resource::<ScoreBoard>() {
let entity = client.resource_entity::<ScoreBoard>().unwrap();
// read component from world storage using entity
}Resources differ from ordinary entities in three ways:
- No room or scope configuration is needed.
- At most one resource per type can exist at a time (inserting a duplicate
returns
Err(ResourceAlreadyExists)). - They can be delegated just like entities by calling
configure_resource.
By default the server owns all component state. Delegation allows a client to take temporary write authority over a specific entity — while it holds authority its mutations replicate back to the server instead of the other way around.
Server marks entity Delegated (ReplicationConfig::delegated())
│
▼
Status: Available ←──────────────────────────────────────────┐
│ │
│ client calls entity_request_authority │
▼ │
Status: Requested │
│ │
├─── server denies ──────────────────► Status: Denied │
│ │ │
│ server grants │ release │
▼ └──────────┤
Status: Granted (client mutations replicate to server) │
│ │
│ client calls entity_release_authority │
▼ │
Status: Releasing ──────────────────────────────────────────►──┘
- The server may revoke authority at any time by calling
entity_take_authority. - The client never holds unrevocable ownership.
- Mutations from a client-authoritative entity should still be validated server-side before applying to authoritative game state. naia replicates what the client sends without validation.
// Server: mark entity as delegatable
server.spawn_entity(&mut world)
.insert_component(position)
.configure_replication(ReplicationConfig::delegated());
// Client: request authority (requires Granted EntityAuthStatus to mutate)
client.entity_mut(&mut world, &entity)
.request_authority();
// Server event loop — handle grant/deny:
for (user_key, entity) in events.read::<EntityAuthGrantEvent>() {
// The requesting client now has write authority.
}The server tick interval is configured in the Protocol:
Protocol::builder()
.tick_interval(Duration::from_millis(50)) // 20 Hztake_tick_events advances the tick counter. Each elapsed server tick
produces a TickEvent that triggers the game simulation step.
The client maintains two tick streams:
- Client tick (
client_tick) — the tick at which the client is sending, running slightly ahead of the server to account for travel time. - Server tick (
server_tick) — the server tick currently arriving at the client, behind the server's actual tick by RTT/2 + jitter.
Use client_interpolation() and server_interpolation() to compute the
sub-tick interpolation fraction [0.0, 1.0) for smooth rendering.
TickBuffered channels carry client input timestamped with the client tick.
The server delivers them via receive_tick_buffer_messages(tick), enabling
rollback-and-replay: apply the server's authoritative update, then replay
buffered client inputs on top. CommandHistory<M> stores the input history
for this purpose.
For a complete step-by-step walkthrough of the full prediction loop, see docs/PREDICTION.md.
When using the Bevy adapter (naia-bevy-client, naia-bevy-server), the
Client SystemParam and NaiaClientPlugin carry a generic type parameter T:
use naia_bevy_client::{Client, NaiaClientPlugin};
// Your marker type — a zero-sized struct, nothing more.
#[derive(Resource)]
pub struct MyClient;
app.add_plugins(NaiaClientPlugin::<MyClient>::new(client_config, protocol()));
fn my_system(client: Client<MyClient>) { … }Why does T exist?
Bevy applications sometimes run more than one naia client simultaneously (for
example, a split-screen game where each half is a separate session, or a relay
node that bridges two servers). The T phantom marker lets Bevy distinguish
the two Client SystemParams at compile time — they are different types and
therefore different Bevy resources, with no runtime overhead.
How to use it
- Declare a zero-sized marker struct, typically in your game crate:
#[derive(Resource)] pub struct GameClient;
- Pass
GameClientas the type parameter everywhere:NaiaClientPlugin::<GameClient>::new(…) Client<GameClient> // SystemParam NaiaClientConfig::<GameClient>::default()
- If you only ever have one client, the marker is still required — just pick any unit struct. The name does not matter to naia.
Single-client shorthand
For single-client apps the demo crates use a local struct Client; or simply
alias the plugin:
type AppPlugin = NaiaClientPlugin<MyClient>;T must satisfy Resource (a Bevy bound) plus Sync + Send + 'static (which
Resource already implies). A #[derive(Resource)] unit struct always
satisfies this.
naia's transport layer is pluggable. Two implementations ship out of the box:
| Target | Implementation | Socket type | Encryption |
|---|---|---|---|
| Native (Linux/macOS/Windows) | UDP datagram socket | transport_udp |
None — dev / trusted LAN only |
Browser (wasm32-unknown-unknown) |
WebRTC data channel | transport_webrtc |
DTLS (WebRTC spec) |
| iOS / Android (native) | — | Not yet supported | — |
| iOS / Android (via WebView) | WebRTC data channel | transport_webrtc WASM build in WKWebView / Android WebView |
DTLS |
| Steam relay | — | Not built-in; implement via the Socket trait |
— |
Security note:
transport_udpsends all packets as unencrypted plaintext. Use it for local development and trusted private networks only. For production native deployments on the internet, usetransport_quic(TLS 1.3, planned) or place the server behind a TLS proxy. SeeSECURITY.mdfor details.
iOS and Android: Native socket support is not yet implemented (blocked on
transport_quic providing a production-ready encrypted mobile transport). As a
workaround, the WASM client build runs inside WKWebView (iOS) or Android
WebView using the same transport_webrtc path as the browser target. Frameworks
such as Capacitor automate the WebView wrapper.
Steam relay: ISteamNetworkingSockets (Valve SDR) is not built-in. Because
the Socket trait is pluggable, a community crate can implement it without any
changes to naia core.
The Server and Client APIs are identical for both shipped transports — only
the Socket value passed to listen / connect differs:
// Native server:
server.listen(NativeSocket::new("0.0.0.0:14191"));
// Native client:
client.connect(NativeSocket::new("127.0.0.1:14191"));
// Browser client (wasm32-unknown-unknown):
client.connect(WebrtcSocket::new("https://myserver.example.com", 14192));For Wasm builds, enable the wbindgen feature on the socket crate and build
with wasm-pack or trunk. The protocol, channel config, and all game logic
are identical — only the entry point and socket type change.
LinkConditionerConfig simulates packet loss, latency, and jitter — useful
for testing replication robustness and prediction/rollback in a local dev loop
without a real bad network.
use naia_shared::LinkConditionerConfig;
// Build a custom profile:
let lag = LinkConditionerConfig::new(
100, // incoming_latency ms
25, // incoming_jitter ms (added or subtracted at random)
0.02, // incoming_loss (2% packet drop)
);
// Or use a named preset:
let lag = LinkConditionerConfig::poor_condition();
// Apply on the server socket (conditions inbound packets from clients):
server.listen(NativeSocket::new(&addrs, Some(lag.clone())));
// Apply on the client socket (conditions inbound packets from the server):
client.connect(Socket::new(server_url, Some(lag)));Named presets — all values are one-way (applied to the receiving side):
| Preset | Latency (ms) | Jitter (ms) | Loss |
|---|---|---|---|
perfect_condition() |
1 | 0 | 0% |
very_good_condition() |
12 | 3 | 0.1% |
good_condition() |
40 | 10 | 0.2% |
average_condition() |
100 | 25 | 2% |
poor_condition() |
200 | 50 | 4% |
very_poor_condition() |
300 | 75 | 6% |
The conditioner applies to incoming packets on whichever socket you pass it to. To simulate a bidirectional bad link, pass the same config (or different configs for asymmetric paths) to both the server and client sockets.
The local in-process transport (transport_local) used in the test harness
accepts the same config via hub.configure_link_conditioner(), enabling
loss/latency injection without a real UDP socket.
Property<T> is generic over any T: Serde. naia ships a set of compact
numeric types in naia_shared that reduce wire size compared to raw f32/u32:
| Type | Wire size | Use case |
|---|---|---|
UnsignedInteger<N> |
exactly N bits | health (0–255 → 8 bits), flags |
SignedInteger<N> |
exactly N bits | relative offsets |
UnsignedVariableInteger<N> |
1–N bits (varint) | counts that are usually small |
SignedVariableInteger<N> |
1–N bits (varint) | deltas that are usually near zero |
UnsignedFloat<BITS, FRAC> |
exactly BITS bits | positive position, speed |
SignedFloat<BITS, FRAC> |
exactly BITS bits | signed angle, velocity axis |
SignedVariableFloat<BITS, FRAC> |
1–BITS bits | per-tick deltas (often tiny) |
BITS is the total bit width; FRAC is the number of decimal digits of
precision retained.
Example — a quantized game unit:
use naia_shared::{Property, Replicate, SignedVariableFloat, UnsignedInteger};
// Tile position: i16 tile coords + sub-tile delta (variable-width float)
#[derive(Clone, PartialEq, Serde)]
pub struct PositionState {
pub tile_x: i16, // already compact at i16
pub tile_y: i16,
pub dx: SignedVariableFloat<14, 2>, // 14-bit max, 2 decimal digits
pub dy: SignedVariableFloat<14, 2>, // encodes near-zero deltas in ~3 bits
}
#[derive(Replicate)]
pub struct Position {
pub state: Property<PositionState>,
}Wrapping multi-axis state in a single Property<State> means one dirty-bit
covers all axes — the whole struct is sent or nothing is, which is correct for
coupled state and avoids partial-update edge cases.
Compared to Property<f32> × 4 (128 bits/tick), PositionState costs roughly
32 bits (2 × i16) + ~6–28 bits (variable delta) = 38–60 bits/tick when
typical sub-tile movement is small — a 2–3× wire reduction.
See benches/src/bench_protocol.rs for working examples of PositionQ,
VelocityQ, and RotationQ using these types in a real benchmark scenario.
naia is server-authoritative by design — a publicly reachable server holds all authoritative state, and clients connect to it. NAT traversal and peer-to-peer hole-punching are intentionally out of scope.
If you need P2P networking (e.g. browser-to-browser direct connections for a rollback-netcode fighting game), the recommended Rust/Wasm ecosystem tools are:
- matchbox_socket — async WebRTC data-channel signaling for P2P connections in native and Wasm targets.
- GGRS / bevy_ggrs — GGPO-style rollback netcode on top of matchbox; well-suited to deterministic simulations.
These libraries are complementary to naia: a game can use naia for server→client replication (lobby, leaderboard, world state) and GGRS for the fast-path P2P match simulation in parallel.
naia is a single-process authority. One server owns all entities it replicates; there is no built-in mechanism for multiple server instances to share state.
For games that need horizontal scaling (e.g. an open world split across geographic zones), the standard pattern is zone sharding at the application layer:
Zone A server (naia process) Zone B server (naia process)
owns entities in region A owns entities in region B
│ │
└───── coordination service ────────────┘
(your code: entity hand-off,
cross-zone messages, matchmaking)
Each zone server runs an independent naia instance. When a player moves between zones the application:
- Serializes the player's replicated state (your
Replicatecomponents) on the source server. - Sends the serialized state to the destination server via your coordination channel (Redis, gRPC, direct TCP — your choice).
- Despawns the entity on the source server (client gets a despawn event).
- Spawns the entity on the destination server and places the player's connection in the new room.
naia provides the per-process primitive (spawn_entity, rooms, scopes,
authority). Zone coordination is an application concern — all the information
you need to implement it is available through the public API.
By default every replicated entity competes equally for outbound bandwidth. The priority accumulator system lets you tilt that competition: entities with a higher gain accumulate priority faster and therefore tend to be replicated more frequently within the same bandwidth budget.
// On the server, after spawning an entity:
// 2× the replication frequency of a normal entity.
server.global_entity_priority_mut(entity).set_gain(2.0);
// ~25% of normal frequency — useful for background/ambient entities.
server.global_entity_priority_mut(entity).set_gain(0.25);
// Pause replication for this entity entirely (gain = 0.0).
server.global_entity_priority_mut(entity).set_gain(0.0);
// Per-user priority: replicate faster to the owner than to spectators.
server.user_entity_priority_mut(&owner_key, entity).set_gain(3.0);
server.user_entity_priority_mut(&spectator_key, entity).set_gain(0.5);The effective gain for a given user is:
global_gain × user_gain (both default to 1.0).
The send loop sorts all dirty entity bundles by their accumulated priority each
tick and drains them against the per-connection bandwidth budget
(BandwidthConfig::target_bytes_per_sec, default 512 kbps). Entities with
higher gain win bandwidth sooner; entities with gain 0.0 are never selected.
This replaces a separate per-entity "tick rate" with a continuous
priority-weighted allocation that automatically adapts to varying numbers of
dirty entities.
Read-only access is available via global_entity_priority and
user_entity_priority (returns EntityPriorityRef).
naia supports optional zstd packet compression on a per-direction basis.
Compression is configured via CompressionConfig in ConnectionConfig:
use naia_shared::{CompressionConfig, CompressionMode};
let compression = CompressionConfig::new(
Some(CompressionMode::Default(3)), // server → client, level 3
None, // client → server, uncompressed
);Three modes are available:
| Mode | When to use |
|---|---|
CompressionMode::Default(level) |
General use. Level −7 (fastest) to 22 (best ratio). Level 3 is a good starting point. |
CompressionMode::Dictionary(level, dict) |
Production. A custom zstd dictionary trained on real game packets achieves 40–60% better compression than the default dictionary on typical game-state delta data. |
CompressionMode::Training(n_samples) |
Dictionary collection mode. Run with this mode for one or two play sessions; naia accumulates packet samples internally. Extract the trained dictionary, then switch to Dictionary mode. |
Typical workflow for dictionary training:
- Set
CompressionMode::Training(2000)in your development build. - Run a representative play session (2000 packets ≈ a few minutes at 20 Hz).
- Extract the trained dictionary from the server's
CompressionEncoderand save it to a file (e.g.assets/naia_dict.bin). - Ship with
CompressionMode::Dictionary(3, include_bytes!("../assets/naia_dict.bin").to_vec()).
Compression applies to the full packet payload after naia's internal bit-packing and quantization. Use it when bandwidth is the primary constraint; skip it if CPU cost is more important than wire size.
Server::connection_stats(&user_key) and Client::connection_stats() return a
ConnectionStats snapshot computed on demand from internal ring buffers:
// Server side:
if let Some(stats) = server.connection_stats(&user_key) {
println!("RTT p50={:.0}ms p99={:.0}ms loss={:.1}% out={:.1}kbps in={:.1}kbps",
stats.rtt_p50_ms, stats.rtt_p99_ms,
stats.packet_loss_pct * 100.0,
stats.kbps_sent, stats.kbps_recv);
}
// Client side:
let stats = client.connection_stats();Fields:
| Field | Description |
|---|---|
rtt_ms |
Round-trip time EWMA in milliseconds |
rtt_p50_ms |
RTT 50th-percentile from the last 32 samples |
rtt_p99_ms |
RTT 99th-percentile from the last 32 samples |
jitter_ms |
EWMA of half the absolute RTT deviation |
packet_loss_pct |
Fraction of sent packets unacknowledged in the last 64-packet window (0.0–1.0) |
kbps_sent |
Rolling-average outgoing bandwidth in kilobits per second |
kbps_recv |
Rolling-average incoming bandwidth in kilobits per second |
Call connection_stats at most once per frame per connection (it performs a
small sort for the percentile computation).
BandwidthConfig sets the per-connection outbound target:
use naia_shared::BandwidthConfig;
// In ServerConfig / ClientConfig:
config.connection.bandwidth = BandwidthConfig {
target_bytes_per_sec: 32_000, // 256 kbps — tighter budget for mobile
};The default is 64 000 bytes/sec (512 kbps). The send loop accumulates a token
bucket of target_bytes_per_sec × dt each tick and drains it against the
priority-sorted dirty entity list. Entities that do not fit in the current
tick's budget carry their accumulated priority into the next tick.
When a client disconnects and reconnects, call client.connect(socket) again
after receiving the DisconnectEvent. naia restarts the full handshake sequence
and the server fires a new ConnectEvent for the user.
// Client — handle disconnect and schedule a reconnect:
for _event in events.read::<DisconnectEvent>() {
// Clear all server-replicated entities from your local world.
// naia does NOT do this automatically — the entities were despawned
// on the server side but your local Bevy world still has them.
for entity in replicated_entities.iter() {
commands.entity(entity).despawn_recursive();
}
replicated_entities.clear();
// Reconnect — naia will re-run the handshake.
// Add your own backoff timer if you want to avoid hammering the server.
client.connect(socket.clone());
}What naia handles automatically on reconnect:
- Full handshake re-negotiation and protocol hash check.
- Re-scoping: all entities currently in the user's rooms and scope will be
re-sent as fresh
SpawnEntityEvent+InsertComponentEventsequences. - Replicated resources: re-delivered as if the client is connecting for the first time.
What the application must handle:
- Despawning stale local entities from the previous session before or
immediately after reconnecting (the
DespawnEntityEventfor them was never sent — the connection dropped). - Any client-local state tied to the old session (auth tokens, predicted
entities,
CommandHistorybuffers). - Retry backoff. naia does not implement reconnection backoff; a simple
Timerresource in your game loop is sufficient.
In a server-authoritative game each client renders the world a little in the
past — typically RTT/2 + interpolation_buffer milliseconds behind the server.
When a client fires a weapon it sends the client tick at which the shot was
taken. By the time that packet arrives the server has advanced by another
RTT/2 ticks. If the server tests the shot against the current world state,
the target has moved and the shot misses even though it was visually accurate on
the client. This is the lag-compensation problem.
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.
// 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.
// 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.
Ordering matters: record after mutation so the snapshot reflects the
authoritative state for that tick. Recording after send_all_packets also
works but is one tick stale.
// client sends the tick at which it fired — stored in your FireCommand message
fn handle_fire(
server: &Server<E>,
shooter: GlobalEntity,
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 the shot 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:
// 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.
By default the Historian clones every replicated component on every entity each tick. On a server with 500 entities each carrying 8 components that is 4,000 allocations per tick — significant on a busy server.
If your hit detection only needs Position and Health, use
enable_historian_filtered to limit snapshotting to those kinds:
server.enable_historian_filtered(
64,
[ComponentKind::of::<Position>(), ComponentKind::of::<Health>()],
);Components not in the filter are simply absent from the snapshot; no error is returned when you query for them.
max_ticks is the maximum lag (in ticks) you will compensate for. A common
choice:
| 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 × component_count × avg_component_size. With filtering, use max_ticks × entity_count × filter_size × avg_component_size.
- 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 from the world manager.
- naia does not re-apply the rewound snapshot to the world — you query the historical data and perform hit detection logic yourself. The live world is never modified by a historian lookup.
- Server-side anti-cheat (clamping look-back to a reasonable bound) is the
application's responsibility. Reject fire commands whose
fire_tickis older thanmax_ticksto prevent clients from querying arbitrarily old state.