Add Multiplayer

Add networked multiplayer to an existing Aether world using QUIC transport, state synchronization, and client-side prediction.

In this tutorial you will take a single-player Aether world and make it multiplayer. You will set up a QUIC server, connect a client, synchronize entity state, and add client-side prediction. By the end you will run a server and client in two terminals with shared avatar state.

This tutorial references the multiplayer-demo example in the Aether repository.

Prerequisites

Architecture Overview

Aether multiplayer uses a server-authoritative model. The server runs simulation at 60 Hz, validates inputs, and broadcasts state deltas. Clients send inputs, receive updates, and use interpolation and prediction to render smoothly. Transport uses QUIC (via quinn) for multiplexed reliable streams and unreliable datagrams over a single encrypted connection.

Set Up the Project

  1. Create a new crate with separate server and client binaries:
cargo new --lib my-multiplayer
mkdir -p my-multiplayer/src/bin
  1. Configure my-multiplayer/Cargo.toml:
[package]
name = "my-multiplayer"
version = "0.1.0"
edition = "2021"

[dependencies]
aether-ecs = { path = "../crates/aether-ecs" }
aether-network = { path = "../crates/aether-network" }
aether-world-runtime = { path = "../crates/aether-world-runtime" }
serde = { version = "1", features = ["derive"] }
bincode = "1"

[[bin]]
name = "server"
path = "src/bin/server.rs"

[[bin]]
name = "client"
path = "src/bin/client.rs"

Define the Protocol

  1. Create shared message types in my-multiplayer/src/lib.rs:
use serde::{Deserialize, Serialize};
pub type PlayerId = u64;

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AvatarState {
    pub head_position: [f32; 3],
    pub head_rotation: [f32; 4],
    pub left_hand_position: [f32; 3],
    pub left_hand_rotation: [f32; 4],
    pub right_hand_position: [f32; 3],
    pub right_hand_rotation: [f32; 4],
}

#[derive(Serialize, Deserialize, Debug)]
pub enum ClientMessage {
    InputUpdate { tick: u64, avatar: AvatarState },
    Ping { client_time_ms: u64 },
}

#[derive(Serialize, Deserialize, Debug)]
pub enum ServerMessage {
    WorldState { tick: u64, avatars: Vec<(PlayerId, AvatarState)> },
    PlayerJoined { player_id: PlayerId },
    PlayerLeft { player_id: PlayerId },
    Pong { client_time_ms: u64, server_time_ms: u64 },
}

Build the Server

  1. Create my-multiplayer/src/bin/server.rs:
use aether_network::quic::{QuicConfig, QuicServer};
use aether_world_runtime::tick::TickScheduler;
use aether_world_runtime::input_buffer::InputBuffer;
use aether_world_runtime::state_sync::StateSyncManager;
use aether_world_runtime::session::SessionManager;
use my_multiplayer::*;

const TICK_RATE: u32 = 60;
const MAX_PLAYERS: usize = 20;

fn main() {
    let port = std::env::var("AETHER_SERVER_PORT").unwrap_or_else(|_| "4433".into());
    let config = QuicConfig { bind_addr: format!("0.0.0.0:{}", port), ..QuicConfig::default() };
    let mut server = QuicServer::new(config).expect("Failed to bind");
    println!("Server listening on 0.0.0.0:{}", port);

    let mut tick_scheduler = TickScheduler::new(TICK_RATE);
    let mut input_buffer = InputBuffer::new(MAX_PLAYERS);
    let mut state_sync = StateSyncManager::new();
    let mut sessions = SessionManager::new();
    let mut last_time = std::time::Instant::now();

    loop {
        let now = std::time::Instant::now();
        let elapsed_us = now.duration_since(last_time).as_micros() as u64;
        last_time = now;

        // Accept new connections
        while let Some(conn) = server.accept() {
            let pid = conn.client_id();
            sessions.player_connected(pid);
            state_sync.register_entity(pid);
            let msg = ServerMessage::PlayerJoined { player_id: pid };
            server.broadcast_reliable(&bincode::serialize(&msg).unwrap());
        }

        // Receive and buffer client inputs
        for (cid, data) in server.recv_all() {
            if let Ok(ClientMessage::InputUpdate { tick, avatar }) =
                bincode::deserialize(&data) {
                input_buffer.buffer_input(cid, tick, avatar);
            }
        }

        // Run simulation ticks
        let ticks = tick_scheduler.update(elapsed_us);
        for server_tick in &ticks {
            for (pid, avatar) in input_buffer.drain_tick(server_tick.tick_number) {
                state_sync.update_entity(pid, avatar);
            }
        }

        // Broadcast latest state via unreliable datagrams
        if let Some(last) = ticks.last() {
            let msg = ServerMessage::WorldState {
                tick: last.tick_number,
                avatars: state_sync.all_entities(),
            };
            server.broadcast_datagram(&bincode::serialize(&msg).unwrap());
        }

        std::thread::sleep(std::time::Duration::from_millis(1));
    }
}

