Introduction
naia is an entity replication and typed message-passing library for multiplayer games in Rust. Its default architecture is server-authoritative, but it also supports opt-in client-authoritative entities and delegated authority when your game needs a more flexible ownership model.
The same naia server can accept native and browser clients over WebRTC from a single shared protocol and game-code path. UDP is still available for native development and trusted networks, but WebRTC is the transport most users should reach for first.
What naia is
naia lets you define a shared Protocol — a compile-time list of replicated
component types, message types, and channel configurations — that both the server
and the client agree on. Given that protocol:
- The server usually spawns entities, attaches replicated components, assigns users to rooms, and lets the adapter flush packets every tick. naia diffs changed fields and delivers them to every in-scope client automatically.
- The client receives entity spawn/update/despawn events and the current server-side field values with no extra bookkeeping.
- Either side can send typed messages over ordered-reliable, unordered-reliable, or unreliable channels.
- Clients can create their own replicated entities when the protocol explicitly enables client-authoritative entities.
- The server can delegate authority over a specific entity to a client, allowing client mutations to flow back to the server while the server retains final ownership.
naia is ECS-agnostic at its core. The Bevy adapter is the most polished path, macroquad works through the core client, and custom engines can integrate by implementing naia’s world access traits.
The internal networking model follows the Tribes 2 Networking Model.
Why naia stands out
Among Rust game networking libraries, naia stands out for:
- One server, native and Wasm clients —
transport_webrtcworks for native andwasm32-unknown-unknownclients at the same time, using the same protocol and gameplay code. The browser is not a second-class citizen; it gets a chair at the adult table. - A complete replication model — server-owned, client-owned, delegated, public/private publication, static entities, replicated resources, rooms, and per-user scope are all part of the same system.
- Built-in lag compensation — the
Historiansnapshots the world each tick so you can rewind to the tick the client was seeing for server-side hit detection. - Per-entity bandwidth control — set priority gain per entity per user; the token-bucket send loop allocates bandwidth proportionally.
- Bevy-first ergonomics without Bevy lock-in — Bevy users get plugins, commands, and replicated resources; non-Bevy users still get the same protocol, transport, replication, and message machinery.
- Typed everything — messages, requests/responses, channels, replicated components, resources, and auth payloads all go through the shared protocol.
Crate map
| Crate | Role | Use when… |
|---|---|---|
naia-shared | Protocol definition, derives, channel types | Writing the shared protocol crate |
naia-server | Core server | Writing a server without Bevy |
naia-client | Core client | Writing a client without Bevy or macroquad |
naia-bevy-shared | Bevy protocol/resource/component helpers | Writing a Bevy shared crate |
naia-bevy-server | Bevy server adapter | Using Bevy on the server |
naia-bevy-client | Bevy client adapter | Using Bevy on the client |
naia-metrics / naia-bevy-metrics | Optional diagnostics integration | Exporting runtime metrics |
Quick concepts
- Protocol — the shared type registry. Both server and client build from the same
Protocolvalue; a hash mismatch during the handshake causes rejection. - Entity — a world object that naia can replicate after it has been
registered with the replication layer. In Bevy, that means calling
enable_replication(). - Component — replicated state attached to an entity. Fields wrapped in
Property<T>are delta-tracked. - Resource — a replicated singleton value, represented internally as a hidden one-component entity.
- Message — a typed payload sent over a channel. Messages are not delta-tracked; they are serialized each time they are sent.
- Room — a coarse membership group. A user and an entity must share a room before replication is possible. Think: match, zone, lobby.
- Scope — a per-user fine-grained visibility decision applied after rooms.
- Channel — a named transport lane with configurable ordering and reliability. Messages and entity actions travel through channels.
- Tick — the server’s heartbeat.
take_tick_eventsadvances the tick counter. - Client-authoritative entity — an opt-in entity created and owned by a client, replicated to the server, and optionally published to other clients.
- Authority delegation — a server entity can be marked
Delegated, allowing a client to request write authority. The server grants or denies and can revoke at any time.
How to read this book
- New to naia? Start with Bevy Quick Start — copy-paste a working server + client in under five minutes.
- Want a step-by-step walkthrough? Read Your First Server then Your First Client.
- Looking for a specific concept? Jump to Core Concepts.
- Building a prediction loop? Read Client-Side Prediction & Rollback.
- Optimising bandwidth? Read Priority-Weighted Bandwidth and Delta Compression.
- Comparing naia to other libraries? See Why naia? for the decision guide and Comparing naia to Alternatives for the technical deep-dive.
Not using Bevy? See Without Bevy for macroquad and custom engine integration.
Why naia?
naia occupies a specific niche in the Rust multiplayer networking ecosystem: Bevy-friendly, ECS-agnostic entity replication for native and browser clients from one shared protocol, with serious primitives for authority, prediction, lag compensation, and bandwidth control.
This page helps you decide whether naia is the right fit for your project. For a technical deep-dive on how naia differs from each library, see Comparing naia to Alternatives.
The short answer
Choose naia when you want:
- A real browser story without a second protocol — WebRTC supports native and Wasm clients from the same server, so your browser build is not a novelty build that lives in a side alley.
- A full authority toolkit — server-owned entities, opt-in client-owned entities, publication, authority delegation, and delegated resources are all modeled explicitly.
- Replication that scales past the happy path — rooms, per-user scope, per-field deltas, static entities, priority-weighted bandwidth, and per-connection budgets are built in.
- Lag compensation as a first-class primitive — the
Historiangives the server a rewindable view for hit detection and other “what did that client see?” questions. - Bevy ergonomics with an escape hatch — use the Bevy plugin when you are in Bevy; use the core API for macroquad or a custom world when you are not.
Decision guide
I need browser clients (WASM)
Use naia if you want WebRTC and one shared protocol. lightyear also supports Wasm clients through WebTransport, and transport-agnostic libraries can be paired with Web transports. naia’s pitch is more specific: its WebRTC transport is part of the naia stack and can serve native and Wasm clients simultaneously from the same server.
I want to build on Bevy and I don’t need browser clients
Consider lightyear first. lightyear is Bevy-native and ships a prediction/interpolation framework baked in. naia’s Bevy adapter is solid, but naia supplies prediction as primitives you assemble rather than a full framework. If you want the interpolation path handled for you, lightyear is a better fit.
If you also need WebRTC, lag compensation, client-authoritative entities, delegated resources, or per-entity bandwidth control, naia is still the stronger choice even on Bevy.
I only need message passing, not entity replication
Consider a lower-level transport/message library. naia’s replication machinery (diff tracking, scope management, priority sorting) is useful when your game state maps to replicated entities/resources. If you already serialize all state manually, a smaller layer may fit better.
I’m building a fighting game or any P2P deterministic rollback game
Use GGRS + matchbox. GGRS implements GGPO-style rollback for fixed-roster deterministic simulations. naia is server-authoritative — it is not designed for P2P netcode. That said, a game can use naia for the lobby/server layer and GGRS for the fast-path P2P match.
I want the simplest possible Bevy replication and don’t need advanced features
Consider bevy_replicon. bevy_replicon is simpler to set up and has less surface area. It is also transport-agnostic, so browser support depends on the transport you pair it with. If you do not need naia’s authority model, Historian, WebRTC transport, priority bandwidth, or compression features, it may be easier to start with.
What naia provides
- Entity replication with per-field delta compression (
Property<T>) - Static entities (write-once, zero per-tick cost after initial send)
- Replicated resources (singletons that can be server-owned or delegated)
- Two-level interest management: rooms (coarse) +
UserScope(fine-grained) - Opt-in client-authoritative entities
- Authority delegation (server grants/revokes client write authority per entity or resource)
- Tick synchronization with client tick leading by ~RTT/2
- Client-side prediction primitives:
TickBufferedchannels,CommandHistory,local_duplicate() - Lag compensation via the
Historiansnapshot buffer - Priority-weighted bandwidth allocation with token-bucket send loop
- Optional zstd packet compression with custom dictionary training
- Connection diagnostics: RTT (EWMA + P50/P99), jitter, packet loss, kbps
What naia does not provide
- A built-in snapshot interpolation framework (the demos show the pattern; you
write the
Interpcomponent logic) - Spatial / automatic interest management (you write the scope predicate;
naia calls it via
scope_checks_pending()) - P2P / NAT hole-punching as a primary architecture
Installation
Most naia applications use a three-crate workspace: a shared crate imported by
both sides, a server binary, and a client binary. The shared crate is where the
Protocol, replicated components, messages, channels, and request/response
types live.
Bevy Projects
For Bevy, use the Bevy adapter crates in all three crates:
# shared/Cargo.toml
[dependencies]
naia-bevy-shared = "0.25"
bevy_ecs = { version = "0.18", default-features = false }
# server/Cargo.toml
[dependencies]
naia-bevy-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
# client/Cargo.toml
[dependencies]
naia-bevy-client = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
naia-bevy-server and naia-bevy-client re-export the shared primitives most
application code needs. Your shared crate should depend on naia-bevy-shared
because Bevy replicated components derive both Component and Replicate.
See Bevy Quick Start for a complete working example.
Without Bevy
For macroquad or a custom engine, use the core crates directly:
# shared/Cargo.toml
[dependencies]
naia-shared = "0.25"
# server/Cargo.toml
[dependencies]
naia-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
# client/Cargo.toml
[dependencies]
naia-client = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
There is no separate macroquad adapter crate. Macroquad clients use naia-client
directly and enable the mquad feature when building through miniquad/macroquad:
naia-client = { version = "0.25", features = ["mquad", "transport_webrtc"] }
naia-shared = { version = "0.25", features = ["mquad"] }
See Core API Overview and Macroquad for the non-Bevy path.
Browser Clients
The server still runs natively. Browser clients compile to
wasm32-unknown-unknown and use the same transport_webrtc protocol path as
native WebRTC clients.
For a core client wrapper crate:
[features]
wbindgen = ["naia-client/wbindgen", "my-game-shared/wbindgen"]
[dependencies]
naia-client = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
For a Bevy client, naia-bevy-client already enables the underlying wasm-bindgen
support it needs; the important transport feature is still transport_webrtc.
Add the Wasm target if you have not already:
rustup target add wasm32-unknown-unknown
Build with whichever frontend tool your app uses:
trunk build --release
wasm-pack build --target web
Transport Features
| Crate | Feature | Use when |
|---|---|---|
naia-server, naia-client | transport_webrtc | Preferred native + browser transport; DTLS via WebRTC |
naia-bevy-server, naia-bevy-client | transport_webrtc | Same transport through the Bevy adapter |
naia-server, naia-client | transport_udp | Native plaintext UDP for local dev/trusted networks |
naia-server, naia-client | transport_local | In-process tests and deterministic harnesses |
naia-client, naia-shared | wbindgen | Core-client wrapper crates targeting wasm-bindgen |
naia-client, naia-shared | mquad | miniquad/macroquad builds |
Workspace Layout
# Cargo.toml (workspace root)
[workspace]
members = ["shared", "server", "client"]
resolver = "2"
Keep protocol construction and all registered replicated/message/channel types
in the shared crate. Both sides must build the exact same Protocol; a mismatch
rejects the handshake, which is correct behavior and also a very efficient way
to discover that one side forgot to enable a feature flag.
Rust Toolchain
naia uses stable Rust. No nightly features are required.
Bevy Quick Start
In under five minutes you will have a live naia + Bevy server and client: the
server spawns a Position entity per connecting user and nudges it every tick;
the client receives real-time updates and prints them to the terminal.
Not using Bevy? All of the networking concepts here apply to the bare
naia-server/naia-clientAPI too. See Core API Overview for the ECS-agnostic version.
What we are building
sequenceDiagram
participant Server
participant Client
Client->>Server: connect (WebRTC)
Server->>Client: SpawnEntity (Position {x:0, y:0})
loop every tick (20 Hz)
Server->>Server: x += 0.1
Server->>Client: UpdateComponent (Position.x)
Client->>Server: PlayerInput (keys pressed)
end
Client->>Server: disconnect
Server->>Server: despawn entity
Workspace layout
my_game/
Cargo.toml ← workspace root
shared/ ← protocol types shared by server + client
server/ ← Bevy server binary
client/ ← Bevy client binary
# Cargo.toml (workspace root)
[workspace]
members = ["shared", "server", "client"]
resolver = "2"
Shared crate
The shared crate defines every type that both sides must agree on. naia derives a
deterministic ProtocolId hash from these registrations; a mismatch causes
handshake rejection.
# shared/Cargo.toml
[package]
name = "my-game-shared"
version = "0.1.0"
edition = "2021"
[dependencies]
naia-bevy-shared = "0.25"
bevy_ecs = { version = "0.18", default-features = false }
#![allow(unused)]
fn main() {
// shared/src/lib.rs
use std::time::Duration;
use bevy_ecs::prelude::Component;
use naia_bevy_shared::{
Channel, ChannelDirection, ChannelMode, Message, Property, Protocol, Replicate,
};
/// A replicated position component.
///
/// `Property<T>` wraps each field for per-field change detection. Only mutated
/// fields are included in the outbound diff — naia never sends the full struct
/// unless all fields changed simultaneously.
#[derive(Component, Replicate, Clone)]
pub struct Position {
pub x: Property<f32>,
pub y: Property<f32>,
}
impl Position {
pub fn new(x: f32, y: f32) -> Self {
Self {
x: Property::new(x),
y: Property::new(y),
}
}
}
/// A typed input message sent from client to server each tick.
///
/// Message fields are NOT wrapped in `Property<>` — messages are serialized in
/// full each send, not delta-tracked.
#[derive(Message, Clone)]
pub struct PlayerInput {
pub up: bool,
pub down: bool,
pub left: bool,
pub right: bool,
}
/// The channel that carries `PlayerInput` from client → server.
///
/// `TickBuffered` stamps each message with the sending tick. The server
/// delivers them via `receive_tick_buffer_messages(tick)` at the matching
/// simulation step — the foundation of client-side prediction.
#[derive(Channel)]
pub struct InputChannel;
/// Build the shared protocol. Both the server and the client call this function.
pub fn protocol() -> Protocol {
Protocol::builder()
.tick_interval(Duration::from_millis(50)) // 20 Hz
.add_component::<Position>()
.add_message::<PlayerInput>()
.add_channel::<InputChannel>(
ChannelDirection::ClientToServer,
ChannelMode::TickBuffered(Default::default()),
)
.build()
}
}
Server
# server/Cargo.toml
[package]
name = "my-game-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "server"
path = "src/main.rs"
[dependencies]
bevy = { version = "0.18", default-features = false, features = ["bevy_core_pipeline", "bevy_log"] }
naia-bevy-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
// server/src/main.rs
use std::collections::HashMap;
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use naia_bevy_server::{
transport::webrtc,
CommandsExt, Plugin as NaiaServerPlugin, RoomKey, Server, ServerConfig,
events::{ConnectEvent, DisconnectEvent, TickEvent},
};
use my_game_shared::{protocol, InputChannel, PlayerInput, Position};
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()))
// Resources
.insert_resource(UserEntities::default())
.insert_resource(GlobalRoom(None))
// Systems
.add_systems(Startup, startup)
.add_systems(
Update,
(
handle_connections,
handle_disconnections,
handle_tick,
),
)
.run();
}
/// Map from Bevy `Entity` (the player entity) keyed by the naia `UserKey`
/// stored as a plain u64 so we don't need to import UserKey here.
#[derive(Resource, Default)]
struct UserEntities(HashMap<u64, Entity>);
#[derive(Resource)]
struct GlobalRoom(Option<RoomKey>);
fn startup(mut server: Server) {
let addrs = webrtc::ServerAddrs::new(
"0.0.0.0:14191".parse().unwrap(), // signaling/auth HTTP
"0.0.0.0:14192".parse().unwrap(), // WebRTC UDP data
"http://127.0.0.1:14192", // public data URL for local dev
);
let socket = webrtc::Socket::new(&addrs, server.socket_config());
server.listen(socket);
println!("Server listening on http://127.0.0.1:14191");
}
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut global_room: ResMut<GlobalRoom>,
mut user_entities: ResMut<UserEntities>,
) {
// Create the shared room the first time a client connects.
let room_key = *global_room.0.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
println!("User connected: {:?}", user_key);
// Spawn a Bevy entity and enable naia replication on it.
let entity = commands
.spawn_empty()
.enable_replication(&mut server)
.insert(Position::new(0.0, 0.0))
.id();
// Both the user and the entity must share a room before replication begins.
server.room_mut(&room_key).add_user(user_key);
server.room_mut(&room_key).add_entity(&entity);
// Track the mapping so we can despawn on disconnect.
user_entities.0.insert(user_key.to_u64(), entity);
}
}
fn handle_disconnections(
mut commands: Commands,
mut server: Server,
mut disconnect_reader: MessageReader<DisconnectEvent>,
mut user_entities: ResMut<UserEntities>,
) {
for DisconnectEvent(user_key, _address, _reason) in disconnect_reader.read() {
println!("User disconnected: {:?}", user_key);
if let Some(entity) = user_entities.0.remove(&user_key.to_u64()) {
commands.entity(entity).despawn();
}
}
}
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
// Drain player input commands that were stamped for this exact tick.
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (_user_key, input) in messages.read::<InputChannel, PlayerInput>() {
// In a real game: apply input to the entity owned by that user.
// Here we just nudge all entities for demonstration.
let _ = input;
}
// Nudge every player entity 0.1 units per tick regardless of input.
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
}
}
Note:
enable_replicationis an extension method fromCommandsExt. It registers the entity with naia’s replication tracker. Without it, inserting aPositioncomponent does nothing from naia’s perspective.
Client
# client/Cargo.toml
[package]
name = "my-game-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "client"
path = "src/main.rs"
[dependencies]
bevy = { version = "0.18", default-features = false, features = ["bevy_core_pipeline", "bevy_log"] }
naia-bevy-client = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
// client/src/main.rs
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use naia_bevy_client::{
transport::webrtc,
Client, ClientConfig, DefaultClientTag, DefaultPlugin as NaiaClientPlugin,
events::{
ClientTickEvent, ConnectEvent, DisconnectEvent,
InsertComponentEvent, SpawnEntityEvent, UpdateComponentEvent,
},
};
use my_game_shared::{protocol, InputChannel, PlayerInput, Position};
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaClientPlugin::new(ClientConfig::default(), protocol()))
.add_systems(Startup, startup)
.add_systems(
Update,
(
handle_connect,
handle_disconnect,
handle_spawn,
handle_insert_position,
handle_update_position,
handle_tick,
),
)
.run();
}
fn startup(mut client: Client<DefaultClientTag>) {
let socket = webrtc::Socket::new("http://127.0.0.1:14191", client.socket_config());
client.connect(socket);
println!("Connecting to http://127.0.0.1:14191 ...");
}
fn handle_connect(mut connect_reader: MessageReader<ConnectEvent<DefaultClientTag>>) {
for _ in connect_reader.read() {
println!("Connected to server!");
}
}
fn handle_disconnect(mut disconnect_reader: MessageReader<DisconnectEvent<DefaultClientTag>>) {
for _ in disconnect_reader.read() {
println!("Disconnected from server.");
}
}
fn handle_spawn(mut spawn_reader: MessageReader<SpawnEntityEvent<DefaultClientTag>>) {
for event in spawn_reader.read() {
println!("Entity spawned by server: {:?}", event.entity);
}
}
fn handle_insert_position(
mut insert_reader: MessageReader<InsertComponentEvent<DefaultClientTag, Position>>,
positions: Query<&Position>,
) {
for event in insert_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!(
"Position inserted on {:?}: ({:.2}, {:.2})",
event.entity, *pos.x, *pos.y
);
}
}
}
fn handle_update_position(
mut update_reader: MessageReader<UpdateComponentEvent<DefaultClientTag, Position>>,
positions: Query<&Position>,
) {
for event in update_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!(
"Position updated on {:?}: ({:.2}, {:.2})",
event.entity, *pos.x, *pos.y
);
}
}
}
fn handle_tick(
mut client: Client<DefaultClientTag>,
mut tick_reader: MessageReader<ClientTickEvent<DefaultClientTag>>,
keyboard: Res<ButtonInput<KeyCode>>,
) {
for _ in tick_reader.read() {
// Send input stamped with the current client tick so the server can
// match it to the exact simulation step (TickBuffered delivery).
let input = PlayerInput {
up: keyboard.pressed(KeyCode::KeyW) || keyboard.pressed(KeyCode::ArrowUp),
down: keyboard.pressed(KeyCode::KeyS) || keyboard.pressed(KeyCode::ArrowDown),
left: keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft),
right: keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight),
};
client.send_tick_buffer_message::<InputChannel, _>(&input);
}
}
Note: The Bevy plugin handles
receive_all_packets,process_all_packets, andsend_all_packetsautomatically every frame. You never call those methods directly — just read events and mutate components.
Running it
# Terminal 1 — start the server
cargo run -p my-game-server
# Terminal 2 — start the client
cargo run -p my-game-client
You should see output similar to:
# Server terminal
Server listening on http://127.0.0.1:14191
User connected: UserKey(1)
# Client terminal
Connecting to http://127.0.0.1:14191 ...
Connected to server!
Entity spawned by server: Entity(0v1)
Position inserted on Entity(0v1): (0.00, 0.00)
Position updated on Entity(0v1): (0.10, 0.00)
Position updated on Entity(0v1): (0.20, 0.00)
…
What just happened
When the client connected, the server created a room and added both the user
and a freshly spawned Position entity to it. Every 50 ms (20 Hz) the server
ticked, incremented pos.x by 0.1, and naia diffed the changed field against
each client’s last acknowledged snapshot. Only the x field — not the whole
Position struct — traveled over the wire. On the client, Bevy fired
UpdateComponentEvent<Position> which your system read and printed. The
PlayerInput messages traveled the other direction: the client stamped them
with the client tick and the server delivered them at the matching simulation
step via receive_tick_buffer_messages.
Next steps
- Your First Server — step-by-step server walkthrough with full explanations.
- Your First Client — detailed client event reference.
- The Shared Protocol — understand
ProtocolIdand type registration. - Rooms & Scoping — control which entities each client sees.
- Client-Side Prediction & Rollback — use
TickBufferedinput for a full prediction loop. - WebRTC (Native + Browser) — build native and
wasm32-unknown-unknownclients against one server.
Your First Server
This chapter builds the server from the Bevy Quick Start step by step, explaining each piece. By the end you will understand why the Bevy plugin works the way it does and how to extend it for a real game.
Core API: Not using Bevy? The bare
naia-serverAPI is identical in concept but uses a direct method-call loop instead of Bevy systems. See Core API Overview.
Project layout
my_game/
shared/ ← protocol, components, channels (Cargo.toml: naia-bevy-shared)
server/ ← naia-bevy-server binary
client/ ← naia-bevy-client binary (next chapter)
Cargo.toml
# server/Cargo.toml
[package]
name = "my-game-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "server"
path = "src/main.rs"
[dependencies]
bevy = { version = "0.18", default-features = false, features = ["bevy_core_pipeline"] }
naia-bevy-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
Step 1 — Plugin setup
NaiaServerPlugin replaces naia’s five-step loop with Bevy-native scheduling.
You provide a ServerConfig (timeouts, heartbeat rate) and the shared Protocol.
use bevy::prelude::*;
use naia_bevy_server::{Plugin as NaiaServerPlugin, ServerConfig};
use my_game_shared::protocol;
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()))
.run();
}
Note:
NaiaServerPlugininsertsreceive_all_packets,process_all_packets, andsend_all_packetsas Bevy systems at fixed positions in the schedule. You never call those methods manually — the plugin owns the loop.
Step 2 — System ordering
The plugin arranges naia’s work around your systems automatically:
sequenceDiagram
participant Bevy as Bevy Update schedule
participant Naia as NaiaServerPlugin
Naia->>Bevy: receive_all_packets (first in frame)
Naia->>Bevy: process_all_packets
Bevy->>Bevy: YOUR event-reading systems run here
Bevy->>Bevy: YOUR tick systems run here
Naia->>Bevy: send_all_packets (last in frame)
Your systems only need to read events and mutate components — ordering relative to naia’s internal systems is handled.
Step 3 — Startup listener
The startup system calls server.listen(...) once. The Server type is a Bevy
SystemParam that gives direct access to naia’s server handle.
#![allow(unused)]
fn main() {
use naia_bevy_server::{transport::webrtc, Server};
fn startup(mut server: Server) {
server.listen(webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config()));
println!("Server listening on 0.0.0.0:14191");
}
}
Add it to the app:
#![allow(unused)]
fn main() {
.add_systems(Startup, startup)
}
Step 4 — Handling connections
ConnectEvent fires once per new client immediately after the handshake
completes. This is the right place to create rooms and spawn player entities.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{
CommandsExt, RoomKey, Server,
events::{ConnectEvent, DisconnectEvent},
};
use my_game_shared::Position;
use std::collections::HashMap;
#[derive(Resource, Default)]
struct UserEntities(HashMap<u64, Entity>);
#[derive(Resource)]
struct GlobalRoom(Option<RoomKey>);
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut global_room: ResMut<GlobalRoom>,
mut user_entities: ResMut<UserEntities>,
) {
let room_key = *global_room.0.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
let entity = commands
.spawn_empty()
.enable_replication(&mut server) // ← registers entity with naia
.insert(Position::new(0.0, 0.0))
.id();
server.room_mut(&room_key).add_user(user_key);
server.room_mut(&room_key).add_entity(&entity);
user_entities.0.insert(user_key.to_u64(), entity);
println!("Connected: {:?} → entity {:?}", user_key, entity);
}
}
}
Note:
enable_replicationis the critical call. Without it naia does not know the entity exists and will not sendSpawnEntitypackets to clients. Theinsert(Position::new(...))call happens on a naia-tracked entity, so naia immediately queues the initial component value for delivery.
Handling disconnections
#![allow(unused)]
fn main() {
fn handle_disconnections(
mut commands: Commands,
mut disconnect_reader: MessageReader<DisconnectEvent>,
mut user_entities: ResMut<UserEntities>,
) {
for DisconnectEvent(user_key, _address, _reason) in disconnect_reader.read() {
if let Some(entity) = user_entities.0.remove(&user_key.to_u64()) {
commands.entity(entity).despawn();
// naia automatically sends DespawnEntity to all in-scope clients.
}
}
}
}
Step 5 — The tick event
TickEvent fires once for each elapsed server tick (configured in the
Protocol as tick_interval). Mutation of replicated components belongs here.
#![allow(unused)]
fn main() {
use naia_bevy_server::events::TickEvent;
use my_game_shared::{InputChannel, PlayerInput, Position};
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
// Deliver TickBuffered input from clients at this exact tick.
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (user_key, input) in messages.read::<InputChannel, PlayerInput>() {
// Apply input to the entity owned by this user.
// (Look up entity in your UserEntities resource here.)
let _ = (user_key, input);
}
// Advance all positions.
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
// naia diffs each mutated Property<f32> field against the last
// acknowledged snapshot for each in-scope client.
}
}
}
Note: The plugin calls
send_all_packetsafter your tick systems complete. You do not need to — and must not — call it yourself.
Putting it together
// server/src/main.rs (complete)
use std::collections::HashMap;
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use naia_bevy_server::{
transport::webrtc,
CommandsExt, Plugin as NaiaServerPlugin, RoomKey, Server, ServerConfig,
events::{ConnectEvent, DisconnectEvent, TickEvent},
};
use my_game_shared::{protocol, InputChannel, PlayerInput, Position};
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()))
.insert_resource(UserEntities::default())
.insert_resource(GlobalRoom(None))
.add_systems(Startup, startup)
.add_systems(Update, (handle_connections, handle_disconnections, handle_tick))
.run();
}
#[derive(Resource, Default)]
struct UserEntities(HashMap<u64, Entity>);
#[derive(Resource)]
struct GlobalRoom(Option<RoomKey>);
fn startup(mut server: Server) {
server.listen(webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config()));
}
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut global_room: ResMut<GlobalRoom>,
mut user_entities: ResMut<UserEntities>,
) {
let room_key = *global_room.0.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
let entity = commands
.spawn_empty()
.enable_replication(&mut server)
.insert(Position::new(0.0, 0.0))
.id();
server.room_mut(&room_key).add_user(user_key);
server.room_mut(&room_key).add_entity(&entity);
user_entities.0.insert(user_key.to_u64(), entity);
}
}
fn handle_disconnections(
mut commands: Commands,
mut disconnect_reader: MessageReader<DisconnectEvent>,
mut user_entities: ResMut<UserEntities>,
) {
for DisconnectEvent(user_key, _address, _reason) in disconnect_reader.read() {
if let Some(entity) = user_entities.0.remove(&user_key.to_u64()) {
commands.entity(entity).despawn();
}
}
}
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (_user_key, _input) in messages.read::<InputChannel, PlayerInput>() {}
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
}
}
What happens per frame
- Plugin:
receive_all_packetsreads WebRTC/UDP packets. - Plugin:
process_all_packetsdecodes packets into Bevy messages. - Your systems:
handle_connectionsandhandle_disconnectionsdrain connection messages. - Your systems:
handle_tickadvances simulation. - Plugin:
send_all_packetsserializes field diffs and flushes to the network.
On a client connecting for the first time, step 5 delivers:
- A
SpawnEntitypacket for each entity in the user’s scope. - A full component snapshot (all
Positionfield values) for each such entity.
On subsequent frames, only changed fields travel over the wire.
Next steps
- Your First Client — receive those events on the client side.
- Running the Demos — run the complete
demos/bevy/example. - Rooms & Scoping — fine-grained per-entity visibility.
- Messages & Channels — typed message passing.
- Tick Synchronization — tick rate,
TickBufferedinput, and client-side prediction.
Your First Client
This chapter connects a Bevy client to the server built in Your First Server. The client receives entity spawn, update, and despawn messages from the server using Bevy’s message system.
Core API: Not using Bevy? The bare
naia-clientAPI is identical in concept but uses a direct method-call loop. See Core API Overview.
Cargo.toml
# client/Cargo.toml
[package]
name = "my-game-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "client"
path = "src/main.rs"
[dependencies]
bevy = { version = "0.18", default-features = false, features = ["bevy_core_pipeline"] }
naia-bevy-client = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
For native or browser clients, enable the WebRTC transport:
naia-bevy-client = { version = "0.25", features = ["transport_webrtc"] }
Plugin setup
NaiaClientPlugin handles the packet loop automatically. Your systems only
read messages.
use bevy::prelude::*;
use naia_bevy_client::{ClientConfig, DefaultPlugin as NaiaClientPlugin};
use my_game_shared::protocol;
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaClientPlugin::new(ClientConfig::default(), protocol()))
.add_systems(Startup, startup)
.add_systems(
Update,
(
handle_connect,
handle_disconnect,
handle_spawn,
handle_despawn,
handle_insert_position,
handle_update_position,
handle_tick,
),
)
.run();
}
Startup — connect
#![allow(unused)]
fn main() {
use naia_bevy_client::{transport::webrtc, Client, DefaultClientTag};
fn startup(mut client: Client<DefaultClientTag>) {
client.connect(webrtc::Socket::new("http://127.0.0.1:14191", client.socket_config()));
println!("Connecting to http://127.0.0.1:14191 ...");
}
}
What the client does NOT do
The Bevy client plugin owns the packet loop. You do not call:
receive_all_packetsprocess_all_packetssend_all_packets
The plugin runs those before and after your systems in the Bevy schedule. Your job is only to read the resulting messages.
Connection events
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{events::{ConnectEvent, DisconnectEvent}, DefaultClientTag};
fn handle_connect(mut connect_reader: MessageReader<ConnectEvent<DefaultClientTag>>) {
for _ in connect_reader.read() {
println!("Connected to server!");
}
}
fn handle_disconnect(mut disconnect_reader: MessageReader<DisconnectEvent<DefaultClientTag>>) {
for _ in disconnect_reader.read() {
println!("Disconnected from server.");
// Despawn stale entities here — naia does NOT do this automatically.
// Without cleanup you will get duplicate entities on reconnect.
}
}
}
Entity lifecycle events
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{events::{SpawnEntityEvent, DespawnEntityEvent}, DefaultClientTag};
fn handle_spawn(mut spawn_reader: MessageReader<SpawnEntityEvent<DefaultClientTag>>) {
for event in spawn_reader.read() {
println!("Entity spawned: {:?}", event.entity);
}
}
fn handle_despawn(mut despawn_reader: MessageReader<DespawnEntityEvent<DefaultClientTag>>) {
for event in despawn_reader.read() {
println!("Entity despawned: {:?}", event.entity);
}
}
}
Note: When naia spawns a server entity locally, it creates a real Bevy
Entity.SpawnEntityEventcarries thatEntityhandle — you can pass it toQuery,Commands::entity, etc. just like any other Bevy entity.
Component events
InsertComponentEvent<C> fires once when a component first arrives for an
entity. UpdateComponentEvent<C> fires whenever any field of that component
changes on the server.
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{events::{InsertComponentEvent, UpdateComponentEvent}, DefaultClientTag};
use my_game_shared::Position;
fn handle_insert_position(
mut insert_reader: MessageReader<InsertComponentEvent<DefaultClientTag, Position>>,
positions: Query<&Position>,
) {
for event in insert_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!("Position inserted: ({:.2}, {:.2})", *pos.x, *pos.y);
}
}
}
fn handle_update_position(
mut update_reader: MessageReader<UpdateComponentEvent<DefaultClientTag, Position>>,
positions: Query<&Position>,
) {
for event in update_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!("Position updated: ({:.2}, {:.2})", *pos.x, *pos.y);
}
}
}
}
The Position component is a standard Bevy component on the client entity — you
read it with an ordinary Query. naia writes the latest server values into it
before your systems run.
Tick event and sending input
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{Client, DefaultClientTag, events::ClientTickEvent};
use my_game_shared::{InputChannel, PlayerInput};
fn handle_tick(
mut client: Client<DefaultClientTag>,
mut tick_reader: MessageReader<ClientTickEvent<DefaultClientTag>>,
keyboard: Res<ButtonInput<KeyCode>>,
) {
for _ in tick_reader.read() {
let input = PlayerInput {
up: keyboard.pressed(KeyCode::KeyW),
down: keyboard.pressed(KeyCode::KeyS),
left: keyboard.pressed(KeyCode::KeyA),
right: keyboard.pressed(KeyCode::KeyD),
};
// send_tick_buffer_message stamps the message with the current
// client tick so the server delivers it at the matching simulation step.
client.send_tick_buffer_message::<InputChannel, _>(&input);
}
}
}
Full client event reference
| Message | When it is emitted |
|---|---|
ConnectEvent | Handshake complete; connection established |
DisconnectEvent | Connection dropped (timeout or explicit) |
SpawnEntityEvent | Server spawned an entity now in your scope |
DespawnEntityEvent | Entity left your scope or server despawned it |
InsertComponentEvent<C> | Component C first arrived for an entity |
UpdateComponentEvent<C> | One or more fields of C changed on the server |
ClientTickEvent | Client tick elapsed; send input here |
MessageEvents | Server sent typed messages; call events.read::<Channel, Message>() |
PublishEntityEvent | A delegated entity was published to the server |
UnpublishEntityEvent | A delegated entity was unpublished |
Running both sides
# Terminal 1 — server first
cargo run -p my-game-server
# Terminal 2 — client
cargo run -p my-game-client
Expected output:
Connecting to 127.0.0.1:14191 ...
Connected to server!
Entity spawned: Entity(0v1)
Position inserted: (0.00, 0.00)
Position updated: (0.10, 0.00)
Position updated: (0.20, 0.00)
…
Browser client
Use the same transport_webrtc module. All event-handling code stays the same:
#![allow(unused)]
fn main() {
use naia_bevy_client::{transport::webrtc, Client, DefaultClientTag};
fn startup(mut client: Client<DefaultClientTag>) {
let socket = webrtc::Socket::new("https://myserver.example.com", client.socket_config());
client.connect(socket);
}
}
Build with wasm-pack build --target web or trunk build --release, and serve
the output directory over HTTP.
See WebRTC (Native + Browser) for the complete setup.
Next steps
- The Shared Protocol — understand
ProtocolIdand type registration. - Rooms & Scoping — control which entities each client sees.
- Client-Side Prediction & Rollback — use
TickBufferedinput to predict before the server confirms. - Running the Demos — run the complete
demos/bevy/example end-to-end.
Running the Demos
naia ships several working demos in the demos/ directory. Each demo
demonstrates a different integration pattern.
Basic demo (core, no ECS)
The simplest demo — server and client using naia-server / naia-client
directly, no ECS framework.
# Server
cargo run -p naia-demo-basic-server
# Client (native)
cargo run -p naia-demo-basic-client
# Client (browser)
cd demos/basic/client/wasm_bindgen
wasm-pack build --target web
Bevy demo (entity replication + prediction)
The flagship demo: entity replication, authority delegation, and client-side prediction all working together.
# Server
cargo run -p naia-demo-bevy-server
# Client
cargo run -p naia-demo-bevy-client
macroquad demo
A lightweight alternative to Bevy using the macroquad game framework.
# Server
cargo run -p naia-demo-macroquad-server
# Client
cargo run -p naia-demo-macroquad-client
Socket demo
Demonstrates the raw transport_webrtc socket layer without the
higher-level Server / Client APIs.
# Server
cargo run -p naia-demo-socket-server
# Client (browser)
cd demos/socket/client/wasm_bindgen
wasm-pack build --target web
Tip: Start with the basic demo to understand the five-step server/client loop, then move to the Bevy demo to see entity replication and prediction working together.
The Shared Protocol
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 and enforced
at connection time via a deterministic hash.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
Conventionally you put Protocol construction in a shared crate:
#![allow(unused)]
fn main() {
use naia_bevy_shared::{ChannelDirection, ChannelMode, Protocol};
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. With Bevy, pass the result to the plugins at startup:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use naia_bevy_server::{Plugin as NaiaServerPlugin, ServerConfig};
use naia_bevy_client::{ClientConfig, Plugin as NaiaClientPlugin};
use my_game_shared::protocol;
// Server app
App::new()
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()));
// Client app
App::new()
.add_plugins(NaiaClientPlugin::new(ClientConfig::default(), protocol()));
}
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.
Danger: If the server and client
Protocolvalues disagree — even a single missingadd_componentcall — the handshake fails silently from the client’s perspective. Always build theProtocolfrom a shared crate imported by both sides.
The shared crate typically contains:
Protocolconstruction- All
#[derive(Replicate)]component types - All
#[derive(Message)]types, including request/response structs that implement theRequest/Responsemarker traits - Custom
#[derive(Channel)]marker types
Entities and Components
With the Bevy adapter, an entity is a standard bevy::Entity. naia tracks the
entity in its replication set after you call commands.enable_replication(&mut server).
Replicated components must derive Replicate:
#![allow(unused)]
fn main() {
#[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.
Tip: Wrap tightly coupled multi-axis state in a single
Property<State>struct rather than separateProperty<f32>fields. One dirty-bit covers the whole struct, preventing partial-update inconsistencies between axes.
Protocol flow diagram
sequenceDiagram
participant Shared as shared crate
participant Server
participant Client
Shared->>Server: protocol()
Shared->>Client: protocol()
Client->>Server: Handshake (ProtocolId hash)
alt hashes match
Server->>Client: Connected
else hash mismatch
Server->>Client: Rejected
end
Registration order
Components, messages, and channels are registered by type. The ProtocolId
hash is computed from the set of registered types — order within the builder
does not matter for the hash, but the set must be identical on both sides.
Note: The
ProtocolIdis computed at startup, not at compile time. If you conditionally register components based on a feature flag, ensure the flag is set identically for the server and client builds.
Entity Replication
Entity replication is the core mechanism by which networked world state moves between hosts. The default path is server-owned state replicated to clients, but naia also supports opt-in client-owned entities and delegated authority.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
The Replication Loop
Internally naia runs these five steps in order every frame:
receive_all_packets – read UDP/WebRTC datagrams from the OS
process_all_packets – decode packets; apply client mutations
(Bevy messages are populated here)
[YOUR SYSTEMS] – read messages, mutate components
send_all_packets – serialise diffs + messages; flush to network
With the Bevy adapter, NaiaServerPlugin and NaiaClientPlugin own
receive_all_packets, process_all_packets, and send_all_packets. Your
systems run between process_all_packets and send_all_packets automatically —
you never call those methods directly.
The equivalent Bevy system ordering looks like this:
graph LR
A[receive_all_packets<br/>plugin] --> B[process_all_packets<br/>plugin]
B --> C[Your systems<br/>read events, mutate components]
C --> D[send_all_packets<br/>plugin]
Danger:
send_all_packetsmust be the last step. The plugin enforces this. In the bare core API, calling it inside a tick loop adds a full tick of latency to every component update.
Spawning a replicated entity
With the Bevy adapter, use CommandsExt::enable_replication:
#![allow(unused)]
fn main() {
use naia_bevy_server::CommandsExt;
let entity = commands
.spawn_empty()
.enable_replication(&mut server) // registers entity with naia
.insert(Position::new(0.0, 0.0)) // initial component value
.id();
}
On the next send_all_packets, naia sends a SpawnEntity packet to every
in-scope client with the initial component values.
To despawn a replicated entity, call commands.entity(entity).despawn(). naia
detects the despawn and sends DespawnEntity to all in-scope clients.
Replication state machine
stateDiagram-v2
[*] --> OutOfScope : entity spawned
OutOfScope --> InScope : scope includes entity
InScope --> OutOfScope : scope excludes entity / Despawn
InScope --> Frozen : scope excludes entity / Persist
Frozen --> InScope : scope.include(entity)
InScope --> [*] : server despawns entity
InScope --> InScope : property mutated → diff sent
Receiving replication messages on the client
On the client, Bevy messages are emitted as naia processes incoming packets:
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{
events::{DespawnEntityEvent, InsertComponentEvent, SpawnEntityEvent, UpdateComponentEvent},
DefaultClientTag,
};
use my_game_shared::Position;
fn handle_replication_events(
mut spawn_reader: MessageReader<SpawnEntityEvent<DefaultClientTag>>,
mut despawn_reader: MessageReader<DespawnEntityEvent<DefaultClientTag>>,
mut insert_reader: MessageReader<InsertComponentEvent<DefaultClientTag, Position>>,
mut update_reader: MessageReader<UpdateComponentEvent<DefaultClientTag, Position>>,
positions: Query<&Position>,
) {
for event in spawn_reader.read() {
println!("Entity spawned: {:?}", event.entity);
}
for event in despawn_reader.read() {
println!("Entity despawned: {:?}", event.entity);
}
for event in insert_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!("Position inserted: ({:.2}, {:.2})", *pos.x, *pos.y);
}
}
for event in update_reader.read() {
if let Ok(pos) = positions.get(event.entity) {
println!("Position updated: ({:.2}, {:.2})", *pos.x, *pos.y);
}
}
}
}
The Position component on the client entity is a standard Bevy component. naia
writes the latest server values into it before your systems run.
Static vs Dynamic Entities
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 with Bevy:
#![allow(unused)]
fn main() {
// Bevy adapter — enable static replication before inserting components.
commands
.spawn_empty()
.as_static()
.insert(tile);
}
Tip: Use static entities for map tiles, level geometry, or any entity written once and never changed. They eliminate diff tracking and save significant CPU time on servers with many entities.
Replicated Resources
A replicated resource is a singleton value replicated through the same machinery as entities. Internally naia creates a hidden one-component entity to carry the value. Resources are usually server-owned, but they can be configured as delegated resources so a client can request authority and mutate them through the normal authority path.
#![allow(unused)]
fn main() {
use naia_bevy_server::ServerCommandsExt;
// Insert a dynamic (diff-tracked) resource:
commands.replicate_resource(ScoreBoard::new());
// Insert a static (immutable) resource:
commands.replicate_resource_static(MapMetadata::new());
// Remove it later:
commands.remove_replicated_resource::<ScoreBoard>();
}
On the client:
#![allow(unused)]
fn main() {
fn read_scoreboard(scoreboard: Option<Res<ScoreBoard>>) {
if let Some(scoreboard) = scoreboard {
// Read it like any other Bevy resource.
}
}
}
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/configure_replicated_resource.
Multi-Server / Zone Architecture
naia is a single-process authority. 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 ────────────┘
(entity hand-off, cross-zone messages, matchmaking)
When a player moves between zones the application:
- Serializes the player’s replicated state on the source server.
- Sends the state to the destination server via your coordination channel.
- 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.
Note: naia provides the per-process primitive (
spawn_entity, rooms, scopes, authority). Zone coordination is an application concern.
Messages & Channels
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.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
Channel modes
| 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 | Request / response pairs |
Channel reliability diagram
graph LR
subgraph Unreliable
UU[UnorderedUnreliable<br/>drop freely]
SU[SequencedUnreliable<br/>keep newest]
end
subgraph Reliable
UR[UnorderedReliable<br/>guaranteed, any order]
OR[OrderedReliable<br/>guaranteed, FIFO]
TB[TickBuffered<br/>guaranteed, tick-stamped]
end
Custom channels
#![allow(unused)]
fn main() {
#[derive(Channel)]
pub struct PlayerInputChannel;
// In protocol builder:
.add_channel::<PlayerInputChannel>(
ChannelDirection::ClientToServer,
ChannelMode::TickBuffered(Default::default()),
)
}
Sending and receiving messages
Any struct that derives Message can be sent through a message channel:
#![allow(unused)]
fn main() {
#[derive(Message)]
pub struct ChatMessage {
pub text: String,
pub sender: u32,
}
}
Register the type in your Protocol builder:
#![allow(unused)]
fn main() {
Protocol::builder()
.add_message::<ChatMessage>()
.build()
}
With the Bevy adapter, send and receive using Server / Client SystemParams
and MessageReader:
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{Server, events::ConnectEvent};
use naia_bevy_client::{Client, DefaultClientTag, events::MessageEvents};
use my_game_shared::{ChatMessage, GameChannel};
// Server — send to a specific client:
fn send_welcome(mut server: Server, mut connect_reader: MessageReader<ConnectEvent>) {
for ConnectEvent(user_key) in connect_reader.read() {
let msg = ChatMessage { text: "Welcome!".into(), sender: 0 };
let _ = server.send_message::<GameChannel, _>(user_key, &msg);
}
}
// Client — receive:
fn receive_chat(mut chat_reader: MessageReader<MessageEvents<DefaultClientTag>>) {
for events in chat_reader.read() {
for msg in events.read::<GameChannel, ChatMessage>() {
println!("Server: {}", msg.text);
}
}
}
// Client — send to server:
fn send_input(mut client: Client<DefaultClientTag>) {
let msg = ChatMessage { text: "Hello!".into(), sender: 42 };
client.send_message::<GameChannel, _>(&msg);
}
}
Note:
Messagetypes do not useProperty<>wrappers — that wrapper is only forReplicatecomponents that participate in per-field delta tracking. Message fields are serialized in full each time the message is sent.
TickBuffered channels
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.
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{Server, events::TickEvent};
use my_game_shared::{PlayerInputChannel, PlayerInput};
// Server tick handler (Bevy system):
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
) {
for TickEvent(server_tick) in tick_reader.read() {
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (user_key, command) in messages.read::<PlayerInputChannel, PlayerInput>() {
// command arrived at exactly the right simulation step
let _ = (user_key, command);
}
}
}
}
Warning: Commands that arrive after their target tick has already executed are discarded silently. The client’s normal correction handler handles this as an ordinary misprediction — see Client-Side Prediction & Rollback.
Broadcasting to all users
To send the same message to every connected user, iterate over server.user_keys():
#![allow(unused)]
fn main() {
for user_key in server.user_keys() {
let _ = server.send_message::<GameChannel, _>(&user_key, &msg);
}
}
naia does not ship a single broadcast call — the per-user loop lets you skip
disconnected or unauthenticated users and is explicit about which users receive
the message.
Backpressure
ReliableSettings::max_queue_depth caps the unacknowledged message queue.
send_message returns Err(MessageQueueFull) when the limit is reached.
This prevents a slow or disconnected client from causing unbounded memory growth
on the server.
#![allow(unused)]
fn main() {
if let Err(e) = server.send_message::<GameChannel, _>(&user_key, &msg) {
// user disconnected or queue is full — safe to ignore or log
}
}
Rooms & Scoping
Entity replication uses a two-level scoping model. Both levels must allow replication before an entity is sent to a user.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
Two-level scoping diagram
graph TD
Entity --> Room
User --> Room
Room -->|coarse gate| Scope{UserScope}
Scope -->|include| Replicated[entity replicated to user]
Scope -->|exclude| NotReplicated[entity hidden from user]
Room membership (coarse)
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.
With the Bevy adapter, room operations happen inside a system that takes
Server as a SystemParam:
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{CommandsExt, RoomKey, Server, events::ConnectEvent};
use my_game_shared::Position;
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut room_key: ResMut<Option<RoomKey>>,
) {
let key = *room_key.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
let entity = commands
.spawn_empty()
.enable_replication(&mut server)
.insert(Position::new(0.0, 0.0))
.id();
server.room_mut(&key).add_user(user_key);
server.room_mut(&key).add_entity(&entity);
}
}
}
Tip: Think of rooms as match instances or game zones. All players in a match go into one room; their entities go in the same room. A player moving between zones moves their user key (and their entities) between rooms.
UserScope (fine-grained)
Within a shared room you can further restrict which entities replicate to which users. The canonical pattern is a visibility callback:
#![allow(unused)]
fn main() {
// scope_checks_pending() returns only entities that may have changed scope.
// scope_checks_all() does a full re-evaluation of everything in scope.
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();
}
Removing users and entities from rooms
To stop replicating an entity to a user, remove either side from the shared room:
#![allow(unused)]
fn main() {
// Remove a specific entity from the room (affects all users in that room).
server.room_mut(&room_key).remove_entity(&entity);
// Remove a user from the room (stops all replication for entities in that room
// unless the user and entity share another room).
server.room_mut(&room_key).remove_user(&user_key);
}
When a user is removed from the last room that contains an entity, the client
receives a DespawnEntityEvent for that entity (or the entity is frozen if
ScopeExit::Persist is configured).
ScopeExit behavior
When an entity leaves a user’s scope, naia’s default behavior is to send a
despawn event to that client (ScopeExit::Despawn). The alternative is
ScopeExit::Persist, which freezes the entity’s last known state on the client
without despawning it.
Note: Use
ScopeExit::Persistfor entities that may re-enter scope frequently (e.g. enemies near the viewport edge). This avoids spawn/despawn round-trips and prevents the client from briefly seeing the entity “pop in” each time.
Replicated resources bypass scoping
Replicated resources are visible to all
connected users automatically. No room membership or UserScope configuration
is required. They are the right choice for server-wide singletons like scoreboards
or match state.
Tick Synchronization
Ticks are the heartbeat of a naia simulation. The server advances a global tick counter; the client maintains two tick streams that stay synchronized with the server despite network jitter.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
Server ticks
The server tick interval is configured in the Protocol:
#![allow(unused)]
fn main() {
Protocol::builder()
.tick_interval(Duration::from_millis(50)) // 20 Hz
}
With the Bevy adapter, each elapsed server tick fires a TickEvent.
Mutate replicated components inside this system:
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{Server, events::TickEvent};
use my_game_shared::{InputChannel, PlayerInput, Position};
fn tick_system(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
// Drain input for this exact tick.
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (_user_key, _input) in messages.read::<InputChannel, PlayerInput>() {
// apply input ...
}
// Advance simulation.
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
}
// NaiaServerPlugin calls send_all_packets after this system completes.
}
}
Client ticks
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.
sequenceDiagram
participant C as Client (tick 47)
participant S as Server (tick 44)
C->>S: KeyCommand(tick=47)
Note over S: receives at tick 47 (arrives in time)
S->>C: Position update (tick=44)
Note over C: server_tick = 44, client_tick = 47
Use client_interpolation() and server_interpolation() to compute the
sub-tick interpolation fraction [0.0, 1.0) for smooth rendering between
discrete tick states.
With the Bevy adapter, use ClientTickEvent to send input each tick:
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{Client, DefaultClientTag, events::ClientTickEvent};
use my_game_shared::{InputChannel, PlayerInput};
fn client_tick_system(
mut client: Client<DefaultClientTag>,
mut tick_reader: MessageReader<ClientTickEvent<DefaultClientTag>>,
keyboard: Res<ButtonInput<KeyCode>>,
) {
for _ in tick_reader.read() {
let input = PlayerInput {
up: keyboard.pressed(KeyCode::KeyW),
down: keyboard.pressed(KeyCode::KeyS),
left: keyboard.pressed(KeyCode::KeyA),
right: keyboard.pressed(KeyCode::KeyD),
};
client.send_tick_buffer_message::<InputChannel, _>(&input);
}
}
}
Prediction and rollback
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.
Tip: The client tick leading the server by ~RTT/2 is what makes tick-accurate input delivery possible. The
ClientConfig::minimum_latencysetting controls the minimum lead — increase it on high-jitter links to reduce tick-buffer misses.
For a complete step-by-step walkthrough of the full prediction loop, see Client-Side Prediction & Rollback.
Connection Lifecycle
A naia connection passes through a well-defined set of states from the initial handshake through to disconnection and optional reconnect.
Core API: Not using Bevy? The bare
naia-server/naia-clientAPI is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.
Connection state machine
stateDiagram-v2
[*] --> PendingAuth : client calls connect()
PendingAuth --> Connected : handshake succeeds (ProtocolId match)
PendingAuth --> [*] : timeout or ProtocolId mismatch
Connected --> Disconnected : network timeout / explicit disconnect
Disconnected --> PendingAuth : client calls connect() again
Transport layer
naia’s transport layer is pluggable. Two implementations ship out of the box:
| Target | Implementation | Socket type | Encryption |
|---|---|---|---|
| Native (Linux/macOS/Windows) | WebRTC data channel | transport_webrtc | DTLS |
Browser (wasm32-unknown-unknown) | WebRTC data channel | transport_webrtc | DTLS |
| Native dev / trusted LAN | UDP datagram socket | transport_udp | None |
| Same-process tests | In-process queues | transport_local | n/a |
Warning:
transport_udpsends all packets as unencrypted plaintext. Use it for local development and trusted private networks only. See Security & Trust Model for production guidance.
The Server and Client APIs are identical for shipped transports — only
the Socket value passed to listen / connect differs:
#![allow(unused)]
fn main() {
use naia_bevy_server::{transport::webrtc, Server};
use naia_bevy_client::Client;
// Bevy startup system — native server accepting native and browser clients:
fn startup_server(mut server: Server) {
let socket = webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config());
server.listen(socket);
}
// Bevy startup system — native or browser client:
fn startup_client(mut client: Client) {
let socket = webrtc::Socket::new("http://127.0.0.1:14191", client.socket_config());
client.connect(socket);
}
}
For Wasm builds, add the Rust Wasm target and build with wasm-pack or trunk.
The protocol, channel config, and all game logic are identical.
Heartbeats and timeout detection
naia sends automatic heartbeats when no other packets are outgoing. If a
client stops responding for longer than the configured timeout window, the server
fires a DisconnectEvent for that user and reclaims all associated resources.
The timeout is controlled by ServerConfig / ClientConfig:
#![allow(unused)]
fn main() {
use naia_bevy_server::ServerConfig;
use std::time::Duration;
let mut config = ServerConfig::default();
// Disconnect a client after 10 seconds with no response.
config.connection.disconnection_timeout_duration = Duration::from_secs(10);
}
The default timeout is 10 seconds. Lower values catch dead connections faster but may cause false positives on brief network outages.
Network condition simulation
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.
#![allow(unused)]
fn main() {
use naia_bevy_shared::LinkConditionerConfig;
// Custom profile:
let lag = LinkConditionerConfig::new(
100, // incoming_latency ms
25, // incoming_jitter ms
0.02, // incoming_loss (2%)
);
// Or use a named preset:
let lag = LinkConditionerConfig::poor_condition();
// Pass the conditioner to a transport socket that accepts it, such as UDP or
// the local test transport.
}
Named presets:
| 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% |
Tip: To simulate a bidirectional bad link, pass the same config to both the server and client sockets. To simulate an asymmetric path (e.g. worse upload), use different configs on each side.
Reconnection
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.
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{
transport::webrtc,
Client, DefaultClientTag,
events::DisconnectEvent,
};
// Bevy system — handle disconnect and reconnect:
fn handle_disconnect(
mut commands: Commands,
mut client: Client<DefaultClientTag>,
mut disconnect_reader: MessageReader<DisconnectEvent<DefaultClientTag>>,
replicated_entities: Query<Entity, With<ReplicatedMarker>>,
) {
for _ in disconnect_reader.read() {
// Clear all server-replicated entities from your local world.
// naia does NOT do this automatically on disconnect.
for entity in replicated_entities.iter() {
commands.entity(entity).despawn_recursive();
}
// Reconnect — naia will re-run the full handshake.
let socket = webrtc::Socket::new("http://127.0.0.1:14191", client.socket_config());
client.connect(socket);
}
}
}
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.
- Any client-local state tied to the old session (auth tokens, predicted
entities,
CommandHistorybuffers). - Retry backoff. naia does not implement reconnection backoff; a simple timer resource in your game loop is sufficient.
Danger: If you reconnect without despawning the stale entities, you will end up with duplicate entities — one set from the old session (never despawned) and one set re-sent by the server on the new connection.
Server Authority Model
naia’s default replication model is server-authoritative: the server owns the canonical state for ordinary replicated entities and resources, and clients receive the subset the server places in their scope.
That default is not the whole authority model. Protocols can opt into client-authoritative entities, and server-owned entities/resources can be made delegable. Use server authority as the baseline, then opt into the more flexible paths where the gameplay actually needs them.
What server authority means
- The server usually spawns, updates, and despawns replicated entities.
- Clients never write to server-owned undelegated entities directly.
- All client inputs travel to the server through typed messages or
TickBufferedchannels; the server applies them and the resulting state update replicates back to clients. - If the protocol enables client-authoritative entities, clients may create entities that replicate to the server.
- The server can grant a client temporary write authority over a specific entity or resource via Authority Delegation, but retains the right to revoke it at any time.
Why server authority?
Server authority prevents a class of cheats where a client modifies local game state (position, health, score) and expects the server to accept it. Without a validation point, any client can claim any position, and that makes for a very short speedrun to chaos.
Warning: naia does not validate client mutations even when authority is delegated. The server must validate all client-originated state before applying it to authoritative game state.
Reclaiming authority
The server can revoke a client’s authority at any time with the Bevy server
CommandsExt API:
#![allow(unused)]
fn main() {
use naia_bevy_server::CommandsExt;
// Server forcibly reclaims authority over a delegated entity.
commands.entity(entity).take_authority(&mut server);
}
After this call the entity returns to Available status. The client that held
authority receives a notification that it was revoked.
Tip: Revoke authority automatically when a player disconnects. An entity stuck in
Grantedstate after a disconnect is a resource leak — the authority slot can never be reclaimed without a server restart.
NAT traversal and P2P
naia is server-authoritative by design — NAT traversal and peer-to-peer hole-punching are intentionally out of scope.
For P2P networking (e.g. browser-to-browser direct connections for a rollback fighting game), the recommended Rust/Wasm ecosystem tools are:
- matchbox_socket — async WebRTC signaling for P2P connections.
- GGRS / bevy_ggrs — GGPO-style rollback netcode on top of matchbox.
These are complementary: a game can use naia for server→client replication (lobby, world state) and GGRS for fast-path P2P match simulation in parallel.
Client-Owned Entities
In addition to server-owned and delegated entities, naia supports client-created
entities that replicate back to the server via the Publicity API.
Client-authoritative entities are opt-in. The shared protocol must call
enable_client_authoritative_entities() before clients may spawn, publish, or
mutate client-owned replicated entities.
Publicity::Public
A client can create an entity locally and mark it as Public, causing it to
replicate to the server:
#![allow(unused)]
fn main() {
use naia_bevy_client::{Client, CommandsExt, Publicity};
fn spawn_public_entity(mut commands: Commands, mut client: Client<Main>) {
commands
.spawn_empty()
.enable_replication(&mut client)
.configure_replication::<Main>(Publicity::Public)
.insert(MyComponent { value: 42.into() });
}
}
The server receives a SpawnEntityEvent for the entity and can read its
components. If other clients share the right room/scope, the server may also
replicate that public entity onward to them. This is distinct from authority
delegation over a server-spawned entity: here the client is the origin.
Publicity::Private
Publicity::Private means the client-owned entity replicates to the server but
is not published to other clients. This is the default for entities created by
the client.
Use this for state the server must validate or react to, but that other clients do not need to see directly.
Use cases
- Client-owned projectiles — the client spawns a bullet, marks it public, and the server validates the trajectory.
- Private client intent/state — replicated to the server but not fanned out to other clients.
- Pure UI / local effects — do not enable naia replication at all.
Warning: As with delegated authority, the server must validate all component values received from client-owned public entities. naia replicates what the client sends without validation.
Authority Delegation
By default, server-spawned replicated entities are server-owned. Delegation allows a client to take temporary write authority over a specific entity or resource. While the client holds authority, its mutations replicate back to the server instead of the other way around.
Delegation is related to, but separate from, client-authoritative entities. A client-owned published entity can be migrated into delegated state; after that migration it is server-owned and follows the same grant/deny/revoke rules as any other delegated entity.
Authority state machine
stateDiagram-v2
[*] --> Available : server marks entity Delegated
Available --> Requested : client calls request_authority()
Requested --> Granted : server grants
Requested --> Denied : server denies
Denied --> Available : client releases
Granted --> Releasing : client calls release_authority()
Releasing --> Available : server acknowledges
Granted --> Available : server calls take_authority()
Trust model
- The server may revoke authority at any time by calling
take_authoritythrough the BevyCommandsExtAPI. - The client never holds unrevocable ownership.
- Mutations from a client-held delegated entity should still be validated server-side before applying to authoritative game state. naia replicates what the client sends; it does not validate or clamp values.
Danger: naia does not validate client mutations. If a client has authority over a
Positioncomponent, it can send any coordinate it likes. Always range-check and sanity-validate delegated values on the server before applying them to authoritative game state.
Server setup
#![allow(unused)]
fn main() {
use naia_bevy_server::{CommandsExt, ReplicationConfig};
commands
.spawn_empty()
.enable_replication(&mut server)
.configure_replication(ReplicationConfig::delegated())
.insert(position);
}
If you skip enable_replication(), you have created a perfectly normal Bevy
entity. naia will politely ignore it, as requested.
Client request flow
#![allow(unused)]
fn main() {
use bevy::ecs::message::MessageReader;
use naia_bevy_client::{
events::{EntityAuthDeniedEvent, EntityAuthGrantedEvent},
Client, CommandsExt,
};
// Client: request authority over a delegated entity.
commands.entity(entity).request_authority(&mut client);
// Client: observe the server's grant/deny response.
fn handle_authority_response(
mut granted_reader: MessageReader<EntityAuthGrantedEvent<Main>>,
mut denied_reader: MessageReader<EntityAuthDeniedEvent<Main>>,
) {
for event in granted_reader.read() {
println!("Authority granted for {:?}", event.entity);
}
for event in denied_reader.read() {
println!("Authority denied for {:?}", event.entity);
}
}
}
Per-user authority
Only one client can hold authority over a given entity at a time. The server controls who may request and who is granted authority. Treat request handling as game logic: check the requesting user, current state, anti-cheat constraints, and whether the entity is currently in that user’s scope before granting.
Delegated resources
Resources can also be delegated using configure_replicated_resource in Bevy:
#![allow(unused)]
fn main() {
use naia_bevy_server::{ReplicationConfig, ServerCommandsExt};
commands.configure_replicated_resource::<ScoreBoard>(ReplicationConfig::delegated());
}
This lets a client request authority over singleton state through the same
authority-channel flow used for entities. On Bevy clients, use
commands.request_resource_authority::<MyClientTag, ScoreBoard>() after the
resource is present locally.
Relationship to Publicity
On the client side, the Publicity enum controls how a locally created entity
is visible to the server:
#![allow(unused)]
fn main() {
use naia_bevy_client::{CommandsExt, Publicity};
commands
.entity(entity)
.configure_replication::<Main>(Publicity::Public);
}
Publicity::Private and Publicity::Public are both client-owned replicated
states: private reaches the server only, public may also be fanned out to other
in-scope clients. Publicity::Delegated migrates the entity into the delegated
authority model, where the server owns the entity and authority can be granted
or revoked.
Entity Publishing
Entity publishing controls whether and how a replicated entity is visible beyond
its owner. This chapter covers initial scope placement, temporary replication
pausing, replicated resources, and the relationship between server-side
ReplicationConfig and client-side Publicity.
How replication starts
In Bevy, an entity is not considered by naia until you call
enable_replication(). After that, a server-owned entity reaches a client only
when all three conditions are true simultaneously:
- The entity and the user share at least one room.
- The entity is included in the user’s
UserScope. - The entity has a
ReplicationConfigthat allows replication.
The first two conditions are managed by rooms and scope (see
Rooms & Scoping). The third — ReplicationConfig — is
what this chapter covers.
ReplicationConfig variants
| Variant | Effect |
|---|---|
ReplicationConfig::public() / default | Replicated to in-scope users |
ReplicationConfig::private() | Client-owned unpublished state; not used for ordinary server-spawned public entities |
ReplicationConfig::delegated() | Marked as eligible for client authority requests (still replicated; see Authority Delegation) |
There is no ReplicationConfig::Disabled variant — to stop replicating an entity
you control scope membership or priority gain (see below).
#![allow(unused)]
fn main() {
use naia_bevy_server::{CommandsExt, ReplicationConfig};
// Default replication.
commands
.spawn_empty()
.enable_replication(&mut server)
.insert(Position::new(0.0, 0.0));
// Mark as delegatable — client can request write authority.
commands
.spawn_empty()
.enable_replication(&mut server)
.configure_replication(ReplicationConfig::delegated())
.insert(position);
}
Pausing replication without removing from scope
To temporarily stop sending updates for an entity — without despawning it or
changing its room membership — set its global priority gain to 0.0:
#![allow(unused)]
fn main() {
// Stop replicating this entity (it stays in scope; clients see its last state).
server.global_entity_priority_mut(&entity).set_gain(0.0);
// Resume replication at normal rate.
server.global_entity_priority_mut(&entity).set_gain(1.0);
// Resume at 2× normal rate (useful for a burst catch-up after pausing).
server.global_entity_priority_mut(&entity).set_gain(2.0);
}
Tip: Pausing replication via gain
0.0is correct for entities that are temporarily hidden (behind a wall, in a fog-of-war zone). The entity stays in the client’s scope, so re-enabling replication is instant — no spawn/despawn round-trip.
See Priority-Weighted Bandwidth for the full priority API.
Removing from scope entirely
To completely hide an entity from a specific user, either:
- Exclude from UserScope:
server.user_scope_mut(&user_key).exclude(&entity)— the client receives aDespawnEntityEvent(or the entity is frozen in place ifScopeExit::Persistis configured). - Remove from all shared rooms: remove both the entity and the user from every room they share.
See Rooms & Scoping for the scope management API.
Replicated resources
Replicated resources bypass the room/scope system entirely. A resource is a singleton replicated to connected clients without manually adding a hidden entity to rooms or user scopes:
#![allow(unused)]
fn main() {
use naia_bevy_server::ServerCommandsExt;
// Dynamic (diff-tracked) resource:
commands.replicate_resource(ScoreBoard::new());
// Static (immutable, sent once per connection) resource:
commands.replicate_resource_static(MapMetadata::new());
// Remove later:
commands.remove_replicated_resource::<ScoreBoard>();
}
Resources can also be marked delegatable using configure_replicated_resource:
#![allow(unused)]
fn main() {
use naia_bevy_server::{ReplicationConfig, ServerCommandsExt};
commands.configure_replicated_resource::<ScoreBoard>(ReplicationConfig::delegated());
}
See Entity Replication — Replicated Resources for the full resource API.
Static vs dynamic replication
Dynamic entities (the default) use per-field delta tracking — only changed
Property<T> fields are sent each tick.
Static entities skip delta tracking. A full snapshot is sent when the entity enters scope; no further updates are ever sent:
#![allow(unused)]
fn main() {
use naia_bevy_server::CommandsExt;
commands
.spawn_empty()
.as_static() // call before inserting replicated components
.insert(tile);
}
Use static entities for map geometry, level tiles, or any data that never changes after the initial spawn.
Danger: Mutating a
Property<T>on a static entity after spawn has no effect on connected clients — the mutation is never sent. Only use static entities for truly immutable data.
Client-side Publicity — client-created entities
On the client side, the Publicity enum controls whether a locally created
entity is replicated back to the server:
#![allow(unused)]
fn main() {
use naia_bevy_client::{Client, CommandsExt, Publicity};
// Client creates an entity and publishes it to the server:
commands
.spawn_empty()
.enable_replication(&mut client)
.configure_replication::<Main>(Publicity::Public)
.insert(MyComponent { value: 42.into() });
// Keep the entity private to the server/owner relationship (default):
commands
.entity(entity)
.configure_replication::<Main>(Publicity::Private);
}
Publicity::Private still lets the entity replicate to the server; it prevents
the server from also publishing that entity to other clients. For a truly local
entity, do not enable naia replication for it.
Publicity is distinct from ReplicationConfig: it controls client-created
entities flowing to the server and possibly out to peers, whereas
ReplicationConfig controls server-owned entities/resources flowing to
clients and whether they are delegable.
See Client-Owned Entities for the full Publicity API.
Lifecycle summary
stateDiagram-v2
[*] --> Spawned : server spawns entity
Spawned --> InScope : user + entity share room<br/>& scope.include(entity)
InScope --> Replicated : send_all_packets → client receives SpawnEntity
Replicated --> UpdateSent : property mutated → diff queued
UpdateSent --> Replicated : send_all_packets → client receives UpdateComponent
Replicated --> Paused : set_gain(0.0)
Paused --> Replicated : set_gain(1.0)
Replicated --> OutOfScope : scope.exclude(entity)
OutOfScope --> [*] : server despawns entity → DespawnEntity sent
InScope --> [*] : server despawns entity → DespawnEntity sent
Client-Side Prediction & Rollback
Client-side prediction hides network latency by letting the client apply inputs immediately to a local copy of the player entity, without waiting for the server’s authoritative confirmation. When the server’s correction arrives, the client rolls back to the authoritative state and replays all buffered commands.
Mental model
The server is authoritative. The client runs ahead of the server by approximately half the round-trip time (RTT/2) so that commands the client sends today arrive at the server in time for the server tick they belong to.
client server
│ │
tick 47 ──────── │──── KeyCommand(t=47) ────▶│
tick 48 ──────── │ │
tick 49 ──────── │◀─── Position(t=47) ───────│ (arrives ~RTT later)
│ │
The client doesn’t wait for the server’s reply before moving the player — it applies the input immediately to a local predicted copy of the entity. When the authoritative correction arrives, the client:
- Snaps the predicted state to the authoritative server state.
- Re-simulates (“replays”) every command issued after that correction tick.
Prediction + rollback timeline
sequenceDiagram
participant C as Client
participant S as Server
C->>C: apply KeyCommand(t=47) locally (prediction)
C->>S: send KeyCommand(t=47) via TickBuffered channel
Note over S: executes tick 47 with command
S->>C: Position update (authoritative, tick=47)
C->>C: snap predicted → server state at t=47
C->>C: replay commands t=48..now
The five building blocks
1. TickBuffered channel — stamped input delivery
#![allow(unused)]
fn main() {
// shared/src/channels.rs
protocol.add_channel::<PlayerCommandChannel>(ChannelSettings {
mode: ChannelMode::TickBuffered(TickBufferSettings::default()),
direction: ChannelDirection::ClientToServer,
});
}
TickBuffered attaches the client tick number to every message. The server
reads them with receive_tick_buffer_messages(&server_tick) — it only sees
commands whose stamp matches the current server tick, so input arrives at
exactly the right simulation step even under jitter.
2. CommandHistory — the replay buffer
#![allow(unused)]
fn main() {
use naia_bevy_client::CommandHistory;
// In your client resources:
pub command_history: CommandHistory<KeyCommand>,
}
CommandHistory::new(128) keeps the last 128 ticks of input. Choose a depth
of at least ceil(max_expected_RTT_ticks × 2). Too shallow and corrections
outside the window cause a visible snap; too deep wastes memory.
3. local_duplicate() — creating the predicted entity
When the server assigns an entity to the local player, clone it into a local predicted counterpart:
#![allow(unused)]
fn main() {
// In message_events, when EntityAssignment.assign == true:
let prediction_entity = commands.entity(confirmed_entity).local_duplicate();
global.owned_entity = Some(OwnedEntity {
confirmed: confirmed_entity,
predicted: prediction_entity,
});
}
local_duplicate() copies every Replicate component so the prediction starts
in sync with the server’s last known state.
4. Per-tick loop — record → send → apply
#![allow(unused)]
fn main() {
pub fn tick_events(
mut client: Client<Main>,
mut global: ResMut<Global>,
mut tick_reader: MessageReader<ClientTickEvent<Main>>,
mut position_query: Query<&mut Position>,
) {
let Some(predicted_entity) = global.owned_entity.as_ref()
.map(|e| e.predicted) else { return; };
let Some(command) = global.queued_command.take() else { return; };
for event in tick_reader.read() {
let client_tick = event.tick;
// Guard: don't overflow the history window.
if !global.command_history.can_insert(&client_tick) { continue; }
// Record.
global.command_history.insert(client_tick, command.clone());
// Send (with tick stamp — arrives at server at the right tick).
client.send_tick_buffer_message::<PlayerCommandChannel, KeyCommand>(
&client_tick, &command,
);
// Apply locally (prediction — no server round-trip yet).
if let Ok(mut position) = position_query.get_mut(predicted_entity) {
shared_behavior::process_command(&command, &mut position);
}
}
}
}
5. Correction handler — rollback + re-simulate
#![allow(unused)]
fn main() {
pub fn update_component_events(
mut global: ResMut<Global>,
mut position_event_reader: MessageReader<UpdateComponentEvent<Main, Position>>,
mut position_query: Query<&mut Position>,
) {
let Some(owned) = &global.owned_entity else { return; };
let mut latest_tick: Option<Tick> = None;
for event in position_event_reader.read() {
if event.entity == owned.confirmed {
match latest_tick {
Some(t) if sequence_greater_than(event.tick, t) => {}
_ => latest_tick = Some(event.tick),
}
}
}
let Some(server_tick) = latest_tick else { return; };
if let Ok([server_pos, mut client_pos]) =
position_query.get_many_mut([owned.confirmed, owned.predicted])
{
// Step A: snap prediction to authoritative state.
client_pos.mirror(&*server_pos);
// Step B: re-simulate every command since that server tick.
for (_tick, command) in global.command_history.replays(&server_tick) {
shared_behavior::process_command(&command, &mut client_pos);
}
}
}
}
Server side — reading stamped input
#![allow(unused)]
fn main() {
let mut messages = server.receive_tick_buffer_messages(&server_tick);
for (_user_key, command) in messages.read::<PlayerCommandChannel, KeyCommand>() {
let Some(entity) = command.entity.get(&server) else { continue; };
let Ok(mut position) = position_query.get_mut(entity) else { continue; };
shared_behavior::process_command(&command, &mut position);
}
}
Misprediction correction strategies
Strategy A — Instant snap (simplest)
Snap the predicted entity directly to the server value, then replay. This is correct but can produce a visible pop on high-latency links.
Strategy B — Smooth error interpolation (production)
Record the pre-rollback render position, run the rollback, then blend the visual position from old to new over 150–250 ms:
#![allow(unused)]
fn main() {
// Before rollback:
let pre_rollback_render_pos = render_position.current();
// Run the rollback:
predicted_pos.mirror(&*server_pos);
for (_tick, cmd) in global.command_history.replays(&server_tick) {
shared_behavior::process_command(&cmd, &mut predicted_pos);
}
// Begin interpolating the visual error away:
let post_rollback_render_pos = interpolate_from_physics(&predicted_pos);
let error = pre_rollback_render_pos - post_rollback_render_pos;
render_position.begin_error_correction(error, CORRECTION_DURATION_MS);
}
Each frame, apply a decaying fraction of error on top of the physics position:
#![allow(unused)]
fn main() {
let alpha = elapsed_ms / CORRECTION_DURATION_MS;
let visual_pos = physics_pos + error * (1.0 - smooth_step(alpha));
}
Tuning the prediction window
CommandHistory::new(N)— keep at most N ticks of history. Set N ≥ 2 × max RTT in ticks. At 20 Hz and 200 ms max RTT that’s ≥ 8 ticks; 128 is a safe default.TickBufferSettings::default()— the tick buffer accepts commands within a small window around the current server tick. For high-jitter links, widen the window.- Tick rate — lower tick rates (e.g. 20 Hz) increase the granularity of prediction mismatches. Higher rates reduce visible snap but increase CPU and bandwidth.
Batching corrections
Multiple UpdateComponentEvents can arrive in the same frame. Accumulate the
earliest correction tick across all component events, then run one rollback:
#![allow(unused)]
fn main() {
let mut rollback_tick: Option<Tick> = None;
for event in position_events.read() {
if event.entity == owned.confirmed {
rollback_tick = Some(match rollback_tick {
Some(t) if sequence_greater_than(t, event.tick) => event.tick,
Some(t) => t,
None => event.tick,
});
}
}
if let Some(from_tick) = rollback_tick {
run_rollback(from_tick, &mut global, &mut position_query);
}
}
Warning: Do not run a rollback inside each event handler inline. Drain all correction events in one system, queue the earliest tick, and replay in a subsequent system. Running two rollbacks in one frame can cause the second to overwrite the first.
Tick-buffer miss
A command sent for client tick T may arrive at the server after tick T has already executed. The server discards it silently. From the client’s perspective this is indistinguishable from an ordinary misprediction — the correction handler fires and replays from the missed tick.
Diagnosing misses in development: a sudden cluster of corrections for the
same entity across several consecutive ticks is the signature of a tick-buffer
miss. Increase ClientConfig::minimum_latency if you see systematic misses.
Full working example
See demos/bevy/client/src/systems/events.rs for the complete prediction loop
and demos/bevy/server/src/systems/events.rs for the server’s tick-buffer read
path. The shared movement logic lives in demos/bevy/shared/src/behavior.rs.
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.
Priority-Weighted Bandwidth
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.
Priority accumulator diagram
graph LR
subgraph "Per entity per user"
G[global_gain] --> EffGain
U[user_gain] --> EffGain
EffGain["effective_gain = global × user"]
end
EffGain --> Acc[priority accumulator]
Budget[bandwidth budget<br/>token bucket] --> SendLoop
Acc --> SendLoop[send loop<br/>sort by priority]
SendLoop -->|fits in budget| Wire[sent this tick]
SendLoop -->|over budget| CarryOver[carry priority to next tick]
Setting entity priority
#![allow(unused)]
fn main() {
// 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. Entities that do not fit in the current tick’s budget carry their accumulated priority into the next tick — so temporarily crowded budgets don’t starve low-priority entities forever.
Tip: A gain of
0.0prevents the entity from ever being selected by the send loop — effectively pausing replication for that entity without removing it from scope. Use this for entities that are temporarily invisible (behind a wall, in a fog of war zone) to free up bandwidth for visible entities.
Bandwidth budget
BandwidthConfig sets the per-connection outbound target:
#![allow(unused)]
fn main() {
use naia_bevy_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.
Connection diagnostics
Server::connection_stats(&user_key) and Client::connection_stats() return a
ConnectionStats snapshot computed on demand from internal ring buffers:
#![allow(unused)]
fn main() {
// 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);
}
}
| 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 |
kbps_sent | Rolling-average outgoing bandwidth in kilobits per second |
kbps_recv | Rolling-average incoming bandwidth in kilobits per second |
Note: Call
connection_statsat most once per frame per connection — it performs a small sort for the percentile computation.
For deeper bandwidth analysis, see Bandwidth Budget Analysis.
Delta Compression
naia uses per-field delta compression to minimize the bandwidth cost of replication updates. Only fields that actually changed are included in each outbound packet — unchanged fields are never sent.
How Property<T> works
Property<T> is naia’s change-detection wrapper. When a field inside
Property<T> is mutated via DerefMut, the containing entity is marked dirty
and only the changed fields are included in the next send_all_packets call.
naia tracks per-field diffs for each in-scope user independently.
#![allow(unused)]
fn main() {
#[derive(Replicate)]
pub struct Position {
pub x: Property<f32>,
pub y: Property<f32>,
}
// Mutating through DerefMut marks the component dirty:
position.x.set(42.0);
// Only `x` is dirty — `y` is not sent this tick.
}
Compact numeric types
Property<T> is generic over any T: Serde. naia ships a set of compact
numeric types in naia_bevy_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
#![allow(unused)]
fn main() {
use naia_bevy_shared::{Property, Replicate, Serde, SignedVariableFloat, UnsignedInteger};
#[derive(Clone, PartialEq, Serde)]
pub struct PositionState {
pub tile_x: 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.
Tip: For position data that changes by small deltas each tick (smooth movement),
SignedVariableFloatorSignedVariableIntegercan encode near-zero values in as few as 3–4 bits, vs. 32 bits for a baref32. Profile your actual packet sizes with the benchmark suite (cargo bench -p naia_bench) before and after to verify the gains.
See benches/src/bench_protocol.rs for working examples of PositionQ,
VelocityQ, and RotationQ using these types in a real benchmark scenario.
Static entities — no delta tracking
Static entities skip delta tracking entirely. When a static entity enters scope, naia sends a full component snapshot. After that no further updates are transmitted. Use them for any entity that is written once and never changes — map tiles, level geometry, etc.
zstd Compression & Dictionary Training
naia supports optional zstd packet compression on a per-direction basis. Compression is applied after naia’s internal bit-packing and quantization, and can be configured independently for each direction of the connection.
Configuration
#![allow(unused)]
fn main() {
use naia_bevy_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 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 for a representative play session; naia accumulates packet samples internally. |
Dictionary training workflow
Tip: A trained dictionary applied to your own game’s packet shape typically achieves 40–60% better compression ratios than zstd’s built-in defaults. The training step is a one-time cost.
- 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:
#![allow(unused)]
fn main() {
CompressionMode::Dictionary(
3,
include_bytes!("../assets/naia_dict.bin").to_vec(),
)
}
When to use compression
- Use it when bandwidth is the primary constraint (mobile clients, data-capped players, high entity counts).
- Skip it when CPU cost is more important than wire size (e.g. embedded servers, very high tick rates).
Note: Compression applies to the full packet payload after bit-packing. The naia quantized numeric types (
SignedVariableFloat,UnsignedInteger<N>, etc.) reduce the payload size before compression runs — combine both for maximum bandwidth efficiency.
Request / Response
naia supports typed request/response pairs over reliable bidirectional channels. This is useful for one-shot operations where the caller needs a reply: fetching a leaderboard entry, submitting an item purchase, or loading level data.
Defining a request/response pair
A request type derives Message and implements the Request trait, which
associates it with its response type. The response type derives Message and
implements the Response marker trait:
#![allow(unused)]
fn main() {
use naia_bevy_shared::{Message, Request, Response};
/// The request struct — carries the query parameters.
#[derive(Message)]
pub struct FetchScore {
pub player_id: u32,
}
impl Request for FetchScore {
type Response = FetchScoreResponse;
}
/// The response struct — carries the answer.
#[derive(Message)]
pub struct FetchScoreResponse {
pub score: u32,
pub rank: u32,
}
impl Response for FetchScoreResponse {}
}
There are public derives for Message, Channel, and Replicate. Request
and Response are marker traits: implement them as shown so naia knows which
response type belongs to which request.
Register the request type in the Protocol builder using add_request:
#![allow(unused)]
fn main() {
Protocol::builder()
.add_request::<FetchScore>()
.build()
}
Note: You register the request type only — the response type is discovered automatically through the
Request::Responseassociated type.
Sending a request (client)
#![allow(unused)]
fn main() {
// Returns Ok(ResponseReceiveKey) on success, Err if the channel is full.
let response_key = client.send_request::<RequestChannel, _>(
&FetchScore { player_id: 42 },
)?;
// Store `response_key` — you need it to match the reply when it arrives.
global.pending_requests.insert(response_key, PendingKind::FetchScore);
}
send_request returns a ResponseReceiveKey you use to match the reply.
Requests travel over a bidirectional reliable channel — register the channel
with ChannelDirection::Bidirectional and ChannelMode::OrderedReliable.
Handling the request (server)
#![allow(unused)]
fn main() {
use bevy_ecs::message::MessageReader;
use naia_bevy_server::{events::RequestEvents, Server};
fn handle_requests(mut server: Server, mut request_reader: MessageReader<RequestEvents>) {
for events in request_reader.read() {
for (_user_key, response_send_key, request) in
events.read::<RequestChannel, FetchScore>()
{
let score = db.lookup_score(request.player_id);
server.send_response(
&response_send_key,
&FetchScoreResponse { score, rank: 1 },
);
}
}
}
}
The server receives a response_send_key alongside the request. Pass it back
to send_response to route the reply to the correct client.
Receiving the response (client)
#![allow(unused)]
fn main() {
use naia_bevy_client::Client;
fn receive_responses(mut client: Client<Main>, mut global: ResMut<Global>) {
let mut finished = Vec::new();
for (response_key, _pending) in &global.pending_requests {
if let Some(response) = client.receive_response(response_key) {
println!("Score: {}, Rank: {}", response.score, response.rank);
finished.push(response_key.clone());
}
}
for response_key in finished {
global.pending_requests.remove(&response_key);
}
}
}
Bidirectional requests
Either side can send requests. In the Bevy demo, both the server and client issue requests to each other:
#![allow(unused)]
fn main() {
// Server sending a request to a client:
let response_key = server.send_request::<RequestChannel, _>(&user_key, &request)?;
// Client handling a request from the server and sending a response:
for (response_send_key, request) in events.read::<RequestChannel, BasicRequest>() {
client.send_response(&response_send_key, &BasicResponse { /* … */ });
}
}
TTL and disconnect cleanup
Pending requests are automatically cancelled when the connection drops. Unmatched
ResponseReceiveKey values become invalid and will not fire any event after
disconnect.
Tip: Use request/response for infrequent operations (level transitions, purchases, leaderboard queries). For high-frequency state that changes every tick, use entity replication instead — it is far more bandwidth-efficient thanks to per-field delta compression.
Full working example
See demos/bevy/shared/src/messages/basic_request.rs for the type definitions
and demos/bevy/server/src/systems/events.rs + demos/bevy/client/src/systems/events.rs
for the complete send/receive pattern.
Transports Overview
naia’s transport layer is selected with Cargo features and exposed through
naia_server::transport::*, naia_client::transport::*, and the matching Bevy
adapter re-exports.
| Feature | Modules | Targets | Encryption | Best for |
|---|---|---|---|---|
transport_webrtc | transport::webrtc | Native server, native clients, Wasm clients | DTLS | Production default; browser support; mixed native/browser populations |
transport_udp | transport::udp | Native only | None | Local dev, trusted LANs, custom secured deployments |
transport_local | transport::local | Same process | n/a | Tests, harnesses, bots |
Use naia-server, naia-client, or the Bevy adapter crates with the transport
feature you need; transport selection is feature/module based.
Default Recommendation
Start with WebRTC unless you have a specific reason not to. It works for native and browser clients, includes the WebRTC handshake and DTLS encryption, and keeps one server path for both desktop and Wasm builds.
Use UDP when you intentionally want plaintext native datagrams: local development, trusted networks, benchmarks, or a deployment where you are adding security at another layer and understand the tradeoff.
Use local when the network is not the thing being tested. It is how you make replication tests deterministic and pleasantly free of port conflicts.
Common Shape
The server and client APIs are the same regardless of transport:
#![allow(unused)]
fn main() {
// Server
server.listen(socket);
// Client
client.connect(socket);
}
The socket value changes; your protocol, rooms, replicated components, messages, authority, and prediction code do not.
Native UDP
transport_udp is naia’s native plaintext UDP transport. It is useful for local
development, trusted networks, benchmarks, and deployments where you deliberately
provide your own security layer. It should not be the default choice for
internet-facing games.
naia-server = { version = "0.25", features = ["transport_udp"] }
naia-client = { version = "0.25", features = ["transport_udp"] }
The transport is exposed as naia_server::transport::udp and
naia_client::transport::udp.
Server Setup
#![allow(unused)]
fn main() {
use naia_server::transport::udp;
let addrs = udp::ServerAddrs::new(
"0.0.0.0:14191".parse().unwrap(), // auth TCP
"0.0.0.0:14192".parse().unwrap(), // UDP data
"http://127.0.0.1:14192", // public UDP URL
);
let socket = udp::Socket::new(&addrs, None);
server.listen(socket);
}
The UDP transport uses a TCP auth handshake and then sends game traffic over UDP. Both auth payloads and game packets are plaintext.
Client Setup
#![allow(unused)]
fn main() {
use naia_client::transport::udp;
let socket = udp::Socket::new("http://127.0.0.1:14191", client.socket_config());
client.connect(socket);
}
Link Conditioning
UDP sockets accept an optional LinkConditionerConfig for latency, jitter, and
loss simulation:
#![allow(unused)]
fn main() {
use naia_shared::LinkConditionerConfig;
let lag = LinkConditionerConfig::average_condition();
let socket = udp::Socket::new(&addrs, Some(lag));
server.listen(socket);
}
Prefer WebRTC for production unless you are intentionally accepting the plaintext UDP tradeoff.
WebRTC (Native + Browser)
transport_webrtc is naia’s preferred transport for most projects. It supports
a native server, native clients, and wasm32-unknown-unknown browser clients
from the same server, with DTLS provided by WebRTC.
Enable it on the crate you use:
naia-bevy-server = { version = "0.25", features = ["transport_webrtc"] }
naia-bevy-client = { version = "0.25", features = ["transport_webrtc"] }
or, without Bevy:
naia-server = { version = "0.25", features = ["transport_webrtc"] }
naia-client = { version = "0.25", features = ["transport_webrtc"] }
Connection Flow
sequenceDiagram
participant C as Client (native or Wasm)
participant Sig as Signaling endpoint
participant S as naia Server
C->>Sig: HTTP offer / auth
Sig->>S: session setup
S->>Sig: answer
Sig->>C: answer
C->>S: ICE / WebRTC data path
S->>C: ICE / WebRTC data path
Note over C,S: DTLS handshake, data channel open
C->>S: naia ProtocolId handshake
S->>C: connected or rejected
Server Setup
#![allow(unused)]
fn main() {
use naia_bevy_server::{transport::webrtc, Server};
fn startup(mut server: Server) {
let addrs = webrtc::ServerAddrs::new(
"0.0.0.0:14191".parse().unwrap(), // signaling/auth HTTP
"0.0.0.0:14192".parse().unwrap(), // WebRTC data UDP
"https://game.example.com:14192", // public data URL clients can reach
);
let socket = webrtc::Socket::new(&addrs, server.socket_config());
server.listen(socket);
}
}
For local development, webrtc::ServerAddrs::default() uses localhost ports.
Client Setup
#![allow(unused)]
fn main() {
use naia_bevy_client::{transport::webrtc, Client};
fn startup(mut client: Client) {
let socket = webrtc::Socket::new(
"https://game.example.com:14191",
client.socket_config(),
);
client.connect(socket);
}
}
The same code shape works for native and Wasm clients. Browser builds still need the normal Rust Wasm target and a web bundler such as Trunk or wasm-pack.
Deployment Notes
- The signaling endpoint is HTTP(S). Put it behind TLS in production.
- The WebRTC data address must be reachable by clients. NAT/firewall rules still matter; WebRTC is not magic, just very well-dressed networking.
- Native and browser clients can connect to the same server at the same time.
- Use the exact same
Protocolon every target. Transport selection should not change your registered components, messages, channels, or resources.
Local (In-Process) Transport
transport_local runs the server and client in the same process with no real
network sockets. It is used by naia’s test harness and is ideal for:
- Unit tests — deterministic, no port conflicts, no OS networking stack.
- Headless AI bots — simulate a client without a network round-trip.
- Determinism checks — compare local-transport and real-network results.
Setup
The local transport is mostly a test-harness tool. The pieces are:
naia_shared::transport::local::LocalTransportHubnaia_server::transport::local::{LocalServerSocket, Socket}naia_client::transport::local::{LocalClientSocket, LocalAddrCell, Socket}
The repository’s contract harness and Bevy resource tests are the best reference
for complete setup because they wire the hub, auth queues, and data queues
directly. See test/harness/src/harness/scenario.rs and
adapters/bevy/server/tests/replicated_resources_bevy.rs.
Link conditioning
The local transport supports LinkConditionerConfig:
Pass Some(LinkConditionerConfig::poor_condition()) to the local client/server
socket wrapper to inject loss, latency, and jitter into in-process delivery.
This injects loss, latency, and jitter into the in-process message delivery — useful for testing your prediction and rollback logic without a real bad network.
Tip: Use
transport_local+LinkConditionerConfig::poor_condition()to stress-test your prediction/rollback handler before deploying on a real network.
Writing a Custom Transport
naia’s protocol logic talks to transport traits rather than directly to OS sockets. A custom transport can be built by implementing the server-side and client-side socket traits exposed from:
naia_server::transport::Socketnaia_client::transport::Socket
The built-in UDP, WebRTC, and local transports are the practical templates.
What a Transport Must Provide
The server-side socket is consumed by server.listen(socket) and produces four
handles:
- auth sender
- auth receiver
- packet sender
- packet receiver
The client-side socket is consumed by client.connect(socket) and produces:
- identity receiver
- packet sender
- packet receiver
That split is important. naia has an authentication/identity phase before normal
packet exchange, so a transport is more than a single send(bytes) function.
Best References
Start with the smallest built-ins:
server/src/transport/local/client/src/transport/local/
Then compare the production transports:
server/src/transport/webrtc.rsclient/src/transport/webrtc.rsserver/src/transport/udp.rsclient/src/transport/udp/
Those implementations show how to adapt an underlying network backend into naia’s auth, identity, sender, and receiver handles.
When to Write One
Custom transports are advanced. Reach for them when you need a network layer naia does not ship, such as a platform service, a managed relay, or a proprietary transport required by a console or storefront.
For most games, prefer transport_webrtc first.
Core API Overview
If you are using Bevy, you do not need this chapter — the Bevy adapter handles everything. This chapter is for macroquad users, custom engine users, or anyone who wants to use naia’s ECS-agnostic core API directly.
Bevy users: See Your First Server and Your First Client for the Bevy-first walkthroughs.
The five-step loop
naia’s core API requires you to run five methods in order every frame. This is the loop that the Bevy plugin automates for you:
use naia_server::{transport::webrtc, Server, ServerConfig};
use my_game_shared::protocol;
fn main() {
let mut server: Server<u32> = Server::new(ServerConfig::default(), protocol());
let socket = webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config());
server.listen(socket);
let mut world = MyWorld::new();
let room_key = server.create_room().key();
loop {
// 1. Read UDP/WebRTC datagrams from the OS into the receive queue.
server.receive_all_packets();
// 2. Decode packets into EntityEvent objects and apply client mutations.
server.process_all_packets();
// 3. Drain connection, spawn, update, and message events.
let events = server.take_world_events(&mut world);
for connect_event in events.read::<ConnectEvent>() {
let user_key = connect_event.user_key;
let entity = world.alloc_entity();
server
.spawn_entity(&mut world)
.insert_component(Position::new(0.0, 0.0));
server.room_mut(&room_key).add_user(&user_key);
server.room_mut(&room_key).add_entity(&entity);
}
// 4. Advance the tick clock. Mutate replicated components here.
for tick_event in server.take_tick_events() {
for entity in world.entities() {
if let Some(mut pos) = server.component_mut::<Position>(&mut world, &entity) {
*pos.x += 0.1;
}
}
}
// 5. Serialise field diffs and messages; flush to the network.
// MUST be the last step — any mutations after this are deferred
// to the next frame.
server.send_all_packets(&mut world);
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
The client loop is identical in structure but processes packets from a single server connection rather than from many users.
WorldMutType and WorldRefType
naia’s core crates are generic over the entity type E (any Copy + Eq + Hash + Send + Sync value) and over a world wrapper trait:
WorldMutType<E>— mutable world access: spawn entity, insert/remove components, despawn entity.WorldRefType<E>— immutable world access: read component values.
You implement these traits for your game world struct, or use the provided
implementations for Bevy (BevyWorldMut) and macroquad. The full trait
definitions and a minimal implementation template live in naia-shared.
graph TD
Server[naia-server] -->|uses| WorldMutType
Server -->|uses| WorldRefType
Client[naia-client] -->|uses| WorldMutType
Client -->|uses| WorldRefType
WorldMutType -->|implemented by| BevyWorld[BevyWorldMut<br/>naia-bevy-server]
WorldMutType -->|implemented by| MqWorld[Macroquad/custom world<br/>naia-client]
WorldMutType -->|implemented by| Custom[Your own impl]
Available adapters
| Adapter | Crate | Notes |
|---|---|---|
| Bevy | naia-bevy-server, naia-bevy-client | Full server + client support; recommended |
| macroquad | naia-client + mquad feature | Client via core API |
| Custom | Implement WorldMutType + WorldRefType | See Writing Your Own Adapter |
See also:
- Macroquad — macroquad client setup.
- Writing Your Own Adapter — implement the adapter traits for a custom engine.
Macroquad
Macroquad clients use the core naia-client crate directly. There is no
separate macroquad adapter crate.
The macroquad demo in demos/macroquad/ is the canonical reference: it pairs a
native naia-server with a macroquad/miniquad client and a shared
naia-shared protocol crate.
Cargo Setup
# shared/Cargo.toml
[dependencies]
naia-shared = { version = "0.25", features = ["mquad"] }
# client/Cargo.toml
[dependencies]
naia-client = { version = "0.25", features = ["mquad", "transport_webrtc"] }
macroquad = "0.3"
my-game-shared = { path = "../shared" }
# server/Cargo.toml
[dependencies]
naia-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
Loop Shape
In macroquad, naia’s client loop lives inside your frame loop:
#[macroquad::main("My Game")]
async fn main() {
let mut client = Client::new(ClientConfig::default(), protocol());
let socket = naia_client::transport::webrtc::Socket::new(
"http://127.0.0.1:14191",
client.socket_config(),
);
client.connect(socket);
loop {
client.receive_all_packets();
client.process_all_packets(&mut world);
// Read connection/entity/component/message events.
// Mutate your local world and render with macroquad.
client.send_all_packets(&mut world);
next_frame().await;
}
}
The exact world implementation is up to your game. The demo uses
naia-demo-world, a small world wrapper that implements the core world traits.
For a production game, you can keep that shape or implement the traits for your
own storage.
Writing Your Own Adapter
Implementing a naia adapter for a custom game framework requires implementing
two traits: WorldMutType<E> and WorldRefType<E>.
WorldMutType<E>
Provides mutable world access that naia uses to spawn, modify, and despawn entities:
#![allow(unused)]
fn main() {
pub trait WorldMutType<E: Copy + Eq + Hash> {
fn spawn_entity(&mut self) -> E;
fn despawn_entity(&mut self, entity: &E);
fn insert_component<C: Replicate>(&mut self, entity: &E, component: C);
fn remove_component<C: Replicate>(&mut self, entity: &E) -> Option<C>;
fn component_mut<C: Replicate>(&mut self, entity: &E) -> Option<ReplicateMut<C>>;
// … additional methods
}
}
WorldRefType<E>
Provides immutable world access for reading component values:
#![allow(unused)]
fn main() {
pub trait WorldRefType<E: Copy + Eq + Hash> {
fn has_entity(&self, entity: &E) -> bool;
fn component<C: Replicate>(&self, entity: &E) -> Option<&C>;
// … additional methods
}
}
Minimal adapter skeleton
#![allow(unused)]
fn main() {
pub struct MyWorld {
entities: HashMap<u32, EntityData>,
next_id: u32,
}
impl WorldMutType<u32> for MyWorld {
fn spawn_entity(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
self.entities.insert(id, EntityData::default());
id
}
fn despawn_entity(&mut self, entity: &u32) {
self.entities.remove(entity);
}
// … implement remaining methods
}
}
Tip: The best references for a minimal custom world are
demos/demo_utils/demo_worldanddemos/demo_utils/empty_world. They show the core world traits without Bevy’s adapter layer.
Bandwidth Budget Analysis
Understanding and tuning your game’s bandwidth consumption is critical for scaling to more concurrent users and supporting mobile clients.
Running the benchmark suite
cargo bench -p naia_bench
The benchmark suite (benches/) includes bench_protocol.rs with realistic
position, velocity, and rotation component types using naia’s quantized numeric
types. Run it before and after tuning to verify improvements.
Reading connection stats in production
#![allow(unused)]
fn main() {
// Server: sample every N ticks
if tick % 60 == 0 {
for user_key in server.user_keys() {
if let Some(stats) = server.connection_stats(&user_key) {
println!(
"user={:?} rtt_p50={:.0}ms p99={:.0}ms loss={:.1}% out={:.1}kbps in={:.1}kbps",
user_key,
stats.rtt_p50_ms, stats.rtt_p99_ms,
stats.packet_loss_pct * 100.0,
stats.kbps_sent, stats.kbps_recv
);
}
}
}
}
For interpreting the kbps_sent values and RTT percentiles in production, see
Connection Diagnostics.
Tuning checklist
-
Use quantized numeric types — replace
Property<f32>withProperty<SignedVariableFloat<BITS, FRAC>>for position/velocity. See Delta Compression. -
Use static entities for map geometry — zero per-tick cost after initial scope entry. See Entity Replication.
-
Set entity priority gain — entities the player can’t see get
0.0gain (never sent); player-owned entity gets3.0(replicated 3× more often). See Priority-Weighted Bandwidth. -
Enable zstd compression —
CompressionMode::Default(3)reduces wire size ~30% with minimal CPU overhead. See zstd Compression. -
Train a dictionary — reduces wire size a further 40–60% vs default zstd on typical game-state delta packets.
-
Reduce tick rate for non-interactive entities — use priority gain to effectively reduce the rate without a separate “slow replication” pathway.
Connection Diagnostics
naia exposes connection health metrics via ConnectionStats. These are computed
on demand from internal ring buffers and cover the full picture of link quality.
Available metrics
| 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 |
Sampling
#![allow(unused)]
fn main() {
// Server side — sample once per second (not every frame):
if let Some(stats) = server.connection_stats(&user_key) {
// log or push to your metrics backend
}
// Client side:
let stats = client.connection_stats();
}
Warning:
connection_statsperforms a small sort for the percentile computation. Call it at most once per frame per connection — not inside a hot inner loop.
metrics and tracing integration
For production observability, naia ships optional feature-gated integration with
the metrics and tracing ecosystems. Enable via the metrics feature flag:
# server/Cargo.toml
naia-bevy-server = { version = "0.25", features = ["metrics"] }
When metrics is enabled, naia emits counters and gauges compatible with any
metrics-crate backend (Prometheus, StatsD, etc.). See the naia-metrics and
naia-bevy-metrics crates for the full list of emitted metric names.
Interpreting the numbers
- rtt_p99 > 300 ms — players on this connection will feel prediction
corrections. Consider widening
TickBufferSettingsand deepeningCommandHistory. - packet_loss_pct > 0.02 (2%) — entity updates may be delayed or arrive
out of order. Test your rollback handler with
LinkConditionerConfig::poor_condition(). - kbps_sent near target_bytes_per_sec — the entity list is bandwidth-limited. Use priority gain to prioritize the most important entities; consider enabling zstd compression.
Benchmarking
naia ships a benchmark suite using Criterion and iai-callgrind.
Running benchmarks
# Criterion throughput benchmarks
cargo bench -p naia_bench
# iai-callgrind instruction-count benchmarks (requires Valgrind)
cargo bench --bench iai -p naia_bench
What the bench suite covers
bench_protocol— serialization and deserialization of typical game component types (PositionQ,VelocityQ,RotationQ) using quantized numeric types. This is the baseline for measuring wire-size improvements from tuningProperty<T>types.bench_replication— ahalo_btb_16v16scenario: 16 clients receiving replication from 16 server entities, measuring per-tick send throughput.bench_histogram— priority accumulator sorting and bandwidth allocation under various entity counts.
Adding your own benchmarks
Add a file under benches/src/ and register it in benches/Cargo.toml. Use
naia’s quantized types and a realistic component layout to get numbers that
reflect real game workloads.
Tip: Run benchmarks on the same hardware before and after a change. Criterion’s output includes regression detection — a >5% regression on
bench_replicationis worth investigating before merging.
Scaling Considerations
naia is a single-process authority. Understanding its scaling envelope helps you right-size your server hardware and plan for growth.
What determines concurrency limits
- Bandwidth — each connected user consumes approximately
target_bytes_per_secof outbound bandwidth. At the default 512 kbps per user, a 1 Gbps uplink supports ~2 000 concurrent users if all users are actively receiving replication. Real workloads use far less due to priority-weighted allocation and scope filtering — entities outside a user’s scope send nothing. - CPU — the bottleneck is typically the send loop (priority sort +
serialization per connected user). Profile with
cargo bench --bench iai -p naia_benchto measure instruction counts per tick at your target entity and user counts. - Memory — Historian snapshot buffers are the dominant memory consumer on
servers with many entities. Use
enable_historian_filteredto limit snapshots to the component types you actually query.
Reducing per-user bandwidth cost
- Scope filtering — entities outside a user’s
UserScopeare never sent. A user seeing only 10% of the world’s entities uses only ~10% of the bandwidth of a user seeing everything. - Priority gain
0.0— entities with gain0.0are never selected by the send loop, even if they are in scope. Use this for temporarily invisible entities (fog of war, behind walls). - Static entities — map geometry sent once, never diff-tracked.
- Quantized numeric types —
SignedVariableFloatencodes near-zero per-tick deltas in 3–4 bits vs 32 bits for a baref32. - zstd compression — reduces the wire size of fully-packed packets by 20–40% (default dictionary) or 40–60% (trained dictionary).
Horizontal scaling
naia is a single-process authority with no built-in cross-process state sharing. For games that need horizontal scaling, use 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 ────────────┘
(entity hand-off, cross-zone messages, matchmaking)
When a player moves between zones:
- Serialize the player’s replicated component state on the source server.
- Send it to the destination server via your coordination channel (Redis, gRPC, direct TCP — your choice).
- Despawn the entity on the source server (the client receives
DespawnEntityEvent). - Spawn the entity on the destination server and add the client to the new room.
See Entity Replication — Multi-Server / Zone Architecture for more detail.
Reference numbers (naia bench, native UDP)
From the halo_btb_16v16 benchmark scenario (16 clients × 16 server entities,
20 Hz, basic position replication with quantized types):
| Metric | Value |
|---|---|
| Idle client latency | ~44 µs |
| Per-client send cost | ~722 ns |
| P95 tick duration | < 1 ms |
These are best-case loopback numbers. Real workloads with more components, larger entity counts, and real network conditions will differ. Use the benchmark suite as a relative baseline — run before and after any architectural change.
cargo bench -p naia_bench
cargo bench --bench iai -p naia_bench # requires Valgrind (instruction counts)
Hosting recommendations
- Dedicated bare-metal — offers the best CCU-per-dollar ratio. Providers like Hetzner, OVH, and Vultr bare-metal give you predictable CPU and network without cloud overhead.
- Cloud VMs — convenient for auto-scaling but higher cost per CCU. Works fine for development and low-volume production.
- Cloudflare / CDN edge — appropriate for serving HTML/JS/WASM assets. Do not route naia’s UDP data traffic through a CDN — the overhead at game networking packet rates is not worth the cost.
- Multiple servers — run one naia process per match or zone; use a lightweight coordination layer (a matchmaker, a Redis pub/sub, or a simple TCP relay) for cross-process coordination. This is the recommended path for horizontal scaling.
Tip: Profile your specific component layout and entity count before optimizing. The priority accumulator, scope filtering, and static entities typically close most of the gap between theoretical limits and real workloads before you need horizontal scaling.
Feature Matrix
Replication
| Feature | Notes |
|---|---|
| Entity replication | Per-field deltas through Property<T> |
| Static entities | Full snapshot on scope entry, no per-tick diff tracking |
| Replicated resources | Singleton values carried by hidden replicated entities |
| Client-authoritative entities | Opt-in via Protocol::enable_client_authoritative_entities() |
| Entity publication | Client-owned Private, Public, and Delegated states |
| Reconnect correctness | Re-sends in-scope entities and resources after reconnect |
Authority
| Feature | Notes |
|---|---|
| Server-owned default model | Ordinary server-spawned entities/resources are server-owned |
| Authority delegation | Clients can request temporary authority over delegated entities/resources |
| Server authority control | Server can grant, deny, revoke, and reclaim authority |
| Scope-aware authority | Authority operations respect scope and delegated status |
Interest And Bandwidth
| Feature | Notes |
|---|---|
| Rooms | Coarse interest groups |
UserScope | Fine-grained per-user visibility |
| Scope exit policy | Despawn or persist/freeze when leaving scope |
| Priority-weighted bandwidth | Per-entity/per-user gain with token-bucket send loop |
| Per-connection bandwidth budgets | Target bytes per second |
| Message backpressure | Reliable channel queue limits return errors instead of silently growing forever |
Messaging And Time
| Feature | Notes |
|---|---|
| Typed messages | Reliable/unreliable, ordered/unordered, sequenced, and tick-buffered modes |
| Typed request/response | Request trait associates each request with its response |
| Tick synchronization | Server/client ticks, RTT-aware timing, interpolation fractions |
| Prediction primitives | TickBuffered, CommandHistory, local duplicate patterns |
| Lag compensation | Historian snapshot buffer, including component-kind filtering |
Transports
| Feature | Notes |
|---|---|
| WebRTC transport | Native and Wasm clients; DTLS; recommended production path |
| UDP transport | Native plaintext transport for dev/trusted/custom-secured deployments |
| Local transport | In-process deterministic tests and harnesses |
| Link conditioning | Loss, latency, and jitter presets for supported transports |
Adapters And Tooling
| Feature | Notes |
|---|---|
| Bevy adapter | Server, client, shared protocol helpers, replicated resources |
| Macroquad/core path | Uses naia-client directly with mquad support |
| Custom world integration | Implement WorldMutType and WorldRefType |
| Metrics integration | naia-metrics and naia-bevy-metrics |
| Contract test harness | Scenario/spec coverage for replication, authority, scope, and transport behavior |
| Benchmarks and fuzzing | Criterion/iai-callgrind benches and protocol/serde fuzz targets |
Serialization
| Feature | Notes |
|---|---|
| Bit-level serialization | Compact bit-packing |
| Quantized numeric types | Fixed-width and variable-width integer/float helpers |
| zstd compression | Optional default, custom dictionary, and dictionary-training modes |
| Enum messages | Supported by #[derive(Message)] |
Security & Trust Model
naia is a networking library, not an anti-cheat or identity platform. It gives you transport choices, typed auth payloads, authority boundaries, and the hooks to validate client-originated state. Your game still owns trust decisions.
Prefer WebRTC For Production
transport_webrtc is the recommended starting point for internet-facing games.
It works for native and browser clients and gets DTLS from WebRTC. Use it unless
you have a concrete reason to choose plaintext UDP.
transport_udp sends auth and game packets in plaintext. It is appropriate for
local development, trusted LANs, controlled benchmarks, or teams intentionally
wrapping/securing it themselves.
| Transport | Encryption | Recommended use |
|---|---|---|
transport_webrtc | DTLS | Production native/browser clients |
transport_udp | None | Local dev, trusted LANs, explicit custom security |
transport_local | n/a | Same-process tests |
Authority Boundaries
Server-owned undelegated entities are only written by the server. Clients affect them by sending input/messages, and the server decides what state changes.
Client-authoritative entities are opt-in through
Protocol::enable_client_authoritative_entities(). Once enabled, client-owned
entities can replicate to the server and, if public, to other scoped clients.
Delegated entities/resources are server-owned, but a client may temporarily hold write authority after the server grants it. The server can revoke authority.
Danger: naia replicates client-originated values; it does not decide whether those values are fair. Validate positions, inventory changes, cooldowns, purchases, and every other client-originated mutation before making it game truth.
Authentication
naia supports application-layer authentication via a typed Message sent during
the handshake:
#![allow(unused)]
fn main() {
use naia_bevy_shared::Message;
#[derive(Message)]
pub struct Auth {
pub username: String,
pub token: String,
}
}
Client:
#![allow(unused)]
fn main() {
client.auth(Auth {
username: "alice".into(),
token: jwt_token,
});
client.connect(socket);
}
Server:
#![allow(unused)]
fn main() {
for events in auth_events.read() {
for (user_key, auth) in events.read::<Auth>() {
if validate_token(&auth.token) {
server.accept_connection(&user_key);
} else {
server.reject_connection(&user_key);
}
}
}
}
When credentials matter, use WebRTC or another encrypted deployment path. Do not send secrets over plaintext UDP and then act surprised when plaintext behaves like plaintext.
What naia Does Not Provide
- Anti-cheat decisions.
- Rate limiting for application-level spam.
- Password/session-token storage.
- Protection against malicious but protocol-valid component values.
- P2P trust negotiation.
Those belong in your game server and infrastructure.
Comparing naia to Alternatives
This comparison is intentionally scoped to libraries a Rust multiplayer game developer is likely to evaluate near naia: lightyear, bevy_replicon, and matchbox. Some tools operate at different layers, so treat this as a decision guide, not a courtroom exhibit.
Updated 2026-05.
External docs checked during this pass: lightyear, bevy_replicon, and matchbox_socket.
Feature Matrix
| Capability | naia | lightyear | bevy_replicon | matchbox |
|---|---|---|---|---|
| Entity replication | Yes, ECS-agnostic core + Bevy adapter | Yes, Bevy-focused | Yes, Bevy-focused | No |
| Native + browser clients | WebRTC transport supports both | Wasm supported via WebTransport path | Depends on chosen transport | WebRTC sockets for browser/native use cases |
| Bevy integration | Yes | Yes | Yes | Via ecosystem glue |
| Non-Bevy integration | Core API + custom world traits | Not the focus | Not the focus | Yes, socket-level |
| Server-authoritative model | Yes | Yes | Yes | No, lower-level/P2P-oriented |
| Client-authoritative entities | Yes, opt-in | Varies by model | Not a direct equivalent | n/a |
| Authority delegation | Entities and resources | Entity authority model | Not a primary feature | n/a |
| Lag compensation primitive | Historian | Not a direct built-in equivalent | Not a direct built-in equivalent | n/a |
| Priority bandwidth allocation | Per-entity/per-user gain | Not a direct equivalent | Not a direct equivalent | n/a |
| Replicated resources | Yes | Bevy resource patterns differ | Yes, Bevy resources | n/a |
| Compression | Optional zstd + dictionary training | Check current feature set | Transport-dependent | Transport/socket-level |
naia vs lightyear
Both projects cover server-authoritative replication, authority, prediction building blocks, and Bevy users. lightyear has a polished Bevy-first prediction and interpolation framework. naia is stronger when you want:
- WebRTC as a built-in naia transport for both native and Wasm clients.
- An ECS-agnostic core that can support Bevy, macroquad, or a custom world.
- Explicit client-authoritative entity publication.
- Delegated replicated resources.
- Historian-based lag compensation as a library primitive.
- Priority-weighted bandwidth allocation.
- Optional zstd compression and dictionary training.
Choose lightyear when you want a Bevy-native stack with more prediction framework provided for you. Choose naia when transport flexibility, authority flexibility, and bandwidth/lag-compensation primitives matter more than having a batteries- included interpolation layer.
naia vs bevy_replicon
bevy_replicon is a narrower Bevy replication library. It can be a good fit when you want straightforward Bevy state replication and prefer to bring your own transport and higher-level networking policy.
naia brings more machinery:
- Built-in transports, with WebRTC as the recommended path.
- Rooms plus per-user scope.
- Client-owned entities and publication states.
- Delegated entities/resources.
- Historian, prediction primitives, and priority bandwidth.
- A non-Bevy core API.
That machinery is valuable for larger or more network-sensitive games. For a small Bevy-only project, bevy_replicon may be less to learn.
naia vs matchbox
matchbox is primarily a WebRTC socket/signaling toolkit, often used for P2P and rollback architectures. It is closer to a transport/session layer than a full entity replication library.
Use matchbox when you want WebRTC sockets and plan to build your own replication or deterministic rollback layer. Use naia when you want replicated entities, messages, authority, scopes, and bandwidth management above the transport.
They can also be complementary conceptually: matchbox-style tooling is a good fit for P2P rollback games, while naia is built around a server-mediated world.
Bevy Adapter
Crates: naia-bevy-shared, naia-bevy-server, naia-bevy-client
The Bevy adapter wraps naia’s core crates and exposes Server / Client as
Bevy resources, routes naia events into Bevy messages, and provides
CommandsExt extension methods for entity replication. If you are using Bevy,
use these crates instead of naia-server / naia-client directly.
The T phantom type parameter
When using the Bevy client adapter, the Client SystemParam and Plugin<T>
carry a generic type parameter T:
#![allow(unused)]
fn main() {
use naia_bevy_client::{Client, Plugin};
#[derive(Resource)]
pub struct MyClient;
app.add_plugins(Plugin::<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 — a split-screen game where each half is a separate session, or a
relay node bridging two servers. The T phantom marker lets Bevy distinguish the
two Client SystemParams at compile time. They become different Bevy resources
with no runtime overhead.
T must satisfy Resource (a Bevy bound) plus Sync + Send + 'static. A
#[derive(Resource)] unit struct always satisfies this.
Single-client shorthand
For apps with only one naia client, use DefaultClientTag and DefaultPlugin to
skip the boilerplate:
#![allow(unused)]
fn main() {
use naia_bevy_client::{DefaultPlugin, Client, DefaultClientTag};
app.add_plugins(DefaultPlugin::new(client_config, protocol()));
fn my_system(client: Client<DefaultClientTag>) { /* … */ }
}
DefaultClientTag is a unit struct defined in naia-bevy-client. Use it
everywhere T appears: the plugin, the Client<T> SystemParam, and the event
types.
Plugin registration
Server
#![allow(unused)]
fn main() {
use naia_bevy_server::{Plugin, ServerConfig};
app.add_plugins(Plugin::new(ServerConfig::default(), protocol()));
}
Client
#![allow(unused)]
fn main() {
use naia_bevy_client::{Plugin, ClientConfig, DefaultClientTag};
app.add_plugins(Plugin::<DefaultClientTag>::new(
ClientConfig::default(),
protocol(),
));
}
System ordering
naia’s Bevy plugins register their packet and world-sync systems internally. In
most apps you can read naia messages and mutate replicated components in normal
Update systems; the plugin handles receive/process/send ordering around them.
Advanced apps can order relative to the exported shared system sets when needed.
#![allow(unused)]
fn main() {
use naia_bevy_shared::{ProcessPackets, SendPackets};
app.configure_sets(Update, MyGameSet::Logic.after(ProcessPackets).before(SendPackets));
}
Warning: If your simulation systems run before packet processing, they will see old network events. If they run after send, mutations made this frame wait for the next frame.
Handling events
naia events are exposed through Bevy’s message system, so normal systems should
read them with MessageReader<T>. Client-side event types carry the client tag
T; server-side event types do not.
#![allow(unused)]
fn main() {
use bevy_ecs::message::MessageReader;
use naia_bevy_server::{events::{ConnectEvent, DisconnectEvent}, Server};
fn handle_connections(
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut disconnect_reader: MessageReader<DisconnectEvent>,
) {
for ConnectEvent(user_key) in connect_reader.read() {
println!("User connected: {:?}", user_key);
// Spawn entities, add to rooms, etc.
}
for DisconnectEvent(user_key, address, reason) in disconnect_reader.read() {
println!("User disconnected: {:?} {:?} {:?}", user_key, address, reason);
}
}
}
Client event types
#![allow(unused)]
fn main() {
use naia_bevy_client::events::{
ConnectEvent,
DisconnectEvent,
SpawnEntityEvent,
DespawnEntityEvent,
InsertComponentEvent,
UpdateComponentEvent,
MessageEvents,
};
}
Server event types
#![allow(unused)]
fn main() {
use naia_bevy_server::events::{
ConnectEvent,
DisconnectEvent,
AuthEvents,
MessageEvents,
TickEvent,
};
}
Entity replication via CommandsExt
The Bevy adapter provides CommandsExt extension methods on Bevy’s Commands
that mirror the core naia entity API:
#![allow(unused)]
fn main() {
use naia_bevy_server::CommandsExt;
fn spawn_player(
mut commands: Commands,
mut server: Server,
user_key: UserKey,
) {
// Spawn a replicated entity.
let entity = commands
.spawn_empty()
.enable_replication(&mut server) // registers the entity with naia
.insert(Position::new(0.0, 0.0))
.id();
// Place the user and entity in a shared room.
server.room_mut(&room_key).add_user(&user_key);
server.room_mut(&room_key).add_entity(&entity);
}
}
Note:
enable_replicationmust be called beforeinsert_componenton the entity. naia only diff-tracks components on entities it knows about.
Sending messages
#![allow(unused)]
fn main() {
// Server → specific client:
server.send_message::<GameChannel, _>(&user_key, &ChatMessage { text: "Hello".into() })?;
// Client → server:
client.send_message::<GameChannel, _>(&ChatMessage { text: "Hi".into() });
}
Multi-client setup
For games with two simultaneous naia clients:
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct ClientA;
#[derive(Resource)]
pub struct ClientB;
app.add_plugins(Plugin::<ClientA>::new(config_a, protocol()))
.add_plugins(Plugin::<ClientB>::new(config_b, protocol()));
fn system_a(client: Client<ClientA>) { /* … */ }
fn system_b(client: Client<ClientB>) { /* … */ }
}
Both clients are fully independent: separate connections, separate event queues,
separate entity sets. Bevy routes events to the correct client based on the T
type parameter.
Full working example
See demos/bevy/ for a complete Bevy demo covering entity replication, authority
delegation, and client-side prediction. The key files are:
demos/bevy/server/src/systems/events.rs— server event handling, room setup, tick loop, and entity spawning.demos/bevy/client/src/systems/events.rs— client event handling, prediction loop, and rollback correction.demos/bevy/shared/src/— sharedProtocol, components, channels, and movement behavior used by both sides.
Glossary
Protocol And Transport
Protocol — Shared registry of components, resources, messages, requests, and channels. Both sides must build the same protocol.
ProtocolId — Deterministic hash of the protocol. A mismatch rejects the handshake.
Channel — Named lane for messages with reliability/ordering settings.
Transport — Network layer below naia. Shipped features are WebRTC, UDP, and local in-process transport.
WebRTC — Encrypted data-channel transport used by naia for native and Wasm clients.
RTT — Round-trip time.
Jitter — Variation in packet delay.
Replication
Entity — A world object registered with naia for replication.
Component — Replicated data attached to an entity.
Property<T> — Change-detection wrapper for replicated component fields.
Replicated resource — Singleton value replicated through a hidden one-component entity.
Delta compression — Sending changed fields instead of full component state.
Static entity — Entity that sends one full snapshot on scope entry and does not diff-track after that.
Room — Coarse interest group. A user and entity must share a room before the entity can replicate to that user.
UserScope — Per-user visibility filter applied after rooms.
ScopeExit — Despawn-or-persist behavior when an entity leaves a user’s scope.
Authority
Server-owned — Ordinary server-spawned replicated state; clients observe but do not directly write.
Client-authoritative entity — Opt-in entity created/owned by a client and replicated to the server.
Publicity — Client-owned entity state: Private, Public, or Delegated.
Authority delegation — Server-owned entity/resource mode where a client may temporarily hold write authority.
Granted / Denied / Available — Client-visible authority states for delegated objects.
Time And Prediction
Tick — Simulation heartbeat.
TickBuffered — Channel mode that stamps messages with the client tick and delivers them at the matching server tick.
Confirmed entity — Server-replicated entity used as the authoritative local copy in prediction.
Predicted entity — Client-local duplicate that runs ahead using local input.
local_duplicate() — Helper that creates a local predicted copy of a
replicated entity.
Rollback — Reset to confirmed state at a past tick, then replay buffered inputs.
Historian — Server-side rolling snapshot buffer used for lag compensation.
Bandwidth And Serialization
Bandwidth budget — Target outbound bytes per second for a connection.
Priority accumulator — Per-entity/per-user priority state used to decide what fits in the current budget.
Token bucket — Budget mechanism that accumulates send capacity over time.
Bit-packing — Serializing at bit granularity instead of byte granularity.
zstd — Optional packet compression algorithm with dictionary support.
API Docs (docs.rs)
Published naia crates:
naia-client— core client APInaia-server— core server APInaia-shared— protocol, channels, replication primitivesnaia-derive— derive macros re-exported by shared cratesnaia-serde— bit-level serialization primitivesnaia-serde-derive— serde derive macrosnaia-client-socket— lower-level client socket supportnaia-server-socket— lower-level server socket supportnaia-socket-shared— shared socket types/configurationnaia-bevy-client— Bevy client adapternaia-bevy-server— Bevy server adapternaia-bevy-shared— Bevy shared protocol/component helpersnaia-metrics— optional metrics primitivesnaia-bevy-metrics— optional Bevy metrics integration
This book explains concepts and patterns. docs.rs is the source for exact type signatures, trait bounds, feature flags, and rustdoc examples.
FAQ
Choosing naia
Should I start with WebRTC or UDP?
Start with WebRTC. It supports native and browser clients and includes DTLS. Use UDP for local development, trusted LANs, benchmarks, or custom-secured deployments where plaintext is an intentional choice.
Is naia Bevy-only?
No. The Bevy adapter is the recommended path for Bevy games, but the core
naia-server, naia-client, and naia-shared crates are ECS-agnostic.
Macroquad uses the core client directly with the mquad feature.
Does naia do P2P rollback networking?
No. naia is built around a server-mediated replicated world. For deterministic P2P rollback games, look at GGRS/matchbox-style stacks.
Crates And Setup
Which crates do I use for Bevy?
Use naia-bevy-shared in your shared crate, naia-bevy-server in the server,
and naia-bevy-client in the client. The Bevy server/client crates re-export
the common shared primitives used by app code.
Is there a macroquad adapter crate?
No. Use naia-client with the mquad feature and implement/use a core world
wrapper. See demos/macroquad/.
Why does my client get rejected on connect?
The most common cause is a protocol mismatch. Both sides must register the same components, messages, requests, resources, channels, tick settings, and relevant feature-gated types.
Replication
Why is my Bevy entity not replicating?
You must call enable_replication() on the entity. A Bevy entity with a
Replicate component is still local until naia is told to track it.
What does Publicity::Private mean?
For a client-owned replicated entity, Private means it replicates to the
server but is not published to other clients. For a truly local-only object, do
not enable naia replication.
Can clients spawn replicated entities?
Yes, if the protocol opts in with enable_client_authoritative_entities().
Treat this as a trust boundary: validate client-originated spawns and mutations
on the server.
Can resources be delegated?
Yes. Replicated resources are hidden one-component entities internally and can be configured as delegated resources.
Prediction And Time
Does naia provide client-side prediction?
naia provides primitives: tick-buffered input, command history, local duplicate helpers, tick synchronization, and correction hooks. You still write the actual prediction/interpolation policy for your game.
Does naia provide lag compensation?
Yes. The Historian stores per-tick snapshots so the server can evaluate events
against the world a client saw when it acted.
Messages
Do messages use Property<T>?
No. Property<T> is for replicated component fields. Messages are serialized in
full each time they are sent.
Are Request and Response derives?
No. Derive Message, then implement the Request and Response marker traits.
The request’s associated Response type tells naia how to register and route
the pair.
Changelog
This page mirrors the repository changelog at the time the book was updated.
For the newest release history, see
CHANGELOG.md.
Unreleased
Breaking Changes
spawn_static_entityremoved. Useserver.spawn_entity(world).as_static()before the first component insert.insert_static_resourceremoved. Useserver.insert_resource(world, value, true)for static resources andserver.insert_resource(world, value, false)for dynamic resources.- Client
WorldEvents<E>renamed toEvents<E>. make_roomrenamed tocreate_room.resource_count()renamed toresources_count().room_count()onUserRefrenamed torooms_count().- Client-side
ReplicationConfigwas replaced byPublicity. server.send_messagenow returnsResult<(), NaiaServerError>.- Server
EntityMut::insert_componentsbatch variant removed.
Added
entity_is_delegatedpredicate onServer<E>.EntityMut::as_static()builder method.- Server-initiated authority APIs:
give_authorityandtake_authority. - Reconnect handling that re-delivers all in-scope entities and replicated resources after reconnect.
Changed
- Internal test/tool crate names were kebab-cased.
- Local-transport hub debug output now uses
log::debug!.
Fixed
- Removed UB-prone lifetime transmute in local transport receivers.
- Bounded pending handshake maps to mitigate spoofed source-address floods.
- Fixed orphaned pending-handshake entries on early disconnect.
- Removed dead migration-response stub.
- Added stale-key-safe
user_opt/user_mut_optaccessors. - Added pending-auth timeout auto-rejection.
- Changed unknown-entity receive handling from panic to warn-and-discard.
- Improved URL parsing panic context.
- Added safety comments to all remaining
unsafeblocks.