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:

MetricValue
Concurrent players12,000+
CPU usage35% (8 cores)
Memory usage2GB
p99 latency18ms
Messages/sec720,000
Bandwidth/player12 KB/s

Lessons Learned

  1. Rust’s ownership prevents bugs - Caught countless race conditions at compile time
  2. Binary encoding is essential - 3x smaller payloads vs JSON
  3. Client prediction feels instant - Even with 100ms latency
  4. QuadTrees massively help - O(log n) vs O(n²) collision checks
  5. Monitoring is critical - We found bottlenecks we never expected

Resources