Build the Client

  1. Create my-multiplayer/src/bin/client.rs:
use aether_network::quic::{QuicConfig, QuicClient};
use aether_world_runtime::prediction::InterpolationBuffer;
use my_multiplayer::*;

fn main() {
    let addr = std::env::var("AETHER_NET_SERVER_ADDR")
        .unwrap_or_else(|_| "127.0.0.1:4433".into());
    let config = QuicConfig { server_addr: addr.clone(), ..QuicConfig::default() };
    let mut client = QuicClient::connect(config).expect("Failed to connect");
    println!("Connected to {}", addr);

    let mut local_tick: u64 = 0;
    let mut interpolation = InterpolationBuffer::new(10);

    loop {
        // Send local avatar input (replace with real VR tracking)
        let avatar = AvatarState {
            head_position: [0.0, 1.7, 0.0],
            head_rotation: [0.0, 0.0, 0.0, 1.0],
            left_hand_position: [-0.3, 1.0, -0.4],
            left_hand_rotation: [0.0, 0.0, 0.0, 1.0],
            right_hand_position: [0.3, 1.0, -0.4],
            right_hand_rotation: [0.0, 0.0, 0.0, 1.0],
        };
        let msg = ClientMessage::InputUpdate { tick: local_tick, avatar };
        client.send_datagram(&bincode::serialize(&msg).unwrap());

        // Receive server state and interpolate
        while let Some(data) = client.recv() {
            if let Ok(ServerMessage::WorldState { tick, avatars }) =
                bincode::deserialize(&data) {
                interpolation.push_snapshot(tick, &avatars);
            }
        }
        let _render_avatars = interpolation.interpolate(local_tick);

        local_tick += 1;
        std::thread::sleep(std::time::Duration::from_nanos(1_000_000_000 / 60));
    }
}

Add Client-Side Prediction

Client-side prediction shows local movement immediately without waiting for a server round-trip.

  1. Add a prediction buffer to the client:
struct PredictionBuffer {
    predicted: Vec<(u64, AvatarState)>,
    last_confirmed_tick: u64,
}

impl PredictionBuffer {
    fn new() -> Self { Self { predicted: Vec::new(), last_confirmed_tick: 0 } }

    fn push(&mut self, tick: u64, state: AvatarState) {
        self.predicted.push((tick, state));
        const MAX_UNCONFIRMED: usize = 120;
        if self.predicted.len() > MAX_UNCONFIRMED { self.predicted.remove(0); }
    }

    fn reconcile(&mut self, server_tick: u64, _server_state: &AvatarState) {
        self.predicted.retain(|(t, _)| *t > server_tick);
        self.last_confirmed_tick = server_tick;
    }
}
  1. In the client loop, store predictions before sending and reconcile when server state arrives. If the server state differs from the predicted state at that tick, discard stale predictions and replay inputs from server_tick + 1 onward.

Run Server and Client

  1. Start the server in terminal 1:
cargo run -p my-multiplayer --bin server
  1. Connect a client in terminal 2:
cargo run -p my-multiplayer --bin client
  1. Start a second client in terminal 3 to test multi-player sync:
cargo run -p my-multiplayer --bin client

Both clients receive PlayerJoined messages and see each other's avatar state.

Configuration

All settings use environment variables:

VariableDefaultPurpose
AETHER_SERVER_PORT4433Server bind port
AETHER_NET_SERVER_ADDR127.0.0.1:4433Client connect address
AETHER_NET_IDLE_TIMEOUT_SECS30Idle disconnect timeout
AETHER_NET_RECONNECT_TIMEOUT_SECS30Reconnect window

Running the Bundled Demo

The Aether repository includes a complete multiplayer demo:

cargo run -p multiplayer-demo --bin mp-server   # Terminal 1
cargo run -p multiplayer-demo --bin mp-client   # Terminal 2

How State Sync Works

The server uses delta compression for efficient broadcasting. Each entity has a last-acknowledged tick per client. Only changed fields are sent. Position updates use unreliable datagrams (latest-wins), while critical events like joins use reliable streams (guaranteed delivery).

Next Steps

  • Add game logic with Visual Scripting 101
  • Import a custom avatar (see Custom Avatar)
  • Explore aether-zoning for scaling beyond 20 players with zone-based load balancing