Build a Real-Time Multiplayer Game State Sync System


distributed-systems performance reliability

System Design Deep Dive

Multiplayer Game State Sync

How to keep all players in sync under 100ms when the network is your enemy.

⏱ 14 min read📐 Advanced🎮 Game Architecture

Imagine two players shooting at each other across the internet. Player A in Tokyo fires at Player B in New York. The shot leaves Tokyo at time T. It travels 150ms across the Pacific before reaching New York’s server. Player B, whose client is only 10ms from the server, has already moved. From the server’s perspective, B is safely behind cover when the shot arrives. From A’s perspective - seeing the world 150ms in the past - B was fully exposed when A pulled the trigger. Who is right?

This is the fundamental tension in real-time multiplayer game design. Every player sees a slightly different version of the game world, lagged by their network round-trip time. A player with a 200ms ping sees the world 100ms older than a player with a 20ms ping. Without compensation, fast-network players have a systematic unfair advantage: they see and react to the current world while slow-network players aim at ghosts.

At 64 players per match running at 20 game ticks per second, a server is processing 1,280 state updates per second and broadcasting roughly 640KB/s of authoritative game state per match. At 10,000 concurrent matches - a modest mid-tier multiplayer game - that is 6.4GB/s of outbound UDP traffic, not counting the inbound input stream. Every packet must be processed, validated, and broadcast within 50ms or the game feels broken. Real systems like Valve’s Source Engine, Overwatch, and Unity Netcode for GameObjects have each solved this at production scale.

We need to solve for sub-100ms update cycles across all 64 players, lag compensation that gives 200ms-RTT players a fair shot, and graceful disconnect recovery that lets a player reconnect mid-match without corrupting the game state. These three problems pull in different directions and must be solved simultaneously.

Requirements and Constraints

Functional Requirements

  • Synchronize game state across all players in a match with consistent world view
  • Apply lag compensation so a player’s hit detection is evaluated at the moment they fired, not when the packet arrives
  • Support up to 64 players per match instance
  • Recover player state if a player disconnects and reconnects within 60 seconds
  • Broadcast authoritative state to all clients at 20 ticks per second (50ms tick interval)
  • Validate all player inputs server-side - no client-trusted physics or hit detection

Non-Functional Requirements

  • Update latency: server processes each game tick in under 16ms, broadcasts within 50ms of tick completion
  • Player capacity: 64 players per match, 10,000 concurrent matches per deployment cluster
  • Network RTT support: correct gameplay for players with up to 200ms round-trip latency
  • Uptime: 99.9% per match instance - a crash mid-match is a bad player experience
  • Reconnect window: player state preserved for 60 seconds after disconnect, full state resync on reconnect
  • Bandwidth: 500 bytes per player per tick (with delta compression) = 640KB/s outbound per match

Constraints and Assumptions

  • We use UDP for all real-time game packets and TCP only for control-plane messages (session setup, reconnect handshake)
  • The game server is authoritative - clients never trust their own physics results
  • Clients run local prediction but reconcile with server state on every tick
  • Clock synchronization across server and clients uses NTP with drift correction; we do not require hardware clocks
  • Match state is not globally replicated in real time - it lives entirely in the game server’s memory during play

High-Level Architecture

The system comprises six components: the Game Client, UDP Gateway, Authoritative Game Server, State Broadcast Service, Reconnect State Cache, and Match Coordinator.

Multiplayer game state sync architecture overview

The Game Client sends player input packets to the UDP Gateway at up to 60 frames per second. Each input packet contains the action (move, fire, jump), the client’s local sequence number, and a client-side timestamp. The UDP Gateway validates the session token, rate-limits malformed packets, and forwards valid inputs to the Authoritative Game Server over a reliable internal channel.

The Authoritative Game Server runs a fixed-rate game loop at 20Hz. Each tick it dequeues all pending inputs, applies them to the canonical game state with lag compensation applied, runs collision detection and physics, and produces a new authoritative state snapshot. The State Broadcast Service takes the delta between this snapshot and the previous one, compresses it, and fans it out to all 64 clients via UDP.

The Reconnect State Cache (backed by Redis) stores the last 60 seconds of state snapshots for each match. If a player disconnects, their session slot is held. When they reconnect, the cache replays the current state to bring them back in sync.

