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

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