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
- Completed Build a Simple World or have a working Aether project
- Two terminal windows available
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
- Create a new crate with separate server and client binaries:
cargo new --lib my-multiplayer
mkdir -p my-multiplayer/src/bin
- 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
- 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
- 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
- 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.
- 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;
}
}
- 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 + 1onward.
Run Server and Client
- Start the server in terminal 1:
cargo run -p my-multiplayer --bin server
- Connect a client in terminal 2:
cargo run -p my-multiplayer --bin client
- 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:
| Variable | Default | Purpose |
|---|---|---|
AETHER_SERVER_PORT | 4433 | Server bind port |
AETHER_NET_SERVER_ADDR | 127.0.0.1:4433 | Client connect address |
AETHER_NET_IDLE_TIMEOUT_SECS | 30 | Idle disconnect timeout |
AETHER_NET_RECONNECT_TIMEOUT_SECS | 30 | Reconnect 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-zoningfor scaling beyond 20 players with zone-based load balancing