Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 clientstransport_webrtc works for native and wasm32-unknown-unknown clients 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 Historian snapshots 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

CrateRoleUse when…
naia-sharedProtocol definition, derives, channel typesWriting the shared protocol crate
naia-serverCore serverWriting a server without Bevy
naia-clientCore clientWriting a client without Bevy or macroquad
naia-bevy-sharedBevy protocol/resource/component helpersWriting a Bevy shared crate
naia-bevy-serverBevy server adapterUsing Bevy on the server
naia-bevy-clientBevy client adapterUsing Bevy on the client
naia-metrics / naia-bevy-metricsOptional diagnostics integrationExporting runtime metrics

Quick concepts

  • Protocol — the shared type registry. Both server and client build from the same Protocol value; 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_events advances 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

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 Historian gives 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: TickBuffered channels, CommandHistory, local_duplicate()
  • Lag compensation via the Historian snapshot 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 Interp component 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

CrateFeatureUse when
naia-server, naia-clienttransport_webrtcPreferred native + browser transport; DTLS via WebRTC
naia-bevy-server, naia-bevy-clienttransport_webrtcSame transport through the Bevy adapter
naia-server, naia-clienttransport_udpNative plaintext UDP for local dev/trusted networks
naia-server, naia-clienttransport_localIn-process tests and deterministic harnesses
naia-client, naia-sharedwbindgenCore-client wrapper crates targeting wasm-bindgen
naia-client, naia-sharedmquadminiquad/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-client API 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_replication is an extension method from CommandsExt. It registers the entity with naia’s replication tracker. Without it, inserting a Position component 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, and send_all_packets automatically 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

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-server API 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: NaiaServerPlugin inserts receive_all_packets, process_all_packets, and send_all_packets as 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_replication is the critical call. Without it naia does not know the entity exists and will not send SpawnEntity packets to clients. The insert(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_packets after 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

  1. Plugin: receive_all_packets reads WebRTC/UDP packets.
  2. Plugin: process_all_packets decodes packets into Bevy messages.
  3. Your systems: handle_connections and handle_disconnections drain connection messages.
  4. Your systems: handle_tick advances simulation.
  5. Plugin: send_all_packets serializes field diffs and flushes to the network.

On a client connecting for the first time, step 5 delivers:

  • A SpawnEntity packet for each entity in the user’s scope.
  • A full component snapshot (all Position field values) for each such entity.

On subsequent frames, only changed fields travel over the wire.


Next steps

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-client API 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_packets
  • process_all_packets
  • send_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. SpawnEntityEvent carries that Entity handle — you can pass it to Query, 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

MessageWhen it is emitted
ConnectEventHandshake complete; connection established
DisconnectEventConnection dropped (timeout or explicit)
SpawnEntityEventServer spawned an entity now in your scope
DespawnEntityEventEntity 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
ClientTickEventClient tick elapsed; send input here
MessageEventsServer sent typed messages; call events.read::<Channel, Message>()
PublishEntityEventA delegated entity was published to the server
UnpublishEntityEventA 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

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-client API 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 Protocol values disagree — even a single missing add_component call — the handshake fails silently from the client’s perspective. Always build the Protocol from a shared crate imported by both sides.

The shared crate typically contains:

  • Protocol construction
  • All #[derive(Replicate)] component types
  • All #[derive(Message)] types, including request/response structs that implement the Request / Response marker 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 separate Property<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 ProtocolId is 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-client API 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_packets must 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:

  1. Serializes the player’s replicated state on the source server.
  2. Sends the state to the destination server via your coordination channel.
  3. Despawns the entity on the source server (client gets a despawn event).
  4. 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-client API is identical in concept but uses a direct method-call style instead of Bevy systems. See Core API Overview.


Channel modes

ModeOrderingReliabilityTypical use
UnorderedUnreliableNoneNoneFire-and-forget telemetry
SequencedUnreliableNewest-winsNonePosition updates (drop stale)
UnorderedReliableNoneGuaranteedOne-off notifications
OrderedReliableFIFOGuaranteedChat, game events
TickBufferedPer tickGuaranteedClient input (tick-stamped)
Bidirectional + ReliableFIFOGuaranteedRequest / 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: Message types do not use Property<> wrappers — that wrapper is only for Replicate components 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-client API 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::Persist for 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-client API 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_latency setting 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-client API 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:

TargetImplementationSocket typeEncryption
Native (Linux/macOS/Windows)WebRTC data channeltransport_webrtcDTLS
Browser (wasm32-unknown-unknown)WebRTC data channeltransport_webrtcDTLS
Native dev / trusted LANUDP datagram sockettransport_udpNone
Same-process testsIn-process queuestransport_localn/a

Warning: transport_udp sends 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:

PresetLatency (ms)Jitter (ms)Loss
perfect_condition()100%
very_good_condition()1230.1%
good_condition()40100.2%
average_condition()100252%
poor_condition()200504%
very_poor_condition()300756%

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 + InsertComponentEvent sequences.
  • 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, CommandHistory buffers).
  • 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 TickBuffered channels; 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 Granted state 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:

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_authority through the Bevy CommandsExt API.
  • 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 Position component, 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:

  1. The entity and the user share at least one room.
  2. The entity is included in the user’s UserScope.
  3. The entity has a ReplicationConfig that 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

VariantEffect
ReplicationConfig::public() / defaultReplicated 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.0 is 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 a DespawnEntityEvent (or the entity is frozen in place if ScopeExit::Persist is 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:

  1. Snaps the predicted state to the authoritative server state.
  2. 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_buffer behind 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_filtered in 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 rateTarget max lagmax_ticks
20 Hz500 ms10
20 Hz3 s (generous buffer)64
60 Hz200 ms12
60 Hz500 ms30

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_tick is older than max_ticks. Without this check a malicious client can query arbitrarily old state.

Danger: Always clamp the look-back window server-side. Accept fire_tick only if server_tick - fire_tick <= max_ticks. A client that sends a very old fire_tick can otherwise cause snapshot_at_tick to 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.0 prevents 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);
}
}
FieldDescription
rtt_msRound-trip time EWMA in milliseconds
rtt_p50_msRTT 50th-percentile from the last 32 samples
rtt_p99_msRTT 99th-percentile from the last 32 samples
jitter_msEWMA of half the absolute RTT deviation
packet_loss_pctFraction of sent packets unacknowledged in the last 64-packet window
kbps_sentRolling-average outgoing bandwidth in kilobits per second
kbps_recvRolling-average incoming bandwidth in kilobits per second

Note: Call connection_stats at 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:

TypeWire sizeUse case
UnsignedInteger<N>exactly N bitshealth (0–255 → 8 bits), flags
SignedInteger<N>exactly N bitsrelative 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 bitspositive position, speed
SignedFloat<BITS, FRAC>exactly BITS bitssigned angle, velocity axis
SignedVariableFloat<BITS, FRAC>1–BITS bitsper-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), SignedVariableFloat or SignedVariableInteger can encode near-zero values in as few as 3–4 bits, vs. 32 bits for a bare f32. 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:

ModeWhen 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.

  1. Set CompressionMode::Training(2000) in your development build.
  2. Run a representative play session (2000 packets ≈ a few minutes at 20 Hz).
  3. Extract the trained dictionary from the server’s CompressionEncoder and save it to a file (e.g. assets/naia_dict.bin).
  4. 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::Response associated 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.

FeatureModulesTargetsEncryptionBest for
transport_webrtctransport::webrtcNative server, native clients, Wasm clientsDTLSProduction default; browser support; mixed native/browser populations
transport_udptransport::udpNative onlyNoneLocal dev, trusted LANs, custom secured deployments
transport_localtransport::localSame processn/aTests, 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);
}

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 Protocol on 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::LocalTransportHub
  • naia_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.


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::Socket
  • naia_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.rs
  • client/src/transport/webrtc.rs
  • server/src/transport/udp.rs
  • client/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

AdapterCrateNotes
Bevynaia-bevy-server, naia-bevy-clientFull server + client support; recommended
macroquadnaia-client + mquad featureClient via core API
CustomImplement WorldMutType + WorldRefTypeSee Writing Your Own Adapter

See also:

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_world and demos/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

  1. Use quantized numeric types — replace Property<f32> with Property<SignedVariableFloat<BITS, FRAC>> for position/velocity. See Delta Compression.

  2. Use static entities for map geometry — zero per-tick cost after initial scope entry. See Entity Replication.

  3. Set entity priority gain — entities the player can’t see get 0.0 gain (never sent); player-owned entity gets 3.0 (replicated 3× more often). See Priority-Weighted Bandwidth.

  4. Enable zstd compressionCompressionMode::Default(3) reduces wire size ~30% with minimal CPU overhead. See zstd Compression.

  5. Train a dictionary — reduces wire size a further 40–60% vs default zstd on typical game-state delta packets.

  6. 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

FieldDescription
rtt_msRound-trip time EWMA in milliseconds
rtt_p50_msRTT 50th-percentile from the last 32 samples
rtt_p99_msRTT 99th-percentile from the last 32 samples
jitter_msEWMA of half the absolute RTT deviation
packet_loss_pctFraction of sent packets unacknowledged in the last 64-packet window (0.01.0)
kbps_sentRolling-average outgoing bandwidth in kilobits per second
kbps_recvRolling-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_stats performs 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 TickBufferSettings and deepening CommandHistory.
  • 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 tuning Property<T> types.
  • bench_replication — a halo_btb_16v16 scenario: 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_replication is 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_sec of 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_bench to 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_filtered to limit snapshots to the component types you actually query.

Reducing per-user bandwidth cost

  1. Scope filtering — entities outside a user’s UserScope are never sent. A user seeing only 10% of the world’s entities uses only ~10% of the bandwidth of a user seeing everything.
  2. Priority gain 0.0 — entities with gain 0.0 are never selected by the send loop, even if they are in scope. Use this for temporarily invisible entities (fog of war, behind walls).
  3. Static entities — map geometry sent once, never diff-tracked.
  4. Quantized numeric typesSignedVariableFloat encodes near-zero per-tick deltas in 3–4 bits vs 32 bits for a bare f32.
  5. 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:

  1. Serialize the player’s replicated component state on the source server.
  2. Send it to the destination server via your coordination channel (Redis, gRPC, direct TCP — your choice).
  3. Despawn the entity on the source server (the client receives DespawnEntityEvent).
  4. 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):

MetricValue
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

FeatureNotes
Entity replicationPer-field deltas through Property<T>
Static entitiesFull snapshot on scope entry, no per-tick diff tracking
Replicated resourcesSingleton values carried by hidden replicated entities
Client-authoritative entitiesOpt-in via Protocol::enable_client_authoritative_entities()
Entity publicationClient-owned Private, Public, and Delegated states
Reconnect correctnessRe-sends in-scope entities and resources after reconnect

Authority

FeatureNotes
Server-owned default modelOrdinary server-spawned entities/resources are server-owned
Authority delegationClients can request temporary authority over delegated entities/resources
Server authority controlServer can grant, deny, revoke, and reclaim authority
Scope-aware authorityAuthority operations respect scope and delegated status

Interest And Bandwidth

FeatureNotes
RoomsCoarse interest groups
UserScopeFine-grained per-user visibility
Scope exit policyDespawn or persist/freeze when leaving scope
Priority-weighted bandwidthPer-entity/per-user gain with token-bucket send loop
Per-connection bandwidth budgetsTarget bytes per second
Message backpressureReliable channel queue limits return errors instead of silently growing forever

Messaging And Time

FeatureNotes
Typed messagesReliable/unreliable, ordered/unordered, sequenced, and tick-buffered modes
Typed request/responseRequest trait associates each request with its response
Tick synchronizationServer/client ticks, RTT-aware timing, interpolation fractions
Prediction primitivesTickBuffered, CommandHistory, local duplicate patterns
Lag compensationHistorian snapshot buffer, including component-kind filtering

Transports

FeatureNotes
WebRTC transportNative and Wasm clients; DTLS; recommended production path
UDP transportNative plaintext transport for dev/trusted/custom-secured deployments
Local transportIn-process deterministic tests and harnesses
Link conditioningLoss, latency, and jitter presets for supported transports

Adapters And Tooling

FeatureNotes
Bevy adapterServer, client, shared protocol helpers, replicated resources
Macroquad/core pathUses naia-client directly with mquad support
Custom world integrationImplement WorldMutType and WorldRefType
Metrics integrationnaia-metrics and naia-bevy-metrics
Contract test harnessScenario/spec coverage for replication, authority, scope, and transport behavior
Benchmarks and fuzzingCriterion/iai-callgrind benches and protocol/serde fuzz targets

Serialization

FeatureNotes
Bit-level serializationCompact bit-packing
Quantized numeric typesFixed-width and variable-width integer/float helpers
zstd compressionOptional default, custom dictionary, and dictionary-training modes
Enum messagesSupported 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.

TransportEncryptionRecommended use
transport_webrtcDTLSProduction native/browser clients
transport_udpNoneLocal dev, trusted LANs, explicit custom security
transport_localn/aSame-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

Capabilitynaialightyearbevy_repliconmatchbox
Entity replicationYes, ECS-agnostic core + Bevy adapterYes, Bevy-focusedYes, Bevy-focusedNo
Native + browser clientsWebRTC transport supports bothWasm supported via WebTransport pathDepends on chosen transportWebRTC sockets for browser/native use cases
Bevy integrationYesYesYesVia ecosystem glue
Non-Bevy integrationCore API + custom world traitsNot the focusNot the focusYes, socket-level
Server-authoritative modelYesYesYesNo, lower-level/P2P-oriented
Client-authoritative entitiesYes, opt-inVaries by modelNot a direct equivalentn/a
Authority delegationEntities and resourcesEntity authority modelNot a primary featuren/a
Lag compensation primitiveHistorianNot a direct built-in equivalentNot a direct built-in equivalentn/a
Priority bandwidth allocationPer-entity/per-user gainNot a direct equivalentNot a direct equivalentn/a
Replicated resourcesYesBevy resource patterns differYes, Bevy resourcesn/a
CompressionOptional zstd + dictionary trainingCheck current feature setTransport-dependentTransport/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_replication must be called before insert_component on 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/ — shared Protocol, 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:

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_entity removed. Use server.spawn_entity(world).as_static() before the first component insert.
  • insert_static_resource removed. Use server.insert_resource(world, value, true) for static resources and server.insert_resource(world, value, false) for dynamic resources.
  • Client WorldEvents<E> renamed to Events<E>.
  • make_room renamed to create_room.
  • resource_count() renamed to resources_count().
  • room_count() on UserRef renamed to rooms_count().
  • Client-side ReplicationConfig was replaced by Publicity.
  • server.send_message now returns Result<(), NaiaServerError>.
  • Server EntityMut::insert_components batch variant removed.

Added

  • entity_is_delegated predicate on Server<E>.
  • EntityMut::as_static() builder method.
  • Server-initiated authority APIs: give_authority and take_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_opt accessors.
  • 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 unsafe blocks.