The Match Coordinator handles pre-match setup: picking a game server pod, assigning player session tokens, and routing the 64 clients to the correct UDP Gateway endpoint. After the match starts, the Coordinator is out of the hot path entirely.

Key Insight

The Match Coordinator is only involved at match creation and termination. Once the match runs, all 64 players talk directly to a single game server process. This keeps the hot path to a single network hop: client to game server and back. Any intermediate routing would add latency that compounds badly at 20Hz.

Component Deep Dives

The Authoritative Game Server

The Authoritative Game Server is the source of truth for everything that happens in a match. No client result is trusted. If a client says it moved to position (100, 50, 200), the server recomputes whether that move was physically valid given the input it received, and discards anything that does not match.

The server runs a tight game loop: wake at tick T, dequeue all inputs received since tick T-1, apply them with lag compensation, run physics, emit state delta, sleep until tick T+1. The entire loop must complete in under 50ms or the next tick starts late and players feel rubber-banding.

# Authoritative game server main loop - runs at 20Hz
# Demonstrates: fixed-rate tick loop, input draining, state snapshot emission
import time
import asyncio
from dataclasses import dataclass, field
from typing import Dict, List

TICK_RATE_HZ = 20
TICK_INTERVAL_S = 1.0 / TICK_RATE_HZ  # 50ms

@dataclass
class GameState:
    tick: int = 0
    player_positions: Dict[str, tuple] = field(default_factory=dict)
    player_healths: Dict[str, int] = field(default_factory=dict)
    events: List[dict] = field(default_factory=list)

class AuthoritativeGameServer:
    def __init__(self, match_id: str):
        self.match_id = match_id
        self.state = GameState()
        self.input_queue: asyncio.Queue = asyncio.Queue()
        self.lag_compensator = LagCompensator(history_ticks=60)  # 3 seconds at 20Hz

    async def run_game_loop(self):
        next_tick_time = time.monotonic()
        while True:
            tick_start = time.monotonic()
            inputs = self.drain_input_queue()
            self.state = self.apply_inputs(self.state, inputs)
            self.lag_compensator.record_state(self.state)
            delta = self.compute_delta(self.state)
            await self.broadcast_delta(delta)
            self.state.tick += 1
            next_tick_time += TICK_INTERVAL_S
            sleep_time = next_tick_time - time.monotonic()
            if sleep_time > 0:
                await asyncio.sleep(sleep_time)
            else:
                # Tick overran - log warning, no sleep, catch up
                print(f"Tick overrun: {abs(sleep_time)*1000:.1f}ms late")

    def drain_input_queue(self) -> List[dict]:
        inputs = []
        while not self.input_queue.empty():
            inputs.append(self.input_queue.get_nowait())
        return inputs

The server maintains a tick history buffer - the last 60 ticks of player positions and state. This is the raw material for lag compensation: when a player fires a shot, the server rewinds to the tick that corresponds to the player’s view of the world and evaluates the hit there.

Real World

Valve’s Source Engine runs game servers at configurable tick rates: casual servers at 64Hz, competitive matchmaking at 128Hz. At 128Hz each tick is 7.8ms - the server physics and broadcast must complete in under 7.8ms. Valve achieves this by running the game loop on a dedicated CPU core with real-time priority and pre-allocating all memory at server startup to avoid GC pauses mid-tick.

Client-Side Prediction and Reconciliation

Waiting for server confirmation on every player input would make the game feel unplayable. At 100ms RTT, every button press would take 100ms to produce any visual response. Client-side prediction solves this: the client immediately applies its own inputs locally, shows the result to the player, and then waits for the server’s authoritative update to arrive.

Think of it like autocomplete on your phone. When you type a word, the keyboard shows the completed word immediately without waiting for a server round-trip. If the server’s spelling correction disagrees, it overrides the prediction. The override might briefly flicker, but most of the time the prediction is right and the experience feels instant.

Server reconciliation is the correction step. When the server broadcasts its authoritative state at tick T, the client compares the server’s position for the local player against what the client predicted at tick T. If they match, nothing happens. If they differ - because the server rejected an input or because another player interacted with the local player - the client snaps to the server position and replays all inputs since tick T to bring the predicted state back to the present.

# Client-side prediction and server reconciliation
# Demonstrates: local input application, reconciliation on mismatch
from collections import deque
from dataclasses import dataclass
from typing import Optional

@dataclass
class InputRecord:
    sequence: int
    tick: int
    action: dict
    predicted_position: tuple

class GameClient:
    def __init__(self, player_id: str):
        self.player_id = player_id
        self.local_position = (0.0, 0.0, 0.0)
        self.input_sequence = 0
        # Ring buffer of unacknowledged inputs
        self.pending_inputs: deque[InputRecord] = deque(maxlen=128)

    def apply_local_input(self, action: dict) -> InputRecord:
        # Immediately predict the result locally
        predicted_pos = self.simulate_movement(self.local_position, action)
        self.local_position = predicted_pos
        record = InputRecord(
            sequence=self.input_sequence,
            tick=self.current_tick(),
            action=action,
            predicted_position=predicted_pos,
        )
        self.pending_inputs.append(record)
        self.input_sequence += 1
        return record

    def on_server_state(self, server_tick: int, server_position: tuple, acked_sequence: int):
        # Remove inputs the server has processed
        while self.pending_inputs and self.pending_inputs[0].sequence <= acked_sequence:
            self.pending_inputs.popleft()

        # Check if our prediction matches the server
        position_error = self.distance(server_position, self.predicted_at_tick(server_tick))
        if position_error > 0.05:  # 5cm threshold - snap and replay
            self.local_position = server_position
            # Replay all pending (unacknowledged) inputs from server position
            for record in self.pending_inputs:
                self.local_position = self.simulate_movement(self.local_position, record.action)

    def simulate_movement(self, pos: tuple, action: dict) -> tuple:
        # Deterministic physics simulation - identical on client and server
        dx = action.get("dx", 0) * 0.1
        dz = action.get("dz", 0) * 0.1
        return (pos[0] + dx, pos[1], pos[2] + dz)

The critical property is that simulate_movement must be deterministic and identical on both client and server. If the physics code diverges even slightly - floating-point rounding differences, different collision detection order - the client will always mismatch and constantly snap to server position, producing visible rubber-banding.

Key Insight

Unity Netcode for GameObjects uses a fixed-point math library for all physics simulations to guarantee bit-identical results across platforms. Floating-point arithmetic is not associative - (a + b) + c may not equal a + (b + c) on different CPU architectures. Fixed-point removes this source of prediction divergence entirely.

Lag Compensation Engine

The Lag Compensation Engine solves the fairest version of the question we opened with: when Player A fires at T=0 on their client, and the packet arrives at the server at T=100ms, should the hit be evaluated against where Player B is at T=0 or T=100ms?

The answer is T=0 - A’s view of the world at the moment they fired. This is lag compensation. The server maintains a state history ring buffer with the last 3 seconds of per-player positions (60 ticks at 20Hz). When a fire event arrives, it carries the client’s timestamp. The server converts that timestamp to a tick number, rewinds all other players to their positions at that tick, evaluates whether the shot hits any of them in that rewound world, then fast-forwards back to the present state.

# Lag compensation: rewind world state to evaluate hit at player's perception time
# Demonstrates: state history lookup, temporal hit detection
import time
from collections import deque
from typing import Optional, List

@dataclass
class PlayerSnapshot:
    tick: int
    player_id: str
    position: tuple
    hitbox_radius: float = 0.5

class LagCompensator:
    def __init__(self, history_ticks: int = 60):
        # Ring buffer: up to 60 ticks (3 seconds at 20Hz) of snapshots per player
        self.history: deque[dict] = deque(maxlen=history_ticks)

    def record_state(self, game_state) -> None:
        snapshot = {
            pid: PlayerSnapshot(
                tick=game_state.tick,
                player_id=pid,
                position=pos,
            )
            for pid, pos in game_state.player_positions.items()
        }
        self.history.append((game_state.tick, snapshot))

    def get_state_at_tick(self, tick: int) -> Optional[dict]:
        for recorded_tick, snapshot in reversed(self.history):
            if recorded_tick <= tick:
                return snapshot
        return None  # tick too old, cannot compensate

    def evaluate_shot(
        self,
        shooter_id: str,
        shooter_position: tuple,
        shot_direction: tuple,
        client_timestamp_ms: int,
        current_tick: int,
        server_tick_rate: int = 20,
    ) -> Optional[str]:
        # Convert client timestamp to server tick
        current_time_ms = int(time.time() * 1000)
        tick_offset = (current_time_ms - client_timestamp_ms) // (1000 // server_tick_rate)
        rewind_tick = max(0, current_tick - int(tick_offset))

        # Retrieve world state at the rewound tick
        historical_state = self.get_state_at_tick(rewind_tick)
        if not historical_state:
            # Cannot rewind this far - reject shot
            return None

        # Raycast against historical player positions
        for player_id, snapshot in historical_state.items():
            if player_id == shooter_id:
                continue
            if self.ray_intersects_hitbox(
                origin=shooter_position,
                direction=shot_direction,
                hitbox_center=snapshot.position,
                radius=snapshot.hitbox_radius,
            ):
                return player_id  # hit!
        return None  # miss

    def ray_intersects_hitbox(self, origin, direction, hitbox_center, radius) -> bool:
        # Simplified sphere intersection test
        import math
        oc = tuple(o - c for o, c in zip(origin, hitbox_center))
        b = sum(oc[i] * direction[i] for i in range(3))
        c = sum(oc[i] ** 2 for i in range(3)) - radius ** 2
        return (b * b - c) >= 0

Lag compensation has one important anti-cheat implication: we must cap the maximum rewind time. If we allow a 2-second rewind for a player claiming 2,000ms latency, they could shoot at a position a player was in 2 seconds ago, long after that player moved away. Capping rewind at 200ms (the maximum supported RTT) limits the compensation to a reasonable physical network delay, making it hard to abuse.

Watch Out

Lag compensation creates a subtle fairness inversion: the player with 200ms RTT gets the most rewinding, effectively getting to shoot at “ghost” positions of other players. Cap the maximum rewind to the 99th percentile RTT of your player base. Overwatch caps at 200ms. Beyond that threshold, shots are rejected, not compensated.

Data Model

The data model separates three tiers: the live in-memory game state (on the game server), the state snapshot cache (Redis), and the post-match analytics store (Postgres).

-- Player session tracking: who is in which match
-- Demonstrates: session state with reconnect support
CREATE TABLE player_sessions (
    session_id      UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    match_id        TEXT        NOT NULL,
    player_id       TEXT        NOT NULL,
    joined_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    disconnected_at TIMESTAMPTZ,
    reconnected_at  TIMESTAMPTZ,
    last_ack_tick   INT         NOT NULL DEFAULT 0,
    udp_endpoint    TEXT,       -- ip:port for current UDP connection
    UNIQUE(match_id, player_id)
);

-- Match metadata: lifecycle state and server assignment
CREATE TABLE matches (
    match_id        TEXT        PRIMARY KEY,
    game_server_pod TEXT        NOT NULL,
    udp_gateway     TEXT        NOT NULL,
    started_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ended_at        TIMESTAMPTZ,
    player_count    INT         NOT NULL DEFAULT 0,
    status          TEXT        NOT NULL DEFAULT 'active'
        CHECK (status IN ('lobby', 'active', 'ended', 'crashed'))
);

-- Post-match event log for analytics
CREATE TABLE match_events (
    id              BIGSERIAL   PRIMARY KEY,
    match_id        TEXT        NOT NULL,
    tick            INT         NOT NULL,
    event_type      TEXT        NOT NULL,  -- 'shot', 'hit', 'death', 'respawn'
    actor_id        TEXT,
    target_id       TEXT,
    position        JSONB,
    metadata        JSONB,
    recorded_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX ON match_events (match_id, tick);
CREATE INDEX ON match_events (match_id, event_type, recorded_at DESC);
CREATE INDEX ON player_sessions (match_id, disconnected_at)
    WHERE disconnected_at IS NOT NULL;

The live game state lives entirely in the game server process memory. We do not write game state to any database during a match - the in-process ring buffer is the only copy. Redis holds compressed state snapshots for reconnect recovery, with a 60-second TTL per match.

# Redis key patterns for game state
# match:state:{match_id}:{tick}  - compressed state snapshot (60s TTL)
# match:session:{session_id}     - player session state (90s TTL)
# match:input_log:{match_id}     - recent input sequence numbers for dedup
Key Insight

Writing game state to a database on every tick would be catastrophically slow. At 20Hz with 64 players, we have 20 writes per second to a state blob that is several KB per tick. The reconnect cache in Redis uses SETEX with a short TTL - we only need the most recent state for reconnect, not the full history.

Key Algorithms and Protocols

Client-Side Prediction and Server Reconciliation

The reconciliation algorithm runs on every received server tick:

# Full reconciliation loop with smooth interpolation
# Demonstrates: prediction error correction with smoothing to avoid visual snapping
import math

class ReconciliationEngine:
    def __init__(self):
        self.correction_blend = 0.3  # blend factor: 0=instant snap, 1=no correction

    def reconcile(
        self,
        current_predicted: tuple,
        server_authoritative: tuple,
        pending_inputs: list,
    ) -> tuple:
        error = self.distance(current_predicted, server_authoritative)
        if error < 0.01:
            return current_predicted  # within tolerance, no correction needed

        if error > 2.0:
            # Large error: server position is very different, snap immediately
            corrected = server_authoritative
        else:
            # Small error: blend toward server position to avoid visible pop
            corrected = tuple(
                server_authoritative[i] * self.correction_blend
                + current_predicted[i] * (1 - self.correction_blend)
                for i in range(3)
            )

        # Re-simulate pending inputs from corrected position
        position = corrected
        for inp in pending_inputs:
            position = self.simulate_movement(position, inp["action"])
        return position

    def distance(self, a: tuple, b: tuple) -> float:
        return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))

    def simulate_movement(self, pos: tuple, action: dict) -> tuple:
        dx = action.get("dx", 0) * 0.1
        dz = action.get("dz", 0) * 0.1
        return (pos[0] + dx, pos[1], pos[2] + dz)

Delta State Compression

Broadcasting the full game state on every tick at 64 players is expensive. At 500 bytes per player the full state is 32KB per tick. Delta compression reduces this by only sending what changed since the last tick.

# Delta encoder: compare snapshots, emit only changed fields
# Demonstrates: field-level diffing for bandwidth reduction
import json

def encode_delta(prev_state: dict, curr_state: dict) -> bytes:
    delta = {}
    for player_id, curr_pos in curr_state["positions"].items():
        prev_pos = prev_state.get("positions", {}).get(player_id)
        if curr_pos != prev_pos:
            delta.setdefault("positions", {})[player_id] = curr_pos
    for player_id, curr_hp in curr_state["healths"].items():
        prev_hp = prev_state.get("healths", {}).get(player_id)
        if curr_hp != prev_hp:
            delta.setdefault("healths", {})[player_id] = curr_hp
    # Events are always included (no previous state to diff against)
    if curr_state.get("events"):
        delta["events"] = curr_state["events"]
    delta["tick"] = curr_state["tick"]
    return json.dumps(delta).encode()

In a typical tick where most players are stationary or only a few players moved, the delta is 10-50 bytes rather than 32KB. Moving players contribute 24 bytes each (3 floats). A chaotic team fight with all 64 players moving approaches the full state size, but that is the worst case, not the average.

Real World

Overwatch uses a custom binary protocol (not JSON) for state deltas. Fields are bit-packed: a player position uses 24 bits (quantized to 0.1 unit precision within the map bounds) instead of 96 bits for three 32-bit floats. Combined with delta encoding, Overwatch’s per-client bandwidth per match is approximately 60KB/s downstream, achieved on top of a 64Hz tick rate.

UDP Packet Loss Recovery

UDP provides no retransmission, so we implement sequence-number-based loss detection and selective redundancy at the application layer.

// Game state packet structure (protobuf schema)
// Demonstrates: sequence tracking, redundant event delivery
syntax = "proto3";

message StatePacket {
  int32 server_tick = 1;
  int32 last_client_input_acked = 2;  // tells client which inputs were processed
  bytes delta_state = 3;              // compressed delta from previous tick
  repeated GameEvent events = 4;      // events replicated for N ticks for reliability
  int64 server_timestamp_ms = 5;
}

message InputPacket {
  int32 sequence = 1;
  int32 client_tick = 2;
  int64 client_timestamp_ms = 3;
  PlayerAction action = 4;
  // Include last 3 inputs for redundancy in case of packet loss
  repeated PlayerAction redundant_actions = 5;
}

message PlayerAction {
  float dx = 1;
  float dz = 2;
  bool fire = 3;
  bool jump = 4;
  float aim_yaw = 5;
  float aim_pitch = 6;
}

message GameEvent {
  enum EventType {
    HIT = 0;
    DEATH = 1;
    RESPAWN = 2;
    PICKUP = 3;
  }
  EventType type = 1;
  string actor_id = 2;
  string target_id = 3;
  int32 value = 4;
  int32 at_tick = 5;
}

Events (kills, pickups, objective captures) are replicated in the state packet for 3 consecutive ticks. If a packet carrying a kill event is lost, the next two packets repeat it. The client deduplicates events by (event_type, actor_id, at_tick) so repeated delivery does not cause double-counting.

Scaling and Performance

Each match is a single game server process - isolated, stateful, and ephemeral. Scaling is horizontal at the match level: more matches mean more pods, not more capacity per match.

Capacity Estimation:

Per-match costs:
  Players: 64
  Tick rate: 20Hz (50ms per tick)
  Inbound input packets: 64 players * 60 FPS input rate = 3,840 UDP packets/sec/match
  Outbound state packets: 64 players * 20Hz = 1,280 UDP packets/sec/match
  Outbound bandwidth: 64 * 20 * 500 bytes = 640 KB/s/match
  Inbound bandwidth: 64 * 60 * 80 bytes = 307 KB/s/match
  Memory per match: ~8MB (game state + lag comp history + input buffers)

At 10,000 concurrent matches:
  Total outbound: 10,000 * 640 KB/s = 6.4 GB/s
  Total inbound: 10,000 * 307 KB/s = 3.1 GB/s
  Total memory: 10,000 * 8 MB = 80 GB
  Total UDP packets/sec: 10,000 * 5,120 = 51.2M packets/sec

Server pod sizing (c5n.4xlarge, 16 vCPU, 42 GB, 25Gbps NIC):
  Matches per pod: ~100 matches (limited by NIC at 25Gbps / 64KB/s = ~390, but CPU limits to ~100)
  Pods for 10,000 matches: ~100 pods
  Bandwidth per pod: 100 * 640 KB/s = 64 MB/s outbound (well under 25Gbps)
Horizontal scaling with geographic zone routing

Zone-based routing assigns each match to the zone where the median player latency is lowest. If 40 of 64 players are in North America and 24 in Europe, the Match Coordinator picks NA servers. Players in Europe get 100-150ms RTT but the majority gets optimal latency. Lag compensation handles the minority.

Key Insight

Scaling a multiplayer game server is embarrassingly parallel at the match level. Unlike a shared-state system (a database, a cache), each match has zero shared state with any other match after creation. You can add pods without coordination, and losing a pod crashes only the matches on that pod - typically less than 100 out of 10,000.

Failure Modes and Recovery

FailureDetectionImpactRecovery
Player disconnectUDP keepalive timeout (5s)Player disappears from match, session slot heldClient reconnects within 60s: state replayed from Redis cache
Game server pod crashKubernetes pod health check failsAll matches on pod terminated mid-gameMatch flagged as crashed in Postgres; clients shown error screen
UDP Gateway overloadPacket loss rate > 5% on gatewayInput packets dropped, players experience rubber-bandingAuto-scale UDP gateway; match coordinator avoids overloaded gateways
Tick overrun (> 50ms)Server logs tick duration percentilePhysics lag, players see slow-motion then fast-forwardProfile hot path; reduce player count per match or upgrade CPU
UDP packet lossClient detects gap in server_tick sequence numbersMissed state update; client extrapolates from last known stateClient uses dead reckoning for up to 3 missed ticks then shows lag indicator
Clock skew between client and serverLag compensation rewind values driftShot evaluations at wrong tick, hit detection errorsNTP sync on game server; client sends raw timestamp, server adjusts for known drift
Redis reconnect cache failureRedis health checkReconnect within 60s shows stale stateFallback: full state resync broadcast to rejoining player
Real World

Riot Games’ Valorant handles server pod crashes by immediately terminating the match and awarding the round to the team in the lead at crash time. They concluded that mid-match migration to a new server is too complex to get right under time pressure, and players strongly prefer a clean termination to a degraded match experience. The match data is preserved in Postgres for dispute resolution.

Comparison of Approaches

TransportLatencyReliabilityHead-of-Line BlockingComplexityBest Fit
Raw UDPLowest (no overhead)None - application handles all lossNoneHigh (custom reliability layer needed)Real-time game state where loss tolerance is acceptable
TCPMedium (congestion control, Nagle)Guaranteed ordered deliveryYes - one lost packet blocks all subsequentLowTurn-based games, lobby chat, control plane
WebSocket (over TCP)Medium (same as TCP + WS framing)Guaranteed ordered deliveryYesLow-mediumBrowser-based games, fallback for UDP-blocked networks
WebRTC DataChannelLow (DTLS + SCTP unreliable mode)Configurable per-channelNo (with unordered channels)High (signaling, TURN servers, NAT traversal)Peer-to-peer or browser games needing low latency
QUICLow (0-RTT reconnect, no HoL blocking)Configurable per-streamNo (stream-level isolation)Medium (newer, less tooling)Next-generation game servers, mobile networks with high loss

For server-authoritative games where the server is a known endpoint (not NAT-traversal needed), raw UDP with application-level reliability is the standard choice. WebRTC DataChannel is right for browser games or peer-to-peer scenarios. QUIC is increasingly attractive for mobile players where packet loss is common and 0-RTT reconnect significantly improves reconnect latency.

Key Takeaways

  • Authoritative server is non-negotiable for competitive games. Clients predict locally for responsiveness, but the server decides every outcome. Any client-trusted result is a cheat vector.
  • Client-side prediction removes the feel of input lag for the local player. The key is that prediction is deterministic and identical between client and server - divergence causes visible rubber-banding.
  • Server reconciliation corrects prediction errors without visible snapping by blending toward the authoritative position for small errors and snapping only for large ones.
  • Lag compensation is the fairness mechanism that equalizes the experience for players on different network connections. It requires a state history ring buffer and a per-shot timestamp from the client.
  • Delta compression is the bandwidth mechanism that makes 64-player matches practical. Sending only changed fields reduces per-tick bandwidth by 10-20x in typical gameplay.
  • UDP with application-level reliability gives the game control over which packets are retransmitted. Game state does not need retransmission - only events (kills, pickups) need guaranteed delivery, and those can be repeated in consecutive packets rather than triggering TCP-style retransmit.
  • Match-level isolation makes horizontal scaling simple. Each match is a process; adding capacity means adding pods. There is no shared mutable state between matches after they start.
  • Reconnect recovery via a short-TTL Redis cache is sufficient for the common case (brief disconnect, immediate reconnect). Full state resync on reconnect is the fallback for longer outages.

The hardest insight in multiplayer game networking is that the goal is not to achieve a single consistent world view - that is physically impossible with non-zero latency. The goal is to achieve a locally consistent world view for each player that is close enough to every other player’s view that the game is fair and feels responsive. Lag compensation and prediction are the engineering mechanisms that approximate this goal.

Frequently Asked Questions

Q: Why not just use TCP instead of UDP?

A: TCP’s guaranteed ordered delivery is a liability for real-time game state. If packet 5 is lost, TCP holds packets 6, 7, and 8 in a buffer until 5 is retransmitted and received - this is head-of-line blocking. By the time the retransmit arrives, the state in packets 6-8 is already stale. Game state updates are not cumulative the way file transfers are; each state update supersedes the previous one. UDP lets us discard packets 6 and 7 if packet 8 arrives, and use 8 directly. The application-level sequence numbers handle ordering and loss detection without blocking on retransmits.

Q: How does lag compensation prevent cheating?

A: Lag compensation rewinds the world to a specific tick number derived from the client’s reported timestamp. The maximum rewind is capped at 200ms, corresponding to the maximum allowed RTT. A cheating client that sends a fraudulent timestamp claiming 2,000ms latency would get capped at 200ms of compensation anyway. The server validates that the client’s claimed latency matches the observed round-trip time from keepalive packets. Significant discrepancies flag the player for anti-cheat review. Lag compensation does expand the hit window slightly for high-latency players, but the cap ensures this is bounded by a physical network constraint, not exploitable without genuine high latency.

Q: What happens when a player’s ping spikes from 20ms to 400ms mid-match?

A: The lag compensation engine uses the actual timestamp on each input packet, not a fixed assumed latency. A spike to 400ms means the rewind calculation requests 400ms of history, which may exceed the 200ms cap. Shots fired during the spike are either capped at 200ms rewind (if we allow it) or rejected outright (stricter competitive settings). The player’s client extrapolates their local position using dead reckoning during the spike, so local movement feels continuous. When the spike resolves, the next server tick produces a large reconciliation correction, which appears as rubber-banding. This is the unavoidable physical consequence of packet loss and latency spikes.

Q: How do you handle a 64-player battle royale where the player count drops to 2 near the end?

A: The state broadcast cost scales with active players, not the lobby size. As players are eliminated, they are removed from the broadcast list. At 2 remaining players, each broadcast packet contains only 2 players’ state, reducing to roughly 1KB/s from the 640KB/s peak. The game server continues running the full tick loop but the physics and lag compensation work shrinks proportionally with player count. No architectural change is needed - the system naturally scales down.

Q: How does client-side prediction handle player interactions? If Player A shoots Player B, does A see B fall immediately?

A: No. Client-side prediction only applies to the local player’s own movement and actions. The local player’s movement is predicted immediately. But the results of interactions with other players (hit detection, health changes, deaths) are authoritative and only shown when the server confirms them. This means when A fires at B, A sees the shot animation immediately (local prediction), but B’s health bar does not drop until the server confirms the hit and broadcasts the state change. The latency of this confirmation is half of A’s RTT to the server - typically 10-50ms, imperceptible in practice.

Q: How does reconnect recovery work if the game server pod crashed rather than the player disconnecting?

A: If the pod crashes, the match is terminated. The Redis reconnect cache survives the pod crash (Redis is separate infrastructure), but without an active game server to reconnect to, the cache is not useful. The match is marked as crashed in Postgres. Clients receive a server-terminated signal and are shown an error screen. This is a product decision: mid-match migration to a new pod requires serializing the full game state, broadcasting it to 64 clients simultaneously, and resuming the game loop without any players noticing a discontinuity - complex enough that most studios opt for a clean termination instead.

Interview Questions

Q: Design the lag compensation system for a first-person shooter where the acceptable maximum rewind is 200ms. Walk through the data structures and the per-shot algorithm.

Expected depth: Describe the state history ring buffer: a circular array of size 60 (3s at 20Hz) where each element is a map of player_id to position and hitbox. Explain the tick-to-timestamp mapping: server tick T corresponds to server_start_time + T * 50ms. On shot arrival, compute rewind_tick = current_tick - (client_latency_ms / 50). Look up ring_buffer[rewind_tick % 60]. Raycast against historical hitboxes. Discuss capping rewind to prevent abuse. Mention that killed players are still present in the ring buffer at their death tick, so you must also check if the target was alive at the rewind tick.

Q: A player with 20ms RTT consistently loses gunfights against a player with 200ms RTT. Why might this happen, and how would you diagnose and fix it?

Expected depth: The 200ms player gets more rewind, so their shots are evaluated against an older world state where the 20ms player has not moved yet. The 20ms player is always aiming at the current position, which does not benefit from rewind. Diagnosis: compare the effective hit window (position uncertainty at rewind tick) for both players. Fix options: apply reciprocal lag compensation (also rewind the shooter slightly for the fast-network player), or reduce the maximum rewind cap. Mention that Overwatch and Counter-Strike have both tuned this tradeoff and landed on different values.

Q: Your game server tick loop is running at 20Hz but you are seeing occasional ticks take 80-100ms, causing visible rubber-banding. How do you diagnose and fix it?

Expected depth: First instrument the tick loop with per-phase timing: input drain, physics step, collision detection, delta encode, broadcast. The 80ms ticks will show which phase is the culprit. Common causes: physics step O(n^2) with n players (use spatial partitioning - octree or grid), GC pause (move to a language with no GC or pre-allocate all objects), broadcast bottleneck (64 UDP sends serialized - use a scatter gather send or a dedicated broadcast thread), or input queue backup (inputs from a 60Hz client filling a 20Hz drain). Fix by profiling, not guessing.

Q: How would you implement a spectator mode where a viewer can watch any player’s perspective with 0ms delay but may be 10 seconds behind real-time?

Expected depth: The spectator receives the same state broadcast stream as players, stored in a local circular buffer on the spectator client. A 10-second buffer requires 10 * 20 * 32KB = 6.4MB of client memory (or less with delta compression). The spectator UI controls a playhead that reads from the buffer. Switching between player perspectives is an instant buffer seek - no new network packets needed. The spectator does not need prediction or lag compensation since they are not interacting with the world. The 10-second delay is a product choice that simplifies the architecture: no need for server-side spectator delay buffering.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article