Your First Server
This chapter builds the server from the Bevy Quick Start step by step, explaining each piece. By the end you will understand why the Bevy plugin works the way it does and how to extend it for a real game.
Core API: Not using Bevy? The bare
naia-serverAPI is identical in concept but uses a direct method-call loop instead of Bevy systems. See Core API Overview.
Project layout
my_game/
shared/ ← protocol, components, channels (Cargo.toml: naia-bevy-shared)
server/ ← naia-bevy-server binary
client/ ← naia-bevy-client binary (next chapter)
Cargo.toml
# server/Cargo.toml
[package]
name = "my-game-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "server"
path = "src/main.rs"
[dependencies]
bevy = { version = "0.18", default-features = false, features = ["bevy_core_pipeline"] }
naia-bevy-server = { version = "0.25", features = ["transport_webrtc"] }
my-game-shared = { path = "../shared" }
Step 1 — Plugin setup
NaiaServerPlugin replaces naia’s five-step loop with Bevy-native scheduling.
You provide a ServerConfig (timeouts, heartbeat rate) and the shared Protocol.
use bevy::prelude::*;
use naia_bevy_server::{Plugin as NaiaServerPlugin, ServerConfig};
use my_game_shared::protocol;
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()))
.run();
}
Note:
NaiaServerPlugininsertsreceive_all_packets,process_all_packets, andsend_all_packetsas Bevy systems at fixed positions in the schedule. You never call those methods manually — the plugin owns the loop.
Step 2 — System ordering
The plugin arranges naia’s work around your systems automatically:
sequenceDiagram
participant Bevy as Bevy Update schedule
participant Naia as NaiaServerPlugin
Naia->>Bevy: receive_all_packets (first in frame)
Naia->>Bevy: process_all_packets
Bevy->>Bevy: YOUR event-reading systems run here
Bevy->>Bevy: YOUR tick systems run here
Naia->>Bevy: send_all_packets (last in frame)
Your systems only need to read events and mutate components — ordering relative to naia’s internal systems is handled.
Step 3 — Startup listener
The startup system calls server.listen(...) once. The Server type is a Bevy
SystemParam that gives direct access to naia’s server handle.
#![allow(unused)]
fn main() {
use naia_bevy_server::{transport::webrtc, Server};
fn startup(mut server: Server) {
server.listen(webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config()));
println!("Server listening on 0.0.0.0:14191");
}
}
Add it to the app:
#![allow(unused)]
fn main() {
.add_systems(Startup, startup)
}
Step 4 — Handling connections
ConnectEvent fires once per new client immediately after the handshake
completes. This is the right place to create rooms and spawn player entities.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::ecs::message::MessageReader;
use naia_bevy_server::{
CommandsExt, RoomKey, Server,
events::{ConnectEvent, DisconnectEvent},
};
use my_game_shared::Position;
use std::collections::HashMap;
#[derive(Resource, Default)]
struct UserEntities(HashMap<u64, Entity>);
#[derive(Resource)]
struct GlobalRoom(Option<RoomKey>);
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut global_room: ResMut<GlobalRoom>,
mut user_entities: ResMut<UserEntities>,
) {
let room_key = *global_room.0.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
let entity = commands
.spawn_empty()
.enable_replication(&mut server) // ← registers entity with naia
.insert(Position::new(0.0, 0.0))
.id();
server.room_mut(&room_key).add_user(user_key);
server.room_mut(&room_key).add_entity(&entity);
user_entities.0.insert(user_key.to_u64(), entity);
println!("Connected: {:?} → entity {:?}", user_key, entity);
}
}
}
Note:
enable_replicationis the critical call. Without it naia does not know the entity exists and will not sendSpawnEntitypackets to clients. Theinsert(Position::new(...))call happens on a naia-tracked entity, so naia immediately queues the initial component value for delivery.
Handling disconnections
#![allow(unused)]
fn main() {
fn handle_disconnections(
mut commands: Commands,
mut disconnect_reader: MessageReader<DisconnectEvent>,
mut user_entities: ResMut<UserEntities>,
) {
for DisconnectEvent(user_key, _address, _reason) in disconnect_reader.read() {
if let Some(entity) = user_entities.0.remove(&user_key.to_u64()) {
commands.entity(entity).despawn();
// naia automatically sends DespawnEntity to all in-scope clients.
}
}
}
}
Step 5 — The tick event
TickEvent fires once for each elapsed server tick (configured in the
Protocol as tick_interval). Mutation of replicated components belongs here.
#![allow(unused)]
fn main() {
use naia_bevy_server::events::TickEvent;
use my_game_shared::{InputChannel, PlayerInput, Position};
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
// Deliver TickBuffered input from clients at this exact tick.
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (user_key, input) in messages.read::<InputChannel, PlayerInput>() {
// Apply input to the entity owned by this user.
// (Look up entity in your UserEntities resource here.)
let _ = (user_key, input);
}
// Advance all positions.
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
// naia diffs each mutated Property<f32> field against the last
// acknowledged snapshot for each in-scope client.
}
}
}
Note: The plugin calls
send_all_packetsafter your tick systems complete. You do not need to — and must not — call it yourself.
Putting it together
// server/src/main.rs (complete)
use std::collections::HashMap;
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use naia_bevy_server::{
transport::webrtc,
CommandsExt, Plugin as NaiaServerPlugin, RoomKey, Server, ServerConfig,
events::{ConnectEvent, DisconnectEvent, TickEvent},
};
use my_game_shared::{protocol, InputChannel, PlayerInput, Position};
fn main() {
App::new()
.add_plugins(MinimalPlugins)
.add_plugins(NaiaServerPlugin::new(ServerConfig::default(), protocol()))
.insert_resource(UserEntities::default())
.insert_resource(GlobalRoom(None))
.add_systems(Startup, startup)
.add_systems(Update, (handle_connections, handle_disconnections, handle_tick))
.run();
}
#[derive(Resource, Default)]
struct UserEntities(HashMap<u64, Entity>);
#[derive(Resource)]
struct GlobalRoom(Option<RoomKey>);
fn startup(mut server: Server) {
server.listen(webrtc::Socket::new(&webrtc::ServerAddrs::default(), server.socket_config()));
}
fn handle_connections(
mut commands: Commands,
mut server: Server,
mut connect_reader: MessageReader<ConnectEvent>,
mut global_room: ResMut<GlobalRoom>,
mut user_entities: ResMut<UserEntities>,
) {
let room_key = *global_room.0.get_or_insert_with(|| server.create_room().key());
for ConnectEvent(user_key) in connect_reader.read() {
let entity = commands
.spawn_empty()
.enable_replication(&mut server)
.insert(Position::new(0.0, 0.0))
.id();
server.room_mut(&room_key).add_user(user_key);
server.room_mut(&room_key).add_entity(&entity);
user_entities.0.insert(user_key.to_u64(), entity);
}
}
fn handle_disconnections(
mut commands: Commands,
mut disconnect_reader: MessageReader<DisconnectEvent>,
mut user_entities: ResMut<UserEntities>,
) {
for DisconnectEvent(user_key, _address, _reason) in disconnect_reader.read() {
if let Some(entity) = user_entities.0.remove(&user_key.to_u64()) {
commands.entity(entity).despawn();
}
}
}
fn handle_tick(
mut server: Server,
mut tick_reader: MessageReader<TickEvent>,
mut positions: Query<&mut Position>,
) {
for TickEvent(server_tick) in tick_reader.read() {
let mut messages = server.receive_tick_buffer_messages(server_tick);
for (_user_key, _input) in messages.read::<InputChannel, PlayerInput>() {}
for mut pos in positions.iter_mut() {
*pos.x += 0.1;
}
}
}
What happens per frame
- Plugin:
receive_all_packetsreads WebRTC/UDP packets. - Plugin:
process_all_packetsdecodes packets into Bevy messages. - Your systems:
handle_connectionsandhandle_disconnectionsdrain connection messages. - Your systems:
handle_tickadvances simulation. - Plugin:
send_all_packetsserializes field diffs and flushes to the network.
On a client connecting for the first time, step 5 delivers:
- A
SpawnEntitypacket for each entity in the user’s scope. - A full component snapshot (all
Positionfield values) for each such entity.
On subsequent frames, only changed fields travel over the wire.
Next steps
- Your First Client — receive those events on the client side.
- Running the Demos — run the complete
demos/bevy/example. - Rooms & Scoping — fine-grained per-entity visibility.
- Messages & Channels — typed message passing.
- Tick Synchronization — tick rate,
TickBufferedinput, and client-side prediction.