Rust for Game Servers
How we built a multiplayer game server handling 10,000+ concurrent connections with sub-20ms latency.
Why Rust for Game Servers?
Traditional game servers use C++ or Java, but Rust offers unique advantages:
- Memory Safety - No segfaults or memory leaks
- Performance - Zero-cost abstractions, as fast as C++
- Concurrency - Fearless concurrency without data races
- Type System - Catch bugs at compile time
We needed to support:
- 10,000+ simultaneous players
- Sub-20ms latency for real-time gameplay
- Authoritative server (no client-side cheating)
- Seamless matchmaking and room management
Architecture Overview
Client (Browser/Unity)
↓ WebSocket
Game Server (Rust)
↓
├─ Tokio Runtime (async I/O)
├─ Game Loop (60 tick/s)
├─ State Management
└─ Redis (matchmaking, leaderboards)
Setting Up the WebSocket Server
We use tokio for async I/O and tokio-tungstenite for WebSockets:
# Cargo.toml
[dependencies]
tokio = { version = "1.35", features = ["full"] }
tokio-tungstenite = "0.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Basic Server
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("0.0.0.0:9001").await.unwrap();
println!("WebSocket server listening on port 9001");
while let Ok((stream, addr)) = listener.accept().await {
tokio::spawn(handle_connection(stream, addr));
}
}
async fn handle_connection(stream: TcpStream, addr: SocketAddr) {
let ws_stream = accept_async(stream).await.expect("Failed to accept");
println!("New connection from: {}", addr);
let (mut write, mut read) = ws_stream.split();
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
println!("Received: {}", text);
// Echo back
write.send(Message::Text(text)).await.unwrap();
}
Ok(Message::Close(_)) => break,
_ => {}
}
}
}
Game State Management
Shared State with Arc and RwLock
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
#[derive(Clone)]
struct Player {
id: String,
x: f32,
y: f32,
health: i32,
}
struct GameRoom {
id: String,
players: HashMap<String, Player>,
created_at: Instant,
}
type SharedState = Arc<RwLock<HashMap<String, Arc<RwLock<GameRoom>>>>>;
#[tokio::main]
async fn main() {
let state: SharedState = Arc::new(RwLock::new(HashMap::new()));
// ... server setup
}
Thread-Safe Player Updates
async fn update_player_position(
state: SharedState,
room_id: &str,
player_id: &str,
x: f32,
y: f32,
) {
let rooms = state.read().await;
if let Some(room) = rooms.get(room_id) {
let mut room = room.write().await;
if let Some(player) = room.players.get_mut(player_id) {
player.x = x;
player.y = y;
}
}
}
Message Protocol
Binary messages are more efficient than JSON:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
enum ClientMessage {
Join { room_id: String, player_name: String },
Move { x: f32, y: f32 },
Attack { target_id: String },
Leave,
}
#[derive(Serialize, Deserialize)]
enum ServerMessage {
Joined { player_id: String },
PlayerJoined { player: Player },
PlayerMoved { player_id: String, x: f32, y: f32 },
PlayerLeft { player_id: String },
GameState { players: Vec<Player> },
}
Binary Encoding with MessagePack
[dependencies]
rmp-serde = "1.1"
use rmp_serde::{encode, decode};
// Encode
let message = ServerMessage::GameState { players: vec![] };
let bytes = rmp_serde::to_vec(&message).unwrap();
write.send(Message::Binary(bytes)).await.unwrap();
// Decode
if let Message::Binary(bytes) = msg {
let message: ClientMessage = rmp_serde::from_slice(&bytes).unwrap();
handle_client_message(message).await;
}
Game Loop
60 ticks per second game simulation:
async fn game_loop(state: SharedState) {
let mut interval = tokio::time::interval(Duration::from_millis(16)); // ~60 FPS
loop {
interval.tick().await;
let rooms = state.read().await;
for room in rooms.values() {
let mut room = room.write().await;
// Update physics
update_physics(&mut room);
// Check collisions
check_collisions(&mut room);
// Broadcast state to all players
broadcast_state(&room).await;
}
}
}
fn update_physics(room: &mut GameRoom) {
for player in room.players.values_mut() {
// Apply velocity, gravity, etc.
player.x += player.velocity_x * 0.016;
player.y += player.velocity_y * 0.016;
}
}
Broadcasting to Players
Efficiently send updates to all players in a room:
use tokio::sync::mpsc;
use futures_util::stream::SplitSink;
type Tx = mpsc::UnboundedSender<ServerMessage>;
struct GameRoom {
id: String,
players: HashMap<String, Player>,
connections: HashMap<String, Tx>, // player_id -> sender
}
async fn broadcast_state(room: &GameRoom) {
let state_msg = ServerMessage::GameState {
players: room.players.values().cloned().collect(),
};
for tx in room.connections.values() {
let _ = tx.send(state_msg.clone());
}
}
async fn handle_player_connection(
room: Arc<RwLock<GameRoom>>,
player_id: String,
write: SplitSink<WebSocketStream, Message>,
) {
let (tx, mut rx) = mpsc::unbounded_channel();
// Register player
{
let mut room = room.write().await;
room.connections.insert(player_id.clone(), tx);
}
// Send messages to this player
while let Some(msg) = rx.recv().await {
let bytes = rmp_serde::to_vec(&msg).unwrap();
write.send(Message::Binary(bytes)).await.unwrap();
}
// Cleanup on disconnect
{
let mut room = room.write().await;
room.connections.remove(&player_id);
room.players.remove(&player_id);
}
}
Performance Optimizations
1. Object Pooling
Reuse allocations for frequently created objects:
use std::sync::Arc;
use parking_lot::Mutex;
struct ObjectPool<T> {
objects: Arc<Mutex<Vec<T>>>,
factory: fn() -> T,
}
impl<T> ObjectPool<T> {
fn new(factory: fn() -> T, initial_size: usize) -> Self {
let objects = (0..initial_size).map(|_| factory()).collect();
Self {
objects: Arc::new(Mutex::new(objects)),
factory,
}
}
fn acquire(&self) -> T {
let mut pool = self.objects.lock();
pool.pop().unwrap_or_else(|| (self.factory)())
}
fn release(&self, obj: T) {
let mut pool = self.objects.lock();
pool.push(obj);
}
}
// Usage
lazy_static! {
static ref MESSAGE_POOL: ObjectPool<Vec<u8>> = ObjectPool::new(
|| Vec::with_capacity(1024),
100
);
}
let mut buffer = MESSAGE_POOL.acquire();
// Use buffer...
MESSAGE_POOL.release(buffer);
2. Spatial Partitioning
Don’t check every player against every other player:
struct QuadTree {
bounds: Rectangle,
capacity: usize,
players: Vec<Player>,
children: Option<Box<[QuadTree; 4]>>,
}
impl QuadTree {
fn insert(&mut self, player: Player) {
if !self.bounds.contains(player.x, player.y) {
return;
}
if self.players.len() < self.capacity {
self.players.push(player);
return;
}
if self.children.is_none() {
self.subdivide();
}
if let Some(children) = &mut self.children {
for child in children.iter_mut() {
child.insert(player.clone());
}
}
}
fn query(&self, range: Rectangle) -> Vec<&Player> {
let mut found = Vec::new();
if !self.bounds.intersects(&range) {
return found;
}
for player in &self.players {
if range.contains(player.x, player.y) {
found.push(player);
}
}
if let Some(children) = &self.children {
for child in children.iter() {
found.extend(child.query(range.clone()));
}
}
found
}
}
3. Delta Compression
Only send what changed:
#[derive(Clone, PartialEq)]
struct PlayerState {
x: f32,
y: f32,
health: i32,
// ...
}
struct PlayerDelta {
id: String,
x: Option<f32>,
y: Option<f32>,
health: Option<i32>,
}
fn compute_delta(old: &PlayerState, new: &PlayerState) -> Option<PlayerDelta> {
let mut delta = PlayerDelta {
id: old.id.clone(),
x: None,
y: None,
health: None,
};
let mut has_changes = false;
if (old.x - new.x).abs() > 0.01 {
delta.x = Some(new.x);
has_changes = true;
}
if (old.y - new.y).abs() > 0.01 {
delta.y = Some(new.y);
has_changes = true;
}
if old.health != new.health {
delta.health = Some(new.health);
has_changes = true;
}
if has_changes {
Some(delta)
} else {
None
}
}
Client Prediction and Reconciliation
Server-Side
struct PlayerInput {
sequence: u32,
timestamp: u64,
dx: f32,
dy: f32,
}
fn process_input(player: &mut Player, input: PlayerInput) {
// Apply input
player.x += input.dx;
player.y += input.dy;
// Store for reconciliation
player.last_processed_input = input.sequence;
}
fn send_state_update(player: &Player, tx: &Tx) {
let msg = ServerMessage::PlayerMoved {
player_id: player.id.clone(),
x: player.x,
y: player.y,
last_input: player.last_processed_input,
};
tx.send(msg);
}
Client-Side (JavaScript)
class Client {
constructor() {
this.inputSequence = 0
this.pendingInputs = []
this.position = { x: 0, y: 0 }
}
sendInput(dx, dy) {
const input = {
sequence: this.inputSequence++,
timestamp: Date.now(),
dx,
dy
}
// Send to server
this.ws.send(JSON.stringify({
type: 'INPUT',
...input
}))
// Apply immediately (prediction)
this.position.x += dx
this.position.y += dy
// Store for reconciliation
this.pendingInputs.push(input)
}
onServerUpdate(serverX, serverY, lastProcessedInput) {
// Remove inputs that server has processed
this.pendingInputs = this.pendingInputs.filter(
input => input.sequence > lastProcessedInput
)
// Reconcile: start from server state, replay pending inputs
this.position.x = serverX
this.position.y = serverY
for (const input of this.pendingInputs) {
this.position.x += input.dx
this.position.y += input.dy
}
}
}
Matchmaking
Redis for fast matchmaking:
[dependencies]
redis = { version = "0.24", features = ["tokio-comp"] }
use redis::AsyncCommands;
async fn find_match(client: &mut redis::aio::Connection, player_id: &str) -> Option<String> {
// Add player to matchmaking queue
let _: () = client.zadd("matchmaking", player_id, chrono::Utc::now().timestamp()).await.ok()?;
// Try to find opponents
let players: Vec<String> = client.zrange("matchmaking", 0, 3).await.ok()?;
if players.len() >= 4 {
// Found enough players, create room
let room_id = uuid::Uuid::new_v4().to_string();
// Remove players from queue
for player in &players {
let _: () = client.zrem("matchmaking", player).await.ok()?;
}
// Create room
let _: () = client.set(format!("room:{}", room_id), serde_json::to_string(&players).unwrap()).await.ok()?;
Some(room_id)
} else {
None
}
}
Monitoring and Metrics
Prometheus metrics:
[dependencies]
prometheus = "0.13"
use prometheus::{Counter, Histogram, register_counter, register_histogram};
lazy_static! {
static ref MESSAGES_RECEIVED: Counter = register_counter!(
"messages_received_total",
"Total number of messages received"
).unwrap();
static ref MESSAGE_LATENCY: Histogram = register_histogram!(
"message_latency_seconds",
"Message processing latency"
).unwrap();
static ref ACTIVE_CONNECTIONS: prometheus::IntGauge = prometheus::register_int_gauge!(
"active_connections",
"Number of active WebSocket connections"
).unwrap();
}
async fn handle_message(msg: ClientMessage) {
let timer = MESSAGE_LATENCY.start_timer();
MESSAGES_RECEIVED.inc();
// Process message...
timer.observe_duration();
}
Load Testing
Use goose for load testing:
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("GameSession")
.register_transaction(transaction!(connect_and_play).set_weight(1)?)
)
.execute()
.await?;
Ok(())
}
async fn connect_and_play(user: &mut GooseUser) -> TransactionResult {
let ws = user.websocket("ws://localhost:9001")?;
// Join game
ws.send_text(r#"{"type":"JOIN","room_id":"test"}"#).await?;
// Send inputs
for _ in 0..100 {
ws.send_text(r#"{"type":"MOVE","x":1.0,"y":0.5}"#).await?;
tokio::time::sleep(Duration::from_millis(16)).await;
}
Ok(())
}
Results
Production metrics:
| Metric | Value |
|---|---|
| Concurrent players | 12,000+ |
| CPU usage | 35% (8 cores) |
| Memory usage | 2GB |
| p99 latency | 18ms |
| Messages/sec | 720,000 |
| Bandwidth/player | 12 KB/s |
Lessons Learned
- Rust’s ownership prevents bugs - Caught countless race conditions at compile time
- Binary encoding is essential - 3x smaller payloads vs JSON
- Client prediction feels instant - Even with 100ms latency
- QuadTrees massively help - O(log n) vs O(n²) collision checks
- Monitoring is critical - We found bottlenecks we never expected