system-design57 min read

How to Design a Multiplayer Game Server — A Senior+ Guide | Ayodhyya

Designing a Multiplayer Game Server

Building Real-Time Competitive Infrastructure at Fortnite/Valorant Scale

Senior+ System Design Guide 20 In-Depth Sections Network · Physics · Architecture

1. Introduction — Why Multiplayer Game Servers Are Hard

Multiplayer game servers sit at the intersection of distributed systems, real-time networking, and physics simulation. Unlike a typical web application where a 200ms response time is perfectly acceptable, a competitive multiplayer shooter like Fortnite or Valorant demands sub-50ms server processing, deterministic physics, anti-cheat enforcement, and state synchronization for up to 100 concurrent players per match — all running across dozens of simultaneous matches in a single data center region.

The fundamental challenge is this: the game world must appear consistent and responsive to all players simultaneously, even though each player's client is running on a different machine, with different network latency, and possibly with adversarial modifications. The server is the single source of truth, and it must reconcile conflicting inputs, compensate for network delays, detect cheats, and maintain a fair experience — all within the tight budget of a single tick, often just 16.6 milliseconds at 60Hz or 33.3ms at 30Hz.

This guide walks through the complete system design of a production multiplayer game server infrastructure, covering everything from network protocol selection and lag compensation algorithms to matchmaking, anti-cheat, replay systems, and cost modeling. We draw on techniques used by studios like Epic Games (Fortnite), Riot Games (Valorant), Valve (CS2), and id Software (Quake) to build a comprehensive reference architecture suitable for senior, staff, and principal engineers preparing for system design interviews or evaluating real production architectures.

Why This Matters at Scale

Consider the operational demands: Fortnite峰值 during Chapter events has sustained over 12 million concurrent players. Valorant regularly supports millions of ranked matches per day. Each match requires a dedicated server instance running the authoritative game simulation, network replication to all participants, anti-cheat analysis, and post-match result processing. The infrastructure must scale horizontally, survive regional outages, and maintain consistent performance regardless of player population spikes.

Key Insight: A multiplayer game server is fundamentally an authoritative state machine with hard real-time constraints, operating in a hostile network environment against potentially adversarial clients.

Unlike stateless microservices that can be freely horizontally scaled, game server instances carry heavy per-instance state (the entire game world snapshot) and must maintain persistent network connections with bounded latency. This creates unique scaling, reliability, and operational challenges that do not arise in typical cloud application architectures.

2. Functional & Non-Functional Requirements

Functional Requirements

  • Match Creation: Players can queue for matches, be matched by skill, and enter a lobby that transitions to an active game session.
  • Real-Time Gameplay: The server runs an authoritative simulation of the game world. Player inputs (movement, shooting, building, abilities) are processed every tick and broadcast to all participants.
  • State Synchronization: All clients receive a consistent, up-to-date view of the game state. Discrepancies due to latency are resolved through prediction, interpolation, and reconciliation.
  • Matchmaking: Players are grouped into matches based on skill rating, queue time, party size, and server region to ensure balanced, low-latency games.
  • Ranking & Leaderboards: Post-match rating adjustments using a skill-rating algorithm (Elo, Glicko-2, or TrueSkill). Global and regional leaderboards.
  • Anti-Cheat: Detection and prevention of cheating through server-side validation, statistical analysis, and client-side integrity verification.
  • Replay & Spectator: Record and playback game matches for review, content creation, and live spectating of tournaments.
  • Real-Time Chat: In-match text and voice communication between teammates and between all players in a lobby.
  • Progression & Inventory: Persistent player accounts, progression tracking, cosmetic items, battle pass progress, and statistics.

Non-Functional Requirements

RequirementTargetRationale
Latency (tick processing)< 5ms per server tickMust leave headroom within the tick interval for network send/receive
Tick Rate30–64 Hz (server), 60 Hz (client render)Competitive games require high tick rates for smooth, responsive gameplay
Concurrent Players10M+ online, 100K+ concurrent matchesScale of major titles like Fortnite, Valorant, Apex Legends
Match Capacity2–100 players per matchSquad-based (4), duo (2), or battle royale (100) game modes
Packet Loss ToleranceResilient up to 5% lossTypical consumer internet conditions
Jitter ToleranceUp to 30ms variationMobile and congested network scenarios
Availability99.9% per regionGame servers are stateful; regional failover is the primary strategy
SecurityServer-authoritative, cheat-resistantCompetitive integrity requires server as single source of truth
Design Trade-off: Higher tick rates improve responsiveness but increase server CPU cost linearly. Most competitive shooters run at 64Hz (CS2) or 128Hz (Valorant), while battle royales often use 20–30Hz due to the larger world and player count.

3. Capacity Estimation & Back-of-the-Envelope

Player & Match Projections

Assume a global title targeting the scale of a mid-tier competitive shooter:

  • Daily Active Users (DAU): 5 million
  • Peak Concurrent Users (PCU): 1.5 million (typically 20–30% of DAU)
  • Average Session Length: 45 minutes
  • Average Players per Match: 50 (battle royale mode with squads)
  • Peak Concurrent Matches: 1.5M / 50 = 30,000 simultaneous matches

Server Instance Sizing

Each match requires one authoritative server instance. At 30,000 peak concurrent matches, we need 30,000 game server instances running simultaneously. A single bare-metal or high-CPU cloud machine can typically host 2–4 game server instances depending on the game complexity:

ParameterValueNotes
Game server CPU per instance2–4 vCPU (high clock)Physics, game logic, anti-cheat per tick
Game server RAM per instance2–4 GBWorld state, player snapshots, buffers
Instances per machine2–4Depends on game complexity and machine spec
Total machines needed (peak)7,500–15,00030,000 matches / 2–4 per machine
Network bandwidth per instance~500 Kbps up, ~2 Mbps down~20 packets/sec × 100 players × ~1KB per packet

Network Bandwidth Estimation

Each game server instance sends state updates to all connected clients. For a 50-player match at 30Hz:

  • Outbound per tick: 50 players × 200 bytes/player state = 10 KB per tick
  • Ticks per second: 30
  • Outbound bandwidth per instance: 10 KB × 30 = 300 KB/s = 2.4 Mbps
  • Total at 30K matches: 30,000 × 2.4 Mbps = 72 Gbps aggregate outbound
Scaling Strategy: The aggregate bandwidth of 72 Gbps is distributed across 7,500+ machines, each handling 5–10 Mbps. This is well within the capability of 10 Gbps network interfaces on modern bare-metal servers. Regional distribution (e.g., NA-East, NA-West, EU, APAC, Brazil, Oceania) further reduces per-DC bandwidth.

Storage Requirements

  • Match results: ~50 players × 30K matches/day × 2 KB per result = 3 GB/day of metadata
  • Replay data: ~500 KB per player per minute × 20 min avg × 5% of matches recorded = ~1.5 TB/day if recording all ranked matches
  • Player profiles: 5M players × 10 KB = 50 GB (fits easily in a relational database or document store)
  • Leaderboards: Sorted sets in Redis, ~50 MB for 5M ranked players

4. Data Model & Storage Design

The data model for a multiplayer game server splits cleanly into two domains: hot real-time state that lives in memory on the game server instance during a match, and persistent cold state stored in databases for profiles, progression, match history, and rankings.

Persistent Data Model (PostgreSQL / DynamoDB)

SQL
-- Player accounts and profiles
CREATE TABLE players (
    player_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username        VARCHAR(32) UNIQUE NOT NULL,
    email           VARCHAR(255) UNIQUE NOT NULL,
    password_hash   VARCHAR(255) NOT NULL,
    display_name    VARCHAR(64) NOT NULL,
    avatar_url      TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    last_login      TIMESTAMPTZ,
    is_banned       BOOLEAN DEFAULT FALSE,
    ban_reason      TEXT,
    ban_expires_at  TIMESTAMPTZ
);

-- Skill ratings per game mode
CREATE TABLE player_ratings (
    player_id       UUID REFERENCES players(player_id),
    game_mode       VARCHAR(32) NOT NULL,
    rating          FLOAT NOT NULL DEFAULT 1500.0,
    rating_dev      FLOAT NOT NULL DEFAULT 350.0,  -- Glicko-2 RD
    volatility      FLOAT NOT NULL DEFAULT 0.06,    -- Glicko-2 sigma
    games_played    INT DEFAULT 0,
    wins            INT DEFAULT 0,
    losses          INT DEFAULT 0,
    win_streak      INT DEFAULT 0,
    last_match_at   TIMESTAMPTZ,
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (player_id, game_mode)
);

-- Match results (append-only)
CREATE TABLE match_results (
    match_id        UUID PRIMARY KEY,
    game_mode       VARCHAR(32) NOT NULL,
    region          VARCHAR(16) NOT NULL,
    map_name        VARCHAR(64) NOT NULL,
    server_id       VARCHAR(64),
    started_at      TIMESTAMPTZ NOT NULL,
    ended_at        TIMESTAMPTZ NOT NULL,
    duration_sec    INT NOT NULL,
    winning_team    INT,
    player_count    INT NOT NULL,
    replay_url      TEXT,
    metadata        JSONB DEFAULT '{}'::jsonb
);

-- Individual player results per match
CREATE TABLE match_player_results (
    match_id        UUID REFERENCES match_results(match_id),
    player_id       UUID REFERENCES players(player_id),
    team            INT,
    placement       INT,          -- 1st place in BR, etc.
    kills           INT DEFAULT 0,
    deaths          INT DEFAULT 0,
    assists         INT DEFAULT 0,
    damage_dealt    FLOAT DEFAULT 0,
    damage_taken    FLOAT DEFAULT 0,
    survival_time   FLOAT DEFAULT 0,
    rating_before   FLOAT,
    rating_after    FLOAT,
    rating_change   FLOAT,
    PRIMARY KEY (match_id, player_id)
);

-- Battle pass and progression
CREATE TABLE player_progression (
    player_id       UUID REFERENCES players(player_id),
    season_id       INT NOT NULL,
    xp              BIGINT DEFAULT 0,
    battle_pass_tier INT DEFAULT 0,
    unlocks         JSONB DEFAULT '[]'::jsonb,
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (player_id, season_id)
);

CREATE INDEX idx_match_results_region_time ON match_results(region, started_at DESC);
CREATE INDEX idx_match_results_mode ON match_results(game_mode, started_at DESC);
CREATE INDEX idx_player_ratings_rating ON player_ratings(game_mode, rating DESC);

Hot State (In-Memory on Game Server)

The authoritative game state held in memory during a live match includes:

C#
public class MatchState
{
    public Guid MatchId { get; set; }
    public ulong ServerTick { get; set; }            // Current tick number
    public float TickRate { get; set; }              // e.g., 30 Hz
    public MatchPhase Phase { get; set; }            // Waiting, InProgress, PostGame
    public GameMode Mode { get; set; }
    public MapDefinition Map { get; set; }
    public DateTime MatchStartTime { get; set; }

    public Dictionary<Guid, PlayerState> Players { get; set; } = new();
    public List<ProjectileState> Projectiles { get; set; } = new();
    public WorldState World { get; set; }            // Buildings, items, vehicles, zones
    public InputBuffer InputBuffer { get; set; }     // Ring buffer of pending inputs
    public EventLog Events { get; set; }             // For replay recording
}

public class PlayerState
{
    public Guid PlayerId { get; set; }
    public int SlotIndex { get; set; }
    public int TeamId { get; set; }
    public Vector3 Position { get; set; }
    public Quaternion Rotation { get; set; }
    public Vector3 Velocity { get; set; }
    public float Health { get; set; }
    public float Shield { get; set; }
    public bool IsAlive { get; set; }
    public int Inventory { get; set; }
    public InventoryItems InventoryItems { get; set; }
    public PlayerInput LatestInput { get; set; }
    public int LastProcessedTick { get; set; }
    public MovementState MovementState { get; set; }
    public BuildState BuildingState { get; set; }    // Fortnite-specific
    public Queue<PlayerInput> PendingInputs { get; set; } = new();
}

public enum MatchPhase
{
    WaitingForPlayers,
    Countdown,
    InProgress,
    EndGame,
    PostMatch
}

Redis Layer

Redis serves multiple roles in the architecture: matchmaking queues, session lookups, real-time leaderboards, distributed locks, and pub/sub for cross-service communication.

C#
public class RedisGameCache
{
    // Matchmaking queue: sorted set scored by rating
    // ZADD matchmaking:ranked <rating> <playerId>
    public async Task EnqueuePlayerAsync(string mode, Guid playerId, double rating)
    {
        await _redis.SortedSetAddAsync($"matchmaking:{mode}", playerId.ToString(), rating);
    }

    // Active match lookup: player -> server instance
    public async Task<string?> GetPlayerMatchServerAsync(Guid playerId)
    {
        return await _redis.HashGetAsync("player:server:map", playerId.ToString());
    }

    // Session token -> player data for auth
    public async Task<PlayerSession?> GetSessionAsync(string token)
    {
        var data = await _redis.HashGetAllAsync($"session:{token}");
        return data.Length > 0 ? Deserialize<PlayerSession>(data) : null;
    }

    // Live leaderboard via sorted set
    public async Task UpdateLeaderboardAsync(string mode, Guid playerId, double rating)
    {
        await _redis.SortedSetAddAsync($"leaderboard:{mode}", playerId.ToString(), rating);
    }

    // Retrieve top N players
    public async Task<List<(string playerId, double rating)>> GetTopPlayersAsync(string mode, int count)
    {
        var results = await _redis.SortedSetRangeByScoreWithScoresAsync(
            $"leaderboard:{mode}", 0, count - 1, order: Order.Descending);
        return results.Select(r => (r.Element, r.Score)).ToList();
    }
}
Storage Pattern: Hot path data (match state, sessions) lives in memory or Redis with sub-millisecond access. Warm data (leaderboards, recent match results) uses Redis sorted sets and TTL-indexed keys. Cold data (match history, progression, analytics) is persisted to PostgreSQL with periodic snapshots to S3/Parquet for analytics.

5. High-Level Architecture

The architecture is organized into several layers: the client-facing edge, the matchmaking and session orchestration layer, the game server fleet, the persistence and analytics layer, and the supporting services (chat, anti-cheat, replay).

graph TB subgraph Clients PC[PC Client] Console[Console Client] Mobile[Mobile Client] end subgraph Edge Layer LB[Load Balancer / Anycast] Gateway[Game Gateway / Auth] end subgraph Matchmaking Cluster MQ[Matchmaker Service] QN[Queue Manager] SR[Server Resolver] end subgraph Game Server Fleet GS1[Game Server Instance 1] GS2[Game Server Instance 2] GS3[Game Server Instance N] GSD[Game Server Daemon] end subgraph Supporting Services Chat[Chat Service] AC[Anti-Cheat Service] RP[Replay Recorder] Voice[Voice Relay] Spec[Spectator Service] end subgraph Persistence Layer PG[(PostgreSQL)] Redis[(Redis Cluster)] S3[(Object Storage)] Kafka[Kafka / Event Stream] end subgraph Operations M[Metrics / Grafana] Logs[Centralized Logging] Deploy[Game Update Deployer] end PC --> LB Console --> LB Mobile --> LB LB --> Gateway Gateway --> MQ MQ --> QN QN --> Redis MQ --> SR SR --> GS1 SR --> GS2 SR --> GS3 GS1 --> GSD GS2 --> GSD GS3 --> GSD GS1 --> Chat GS1 --> AC GS1 --> RP GS1 --> Voice GS1 --> Spec GS1 --> PG GS1 --> Redis GS1 --> Kafka Kafka --> S3 GSD --> PG M --> GS1 M --> GS2

Component Responsibilities

Game Gateway

The gateway handles initial client connections, authentication (JWT validation), and routes the client to the appropriate matchmaking queue or game server. It maintains a mapping of player-to-server-instance in Redis so reconnects and late joins can be handled without full re-authentication. The gateway also enforces rate limiting and DDoS protection at the edge.

Matchmaker Service

The matchmaker collects players into queues, groups them into balanced matches based on skill rating, party size, and latency, and then requests available server capacity from the Server Resolver. It uses an ELO-based bucketing system where players within a configurable rating range (e.g., ±200 ELO) are eligible for the same match. Queue timeout logic progressively widens the rating window to prevent indefinite waits.

Server Resolver

The Server Resolver maintains real-time capacity information for every game server instance in every region. When the matchmaker produces a candidate match, the resolver identifies the optimal server instance based on: (1) available capacity, (2) geographic proximity to the majority of players, (3) current load, and (4) network quality metrics. It communicates with the Game Server Daemon, which manages the lifecycle of individual server instances on each physical or virtual machine.

Game Server Daemon

The Game Server Daemon (GSD) runs on every machine hosting game server instances. It manages the lifecycle of server processes — starting new instances, monitoring health, collecting metrics, and terminating completed matches. The GSD reports capacity and load metrics to the Server Resolver via a persistent connection and supports rolling deployments of game updates without affecting active matches.

Game Server Instance

Each instance is the authoritative simulation for a single match. It runs the game loop, processes player inputs, executes physics, manages world state, runs anti-cheat checks, records replay data, and replicates state to all connected clients. The instance is completely isolated — no instance communicates with another instance during gameplay, which simplifies consistency guarantees significantly.

Architecture Decision: We use a dedicated server model (all game logic on the server) rather than peer-to-peer. This is essential for competitive integrity — a peer-to-peer architecture creates trust issues and is trivially exploitable. The trade-off is higher infrastructure cost, which is the price of fairness.

6. Game Loop, Tick Rate & Determinism

The game loop is the heartbeat of the server. Every tick, the server must: receive and buffer player inputs, process each input through the game logic, run physics simulation, check win conditions, log events for replay, and broadcast the updated state to all clients. The budget for all of this is the tick interval — 16.6ms at 60Hz or 33.3ms at 30Hz.

Server Game Loop

C#
public class GameServerLoop
{
    private readonly MatchState _state;
    private readonly NetworkManager _network;
    private readonly PhysicsEngine _physics;
    private readonly AntiCheatEngine _antiCheat;
    private readonly ReplayRecorder _replay;
    private readonly float _tickRate;
    private readonly float _tickInterval;  // seconds per tick
    private bool _running;
    private ulong _currentTick;

    public GameServerLoop(MatchState state, NetworkManager network,
        PhysicsEngine physics, AntiCheatEngine antiCheat,
        ReplayRecorder replay, float tickRate = 30f)
    {
        _state = state;
        _network = network;
        _physics = physics;
        _antiCheat = antiCheat;
        _replay = replay;
        _tickRate = tickRate;
        _tickInterval = 1.0f / tickRate;
    }

    public async Task RunAsync(CancellationToken ct)
    {
        _running = true;
        _currentTick = 0;
        double nextTickTime = Stopwatch.GetTimestamp();

        while (_running && !ct.IsCancellationRequested)
        {
            double currentTime = Stopwatch.GetTimestamp();
            double elapsed = (currentTime - nextTickTime) / Stopwatch.Frequency;

            if (elapsed < 0)
            {
                // Not time for next tick yet — yield CPU
                await Task.Delay(1);
                continue;
            }

            // Accumulator-based fixed timestep
            double accumulator = elapsed;
            nextTickTime += _tickInterval * Stopwatch.Frequency;

            while (accumulator >= _tickInterval)
            {
                Tick(_currentTick);
                _currentTick++;
                accumulator -= _tickInterval;
            }

            // Anti-cheat runs asynchronously after each tick
            _antiCheat.AnalyzeTick(_state);
        }
    }

    private void Tick(ulong tick)
    {
        // 1. Receive and buffer all player inputs
        var inputs = _network.CollectInputs();

        // 2. Apply inputs to each player
        foreach (var (playerId, input) in inputs)
        {
            if (_state.Players.TryGetValue(playerId, out var player) && player.IsAlive)
            {
                player.LatestInput = input;
                player.LastProcessedTick = (int)tick;
                _replay.RecordInput(tick, playerId, input);
            }
        }

        // 3. Process game logic per player
        foreach (var player in _state.Players.Values)
        {
            if (!player.IsAlive) continue;
            ProcessPlayerMovement(player, _state);
            ProcessPlayerActions(player, _state);
            ProcessInventory(player, _state);
        }

        // 4. Process projectiles and collisions
        _physics.StepSimulation(_state, _tickInterval);

        // 5. Process game-specific systems
        ProcessZoneShrinkage(_state, tick);       // Battle royale zone
        ProcessBuildingDecay(_state, tick);       // Fortnite builds
        ProcessAbilityCooldowns(_state, tick);    // Valorant abilities

        // 6. Check win conditions
        var result = CheckWinConditions(_state);
        if (result != null)
        {
            EndMatch(_state, result);
            _running = false;
            return;
        }

        // 7. Record snapshot for replay
        _replay.RecordSnapshot(tick, _state);

        // 8. Broadcast state to all clients
        _network.BroadcastState(_state, tick);

        // 9. Collect telemetry
        CollectTickMetrics(tick);
    }

    private void ProcessPlayerMovement(PlayerState player, MatchState state)
    {
        var input = player.LatestInput;
        // Server-authoritative movement: apply input on server
        var desiredVelocity = CalculateDesiredVelocity(input, player.MovementState);
        var newRotation = Quaternion.Lerp(
            player.Rotation,
            input.LookRotation,
            _tickInterval * player.MovementState.TurnSpeed);

        var collisionResult = _physics.MoveCharacter(
            player.Position, desiredVelocity, _tickInterval);

        player.Position = collisionResult.Position;
        player.Rotation = newRotation;
        player.Velocity = collisionResult.Velocity;
        player.MovementState = collisionResult.NewMovementState;
    }
}

Tick Rate Trade-offs

Tick RateIntervalInput Lag AddedCPU Cost per MatchUse Case
20 Hz50 ms25 ms avgBaseline (1.0x)Battle royale (large world, 100 players)
30 Hz33 ms16.5 ms avg1.5xCompetitive BR, squad-based shooters
64 Hz15.6 ms7.8 ms avg3.2xTactical shooters (CS2)
128 Hz7.8 ms3.9 ms avg6.4xTop-tier competitive (Valorant Premier)
Critical Constraint: The server tick rate must be an exact multiple (or divisor) of the client's network send rate to avoid jitter in input processing. If the server runs at 30Hz, clients should ideally send inputs at 30Hz or 60Hz. Mismatched rates cause inconsistent input sampling and degraded responsiveness.

Determinism Considerations

While a dedicated server model does not strictly require deterministic simulation (since the server is the sole authority), determinism is valuable for replay playback and anti-cheat verification. Determinism means that given the same sequence of inputs, the server produces the same sequence of states. This requires careful handling of floating-point math, random number generation, and data structure iteration order. Use fixed-point math for physics calculations, seed-based PRNGs for random events, and sorted collections where iteration order matters.

7. Network Protocol — UDP vs TCP

The choice of network protocol is one of the most consequential decisions in multiplayer game server design. The two primary options — UDP and TCP — have fundamentally different trade-offs that directly impact gameplay feel and responsiveness.

Why UDP is the Standard for Real-Time Games

TCP guarantees ordered, reliable delivery through its built-in retransmission and flow control mechanisms. While this sounds desirable, it is catastrophic for real-time games due to head-of-line blocking. When a TCP packet is lost, all subsequent packets are buffered at the kernel level until the lost packet is retransmitted and received. This means a single lost packet can stall all subsequent updates for 100–300ms (one round-trip), causing rubber-banding, stuttering, and inconsistent game state.

UDP has no built-in ordering or reliability. Packets are sent independently, and if one is lost, it does not affect the delivery of subsequent packets. This makes UDP ideal for game state updates, where the latest state is always more valuable than any previous state. If a state packet is lost, the next state packet (arriving 30ms later) supersedes it anyway.

Hybrid Approach: UDP + Custom Reliability Layer

Modern game networks use a hybrid approach: UDP as the transport with a custom reliability layer on top. Time-sensitive data (player positions, inputs, physics state) is sent unreliably — a lost packet simply means waiting for the next one. Critical but non-time-sensitive data (match start/end events, player join/leave notifications, chat messages) is sent reliably with sequence numbers and acknowledgments at the application layer.

C#
public enum PacketChannel : byte
{
    Unreliable       = 0,   // Position updates, physics snapshots
    UnreliableSequenced = 1, // Latest state only (old packets discarded)
    ReliableOrdered  = 2,   // Game events, match state changes
    ReliableUnordered = 3,  // Chat messages, inventory updates
}

public class GamePacketHeader
{
    public ushort SequenceNumber { get; set; }    // Per-channel sequence
    public ushort AckSequence { get; set; }       // Last received sequence from peer
    public uint AckBitfield { get; set; }         // Bitmask of received packets before ack
    public PacketChannel Channel { get; set; }
    public byte PayloadType { get; set; }         // StateUpdate, Input, Event, etc.

    public const int HeaderSize = 12;             // bytes

    public byte[] Serialize()
    {
        var buffer = new byte[HeaderSize];
        var span = buffer.AsSpan();
        BinaryPrimitives.WriteUInt16LittleEndian(span, SequenceNumber);
        BinaryPrimitives.WriteUInt16LittleEndian(span[2..], AckSequence);
        BinaryPrimitives.WriteUInt32LittleEndian(span[4..], AckBitfield);
        span[8] = (byte)Channel;
        span[9] = PayloadType;
        // 2 bytes reserved
        return buffer;
    }
}

public class UnreliableChannel
{
    private ushort _sequence;

    public GamePacket CreatePacket(byte[] payload, byte payloadType)
    {
        _sequence++;
        return new GamePacket
        {
            Header = new GamePacketHeader
            {
                SequenceNumber = _sequence,
                Channel = PacketChannel.Unreliable,
                PayloadType = payloadType
            },
            Payload = payload
        };
    }
}

public class ReliableChannel
{
    private ushort _sendSequence;
    private ushort _receiveSequence;
    private readonly Dictionary<ushort, byte[]> _pendingAcks = new();
    private readonly HashSet<ushort> _receivedSequences = new();
    private readonly Queue<(ushort seq, byte[] data)> _receiveQueue = new();

    public GamePacket CreateReliablePacket(byte[] payload, byte payloadType)
    {
        _sendSequence++;
        var packet = new GamePacket
        {
            Header = new GamePacketHeader
            {
                SequenceNumber = _sendSequence,
                Channel = PacketChannel.ReliableOrdered,
                PayloadType = payloadType
            },
            Payload = payload
        };
        _pendingAcks[_sendSequence] = payload;
        return packet;
    }

    public void OnAckReceived(ushort ackSequence, uint bitfield)
    {
        _pendingAcks.Remove(ackSequence);
        for (int i = 0; i < 32; i++)
        {
            if ((bitfield & (1u << i)) != 0)
            {
                _pendingAcks.Remove((ushort)(ackSequence - 1 - i));
            }
        }
    }

    public void ResendLostPackets(Func<GamePacket, bool> sendFunc)
    {
        foreach (var (seq, data) in _pendingAcks)
        {
            var packet = new GamePacket
            {
                Header = new GamePacketHeader
                {
                    SequenceNumber = seq,
                    Channel = PacketChannel.ReliableOrdered,
                    PayloadType = 0
                },
                Payload = data
            };
            sendFunc(packet);
        }
    }
}

Packet Structure

Game packets are compact — every byte counts when sending at high frequency. A typical state update packet structure:

FieldSizeDescription
Packet Header12 bytesSequence, ack, channel, type
Tick Number8 bytesServer tick this state represents
Player Count2 bytesNumber of player states in packet
Per-Player State~150–300 bytesPosition, rotation, health, animation, etc.
World EventsVariableZone updates, building changes, item spawns
Checksum4 bytesCRC32 of payload for corruption detection

QUIC as an Emerging Alternative

QUIC (used by HTTP/3) is gaining traction in game networking because it operates over UDP but provides built-in streams, encryption, and ordered delivery per stream. This allows developers to use reliable ordered delivery for some data streams and unreliable delivery for others — all within the same QUIC connection. However, QUIC's connection establishment overhead and encryption add latency that may not suit the most latency-sensitive game data. Some studios (e.g., Google Stadia, Amazon Luna) have adopted QUIC-based protocols for cloud gaming where the bandwidth benefits outweigh the latency costs.

Best Practice: Use raw UDP with a custom reliability layer for competitive games. Use QUIC for auxiliary services (chat, authentication, match results). This gives you full control over the hot path while benefiting from QUIC's reliability for non-time-critical data.

8. State Synchronization & Replication

State synchronization is the process of keeping all clients seeing a consistent game world. The server is authoritative — it computes the "true" game state each tick — and clients must be brought as close to that state as possible despite network latency, packet loss, and varying connection quality.

Delta Compression

Sending the full world state every tick is wasteful. Most of the world does not change between ticks. Delta compression sends only the differences between the last acknowledged state and the current state, dramatically reducing bandwidth.

C#
public class DeltaCompressor
{
    private readonly Dictionary<Guid, PlayerSnapshot> _lastAckedSnapshots = new();

    public DeltaState ComputeDelta(MatchState current, Guid playerId)
    {
        var delta = new DeltaState { Tick = current.ServerTick };

        foreach (var (pid, currentState) in current.Players)
        {
            if (!_lastAckedSnapshots.TryGetValue(pid, out var lastAcked))
            {
                // Player is new — send full state
                delta.FullStates.Add(pid, currentState.ToSnapshot());
                continue;
            }

            var playerDelta = new PlayerDelta { PlayerId = pid };

            // Position delta — only send if changed by more than threshold
            float posDelta = Vector3.Distance(lastAcked.Position, currentState.Position);
            if (posDelta > 0.01f)
            {
                playerDelta.Position = currentState.Position;
                playerDelta.DeltaFlags |= DeltaFlags.PositionChanged;
            }

            // Rotation delta — quantized to save bandwidth
            if (Quaternion.Angle(lastAcked.Rotation, currentState.Rotation) > 0.5f)
            {
                playerDelta.Rotation = QuantizeRotation(currentState.Rotation);
                playerDelta.DeltaFlags |= DeltaFlags.RotationChanged;
            }

            // Health always sent (critical)
            if (Math.Abs(lastAcked.Health - currentState.Health) > 0.01f)
            {
                playerDelta.Health = currentState.Health;
                playerDelta.DeltaFlags |= DeltaFlags.HealthChanged;
            }

            // Shield
            if (Math.Abs(lastAcked.Shield - currentState.Shield) > 0.01f)
            {
                playerDelta.Shield = currentState.Shield;
                playerDelta.DeltaFlags |= DeltaFlags.ShieldChanged;
            }

            // Alive status — always sent when changed
            if (lastAcked.IsAlive != currentState.IsAlive)
            {
                playerDelta.IsAlive = currentState.IsAlive;
                playerDelta.DeltaFlags |= DeltaFlags.AliveChanged;
            }

            if (playerDelta.DeltaFlags != DeltaFlags.None)
            {
                delta.PlayerDeltas.Add(playerDelta);
            }
        }

        // Track what we sent for this player's ack
        foreach (var (pid, state) in current.Players)
        {
            _lastAckedSnapshots[pid] = state.ToSnapshot();
        }

        return delta;
    }

    private static ushort QuantizeRotation(Quaternion q)
    {
        // Compress quaternion to 16 bits using smallest-three encoding
        float maxVal = Math.Max(Math.Max(Math.Abs(q.X), Math.Abs(q.Y)),
                                Math.Max(Math.Abs(q.Z), Math.Abs(q.W)));
        int i0 = 0, i1 = 1, i2 = 2;
        float sign = q.W >= 0 ? 1f : -1f;
        // ... smallest-three compression logic
        return (ushort)((i0 << 14) | (i1 << 12) |
                        ((ushort)((q.X / maxVal * 0.5f + 0.5f) * 4095) << 0));
    }
}

[Flags]
public enum DeltaFlags : byte
{
    None            = 0,
    PositionChanged = 1,
    RotationChanged = 2,
    HealthChanged   = 4,
    ShieldChanged   = 8,
    AliveChanged    = 16,
    InventoryChanged = 32,
    AnimationChanged = 64,
    FullSnapshot    = 128
}

Client-Side Interpolation

Clients receive state updates at the server's tick rate (e.g., 30Hz) but render at a higher rate (e.g., 60Hz or 144Hz). Between server ticks, the client interpolates between the last two received states to produce smooth motion. The client renders approximately one server tick behind the latest received state to always have two snapshots to interpolate between.

C#
public class ClientInterpolator
{
    private readonly Queue<StateSnapshot> _snapshotBuffer = new();
    private const int InterpolationDelayTicks = 2; // Render 2 ticks behind

    public void OnStateReceived(StateSnapshot snapshot)
    {
        _snapshotBuffer.Enqueue(snapshot);
    }

    public PlayerRenderState InterpolatePlayer(Guid playerId, float renderTime)
    {
        var snapshots = _snapshotBuffer.ToArray();
        if (snapshots.Length < 3)
            return snapshots.Length > 0
                ? snapshots[^1].GetPlayerState(playerId)
                : default;

        // Target render tick = latest server tick - interpolation delay
        ulong targetTick = snapshots[^1].Tick - InterpolationDelayTicks;

        // Find the two snapshots surrounding the target tick
        StateSnapshot from = null, to = null;
        for (int i = 0; i < snapshots.Length - 1; i++)
        {
            if (snapshots[i].Tick <= targetTick && snapshots[i + 1].Tick >= targetTick)
            {
                from = snapshots[i];
                to = snapshots[i + 1];
                break;
            }
        }

        if (from == null || to == null)
            return snapshots[^1].GetPlayerState(playerId);

        // Linear interpolation factor
        float t = (float)(targetTick - from.Tick) / (to.Tick - from.Tick);

        var fromState = from.GetPlayerState(playerId);
        var toState = to.GetPlayerState(playerId);

        return new PlayerRenderState
        {
            Position = Vector3.Lerp(fromState.Position, toState.Position, t),
            Rotation = Quaternion.Slerp(fromState.Rotation, toState.Rotation, t),
            Health = Mathf.Lerp(fromState.Health, toState.Health, t),
            AnimationFrame = Mathf.Lerp(fromState.AnimationFrame, toState.AnimationFrame, t)
        };
    }
}
Key Insight: Interpolation adds approximately one tick of visual latency (33ms at 30Hz) but eliminates jitter entirely. Clients always have two snapshots to blend between, producing perfectly smooth motion. This is the standard approach used by Valve's Source engine and Unreal Engine's network replication system.

Priority-Based Replication

Not all entities need to be replicated to all players at the same rate. A player 500 meters away does not need per-tick position updates. Priority-based replication assigns update frequency based on relevance (distance, line of sight, importance) to each client, significantly reducing bandwidth.

9. Lag Compensation, Prediction & Rollback

Lag compensation is the most technically complex subsystem in a competitive game server. The fundamental problem: a player shoots at what they see on screen, but by the time their input reaches the server, the target has moved. Without lag compensation, the server would evaluate the shot against the target's current position — not the position the shooter saw — making it impossible to hit fast-moving targets at high latency.

Server-Side Lag Compensation

The server maintains a circular buffer of historical world states (typically the last 1 second of snapshots). When a player fires a shot, the client sends the tick number they were rendering at the moment of the shot. The server rewinds the world to that historical state and evaluates the hit detection against the rewound positions.

C#
public class LagCompensator
{
    private readonly Queue<WorldSnapshot> _history = new();
    private const int MaxHistoryTicks = 60; // 1 second at 60Hz

    public void RecordSnapshot(ulong tick, MatchState state)
    {
        var snapshot = new WorldSnapshot
        {
            Tick = tick,
            Timestamp = DateTime.UtcNow,
            Players = state.Players.ToDictionary(
                kvp => kvp.Key,
                kvp => kvp.Value.ToSnapshot())
        };

        _history.Enqueue(snapshot);
        while (_history.Count > MaxHistoryTicks)
            _history.Dequeue();
    }

    public HitResult EvaluateHit(
        Guid shooterId,
        Vector3 shotOrigin,
        Vector3 shotDirection,
        float weaponRange,
        ulong clientTick,
        ulong serverTick)
    {
        // Calculate how many ticks back to rewind
        ulong ticksBack = serverTick - clientTick;
        if (ticksBack > MaxHistoryTicks)
            ticksBack = MaxHistoryTicks; // Clamp to prevent stale lookups

        // Find the historical snapshot closest to client's tick
        WorldSnapshot? rewoundState = null;
        foreach (var snapshot in _history)
        {
            if (snapshot.Tick <= clientTick)
            {
                rewoundState = snapshot;
                break;
            }
        }

        if (rewoundState == null)
            return HitResult.Miss; // No historical data available

        // Evaluate hit detection against rewound player positions
        foreach (var (playerId, playerSnap) in rewoundState.Players)
        {
            if (playerId == shooterId) continue; // Can't shoot yourself
            if (!playerSnap.IsAlive) continue;

            // Sphere or capsule intersection test
            var hitbox = GetPlayerHitbox(playerSnap);
            if (RaycastCapsule(shotOrigin, shotDirection, weaponRange,
                               hitbox, out float hitDistance, out Vector3 hitPoint))
            {
                float damage = CalculateDamage(weaponRange, hitDistance, hitPoint);
                return new HitResult
                {
                    Hit = true,
                    TargetId = playerId,
                    Damage = damage,
                    HitPoint = hitPoint,
                    RewoundTicks = ticksBack,
                    RewoundPosition = playerSnap.Position
                };
            }
        }

        return HitResult.Miss;
    }

    private bool RaycastCapsule(Vector3 origin, Vector3 direction, float maxDistance,
        Capsule hitbox, out float distance, out Vector3 hitPoint)
    {
        // GJK algorithm or simplified sphere-sweep for performance
        hitPoint = Vector3.Zero;
        distance = 0;

        // Simplified: check ray vs expanded sphere (capsule as two spheres + cylinder)
        float expandedRadius = hitbox.Radius;
        Vector3 center = hitbox.Bottom + (hitbox.Top - hitbox.Bottom) * 0.5f;
        float halfHeight = Vector3.Distance(hitbox.Bottom, hitbox.Top) * 0.5f;

        // Ray-swept sphere test along capsule axis
        Vector3 oc = origin - center;
        float a = Vector3.Dot(direction, direction);
        float b = 2f * Vector3.Dot(oc, direction);
        float c = Vector3.Dot(oc, oc) - expandedRadius * expandedRadius;
        float discriminant = b * b - 4 * a * c;

        if (discriminant < 0) return false;

        distance = (-b - Mathf.Sqrt(discriminant)) / (2 * a);
        if (distance < 0 || distance > maxDistance) return false;

        hitPoint = origin + direction * distance;
        return true;
    }
}

public struct WorldSnapshot
{
    public ulong Tick { get; set; }
    public DateTime Timestamp { get; set; }
    public Dictionary<Guid, PlayerSnapshot> Players { get; set; }
}

public struct HitResult
{
    public bool Hit { get; set; }
    public Guid TargetId { get; set; }
    public float Damage { get; set; }
    public Vector3 HitPoint { get; set; }
    public ulong RewoundTicks { get; set; }
    public Vector3 RewoundPosition { get; set; }

    public static HitResult Miss => new() { Hit = false };
}

Client-Side Prediction

Without prediction, the client would have to wait one round-trip for the server to acknowledge each movement input before updating the player's position on screen. This adds 30–100ms of apparent input lag. Client-side prediction resolves this by having the client immediately apply its own inputs locally, showing instant response, while simultaneously sending the input to the server.

When the server's acknowledgment arrives with the authoritative position, the client compares it to its predicted position. If they match, no correction is needed. If they diverge (due to collision with an invisible wall, a cheat detection correction, or a physics discrepancy), the client smoothly reconciles back to the server's position.

C#
public class ClientPredictor
{
    private readonly Queue<PendingInput> _pendingInputs = new();
    private Vector3 _lastServerPosition;
    private ulong _lastServerTick;

    public void OnLocalInput(PlayerInput input, ulong currentTick)
    {
        // Immediately apply input locally for responsive feel
        ApplyInput(input);

        // Store for reconciliation when server acknowledges
        _pendingInputs.Enqueue(new PendingInput
        {
            Tick = currentTick,
            Input = input,
            PredictedPosition = LocalPlayer.Position,
            PredictedRotation = LocalPlayer.Rotation
        });
    }

    public void OnServerAck(Guid playerId, ulong tick, Vector3 authoritativePos,
        Quaternion authoritativeRot)
    {
        // Remove all inputs up to and including the acknowledged tick
        while (_pendingInputs.Count > 0 && _pendingInputs.Peek().Tick <= tick)
        {
            var acked = _pendingInputs.Dequeue();
        }

        // Compare predicted vs authoritative
        if (_pendingInputs.Count > 0)
        {
            var latestPredicted = _pendingInputs.Peek().PredictedPosition;
            float error = Vector3.Distance(latestPredicted, authoritativePos);

            if (error > 0.01f)
            {
                // Server correction needed
                StartReconciliation(authoritativePos, authoritativeRot);
            }
        }

        _lastServerPosition = authoritativePos;
        _lastServerTick = tick;
    }

    private void StartReconciliation(Vector3 targetPos, Quaternion targetRot)
    {
        // Snap to server position and re-apply all pending (unacknowledged) inputs
        LocalPlayer.Position = targetPos;
        LocalPlayer.Rotation = targetRot;

        foreach (var pending in _pendingInputs)
        {
            ApplyInput(pending.Input);
        }
    }
}

Entity Interpolation for Other Players

While the local player uses prediction, other players' positions are displayed via interpolation between server-received states. This avoids the rubber-banding effect — other players appear to move smoothly between the states the server has confirmed, with no speculative prediction of their movements.

Anti-Cheat Interaction: Lag compensation and client-side prediction are the two primary attack surfaces for cheaters. A cheating client can send falsified inputs or fake tick numbers to rewind the server to a favorable state. The server must validate that client-reported tick numbers are within acceptable bounds (e.g., current server tick minus 1 second) and that reported positions are physically plausible given the player's previous state and movement capabilities.

10. Matchmaking System

The matchmaking system is responsible for assembling fair, low-latency matches from a pool of queuing players. A good matchmaking system balances match quality (skill balance) against queue time — players want fair matches but do not want to wait forever to find one.

Matchmaking Algorithm

C#
public class Matchmaker
{
    private readonly RedisGameCache _redis;
    private readonly ServerResolver _serverResolver;
    private readonly float _baseRatingRange;
    private readonly float _rangeExpansionPerSecond;
    private readonly int _targetTeamSize;
    private readonly int _targetTeamCount;

    public Matchmaker(RedisGameCache redis, ServerResolver serverResolver,
        float baseRatingRange = 100f, float rangeExpansionPerSecond = 5f)
    {
        _redis = redis;
        _serverResolver = serverResolver;
        _baseRatingRange = baseRatingRange;
        _rangeExpansionPerSecond = rangeExpansionPerSecond;
        _targetTeamSize = 4;  // Squad-based
        _targetTeamCount = 25; // 25 squads = 100 players
    }

    public async Task<MatchCandidate?> TryFormMatchAsync(string gameMode, string region)
    {
        int totalPlayersNeeded = _targetTeamSize * _targetTeamCount;

        // Get players in queue sorted by wait time (FIFO within rating buckets)
        var queuedPlayers = await _redis.GetQueuedPlayersAsync(gameMode, region);
        if (queuedPlayers.Count < totalPlayersNeeded)
            return null;

        // Calculate adaptive rating range based on oldest player's wait time
        var oldestPlayer = queuedPlayers[0];
        float waitSeconds = (float)(DateTime.UtcNow - oldestPlayer.QueuedAt).TotalSeconds;
        float ratingRange = _baseRatingRange +
            (waitSeconds * _rangeExpansionPerSecond);

        // Try to find a match within the expanded rating range
        double medianRating = queuedPlayers[queuedPlayers.Count / 2].Rating;
        double minRating = medianRating - ratingRange;
        double maxRating = medianRating + ratingRange;

        var eligible = queuedPlayers
            .Where(p => p.Rating >= minRating && p.Rating <= maxRating)
            .ToList();

        if (eligible.Count < totalPlayersNeeded)
            return null;

        // Team composition: balance teams by aggregate rating
        var teams = ComposeBalancedTeams(eligible, totalPlayersNeeded);

        // Find optimal server region
        var playerIds = eligible.Select(p => p.PlayerId).ToList();
        var serverInfo = await _serverResolver.FindBestServerAsync(
            gameMode, region, playerIds);

        if (serverInfo == null) return null;

        // Create match candidate
        return new MatchCandidate
        {
            MatchId = Guid.NewGuid(),
            GameMode = gameMode,
            Players = eligible.Select(p => new MatchPlayer
            {
                PlayerId = p.PlayerId,
                Team = teams[p.PlayerId],
                Rating = p.Rating,
                PartyId = p.PartyId
            }).ToList(),
            ServerInstanceId = serverInfo.InstanceId,
            Region = serverInfo.Region,
            AverageRating = eligible.Average(p => p.Rating),
            RatingSpread = eligible.Max(p => p.Rating) -
                          eligible.Min(p => p.Rating),
            CreatedAt = DateTime.UtcNow
        };
    }

    private Dictionary<Guid, int> ComposeBalancedTeams(
        List<QueuedPlayer> players, int totalNeeded)
    {
        var teams = new Dictionary<Guid, int>();
        int teamCount = totalNeeded / _targetTeamSize;

        // Sort by rating descending for snake draft
        var sorted = players.OrderBy(p => -p.Rating).ToList();
        for (int i = 0; i < totalNeeded; i++)
        {
            int teamIndex;
            int round = i / teamCount;
            if (round % 2 == 0)
                teamIndex = i % teamCount;
            else
                teamIndex = teamCount - 1 - (i % teamCount);

            teams[sorted[i].PlayerId] = teamIndex;
        }

        return teams;
    }
}

Rating Algorithm Comparison

AlgorithmSystemStrengthsWeaknesses
EloChess, early LoLSimple, well-understood, fastAssumes 1v1, no confidence metric
Glicko-2CS:GO (MMR), Pokemon ShowdownRating deviation (RD), volatility, handles inactivityMore complex to tune parameters
TrueSkillXbox Live, HaloHandles team games, party queue, multi-skillMicrosoft patent, Bayesian inference is expensive
TrueSkill 2Halo Infinite, newer Xbox titlesHandles quit/AFK, fireteams, multi-objectiveComplex, requires lots of tuning data
OpenSkillOpen-source alternativeMIT licensed, handles teams, BayesianLess battle-tested at scale
Industry Standard: Most modern games use a variant of TrueSkill or Glicko-2 for team-based matchmaking. For new projects, OpenSkill provides a free, well-tested alternative. The key insight is that skill rating is not just a number — it must encode confidence (how certain we are of a player's skill) and volatility (how much their skill varies between sessions).

11. Game Session & Lobby Management

Game session management handles the full lifecycle of a match: from when players are matched together, through the pre-game lobby, the active match, and post-match results. This state machine must be robust — players can disconnect, servers can crash, and the system must handle all edge cases gracefully.

Session State Machine

C#
public class GameSessionManager
{
    private readonly RedisGameCache _redis;
    private readonly Dictionary<Guid, GameSession> _sessions = new();

    public async Task<GameSession> CreateSessionAsync(MatchCandidate candidate)
    {
        var session = new GameSession
        {
            MatchId = candidate.MatchId,
            State = SessionState.Creating,
            Players = candidate.Players.ToDictionary(
                p => p.PlayerId, p => new SessionPlayer
                {
                    PlayerId = p.PlayerId,
                    Team = p.Team,
                    State = PlayerSessionState.Connecting,
                    ConnectionToken = GenerateToken(),
                    ConnectedAt = null,
                    DisconnectedAt = null
                }),
            ServerInstanceId = candidate.ServerInstanceId,
            Region = candidate.Region,
            CreatedAt = DateTime.UtcNow,
            StateHistory = new List<SessionStateTransition>
            {
                new(SessionState.Creating, DateTime.UtcNow)
            }
        };

        _sessions[candidate.MatchId] = session;

        // Register player-to-server mapping in Redis
        foreach (var player in candidate.Players)
        {
            await _redis.SetPlayerServerAsync(
                player.PlayerId, candidate.ServerInstanceId, candidate.MatchId);
        }

        // Notify the game server to allocate the match
        var serverReady = await NotifyGameServerAllocateAsync(candidate);
        if (!serverReady)
        {
            session.State = SessionState.Failed;
            await HandleSessionFailure(session, "Server allocation failed");
            return session;
        }

        session.State = SessionState.WaitingForPlayers;
        return session;
    }

    public async Task OnPlayerConnected(Guid matchId, Guid playerId)
    {
        var session = _sessions[matchId];
        var player = session.Players[playerId];
        player.State = PlayerSessionState.Connected;
        player.ConnectedAt = DateTime.UtcNow;

        // Check if all players connected
        if (session.Players.Values.All(p => p.State == PlayerSessionState.Connected))
        {
            session.State = SessionState.AllPlayersConnected;
            session.GameStartTime = DateTime.UtcNow.AddSeconds(10); // 10s countdown
        }

        await UpdateSessionRedis(session);
    }

    public async Task OnPlayerDisconnected(Guid matchId, Guid playerId)
    {
        var session = _sessions[matchId];
        var player = session.Players[playerId];
        player.State = PlayerSessionState.Disconnected;
        player.DisconnectedAt = DateTime.UtcNow;

        // In competitive games, disconnects are punished ( deserter penalty )
        // But the match continues — AI takes over or player is removed

        // If too many players disconnect, consider ending the match
        int connected = session.Players.Values
            .Count(p => p.State == PlayerSessionState.Connected);
        int total = session.Players.Count;

        if (connected < total * 0.5)
        {
            // Less than 50% connected — cancel match
            session.State = SessionState.Cancelled;
            await HandleMatchCancellation(session);
        }

        await UpdateSessionRedis(session);
    }

    public async Task EndSession(Guid matchId, MatchResult result)
    {
        var session = _sessions[matchId];
        session.State = SessionState.Completed;
        session.Result = result;
        session.EndedAt = DateTime.UtcNow;

        // Process rating changes
        foreach (var player in session.Players.Values)
        {
            if (player.State == PlayerSessionState.Disconnected)
            {
                // Apply deserter penalty
                await ApplyDeserterPenaltyAsync(player.PlayerId);
            }
        }

        // Publish match result event
        await PublishMatchResult(session, result);

        // Cleanup Redis mappings
        foreach (var player in session.Players.Values)
        {
            await _redis.RemovePlayerServerAsync(player.PlayerId);
        }

        // Remove from active sessions after retention period
        _ = Task.Delay(TimeSpan.FromMinutes(30)).ContinueWith(_ =>
        {
            _sessions.Remove(matchId);
        });
    }
}

public class GameSession
{
    public Guid MatchId { get; set; }
    public SessionState State { get; set; }
    public Dictionary<Guid, SessionPlayer> Players { get; set; }
    public Guid ServerInstanceId { get; set; }
    public string Region { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? GameStartTime { get; set; }
    public DateTime? EndedAt { get; set; }
    public MatchResult? Result { get; set; }
    public List<SessionStateTransition> StateHistory { get; set; }
}

public enum SessionState
{
    Creating,
    WaitingForPlayers,
    AllPlayersConnected,
    InGame,
    PostGame,
    Completed,
    Cancelled,
    Failed
}
Edge Case: The most complex session management scenario is a server crash mid-match. The system must detect the crash (via heartbeat timeout), attempt to migrate the session to a new server (if the game supports it), or gracefully end the match and handle rating adjustments. For battle royale games, partial migration is impractical — the match is simply ended and players receive partial rewards.

12. Anti-Cheat System

Cheating is an existential threat to competitive multiplayer games. A single cheater can ruin the experience for 99 other players. Modern anti-cheat operates at multiple layers: client-side integrity verification, server-side behavioral analysis, and post-match statistical detection.

Multi-Layer Anti-Cheat Architecture

graph LR subgraph Client Side A[Kernel-Level Driver] --> B[Memory Integrity Check] B --> C[Process Hook Detection] C --> D[Input Validation] end subgraph Server Side E[Input Plausibility Check] --> F[Movement Validation] F --> G[Shot Validation] G --> H[Statistical Anomaly Detection] end subgraph Post-Match I[Behavioral Analysis] --> J[Win Rate / KDA Analysis] J --> K[Report Aggregation] K --> L[Manual Review Queue] end D --> E L --> M[Temporary Ban] L --> N[Permanent Ban]

Server-Side Validation Code

C#
public class ServerAntiCheat
{
    private readonly float _maxMovementSpeed;
    private readonly float _maxJumpHeight;
    private readonly float _maxFireRate;
    private readonly float _maxTurnSpeed;
    private readonly Dictionary<Guid, AntiCheatPlayerData> _playerData = new();

    public ServerAntiCheat(GameConfig config)
    {
        _maxMovementSpeed = config.MaxMovementSpeed;   // e.g., 600 units/sec
        _maxJumpHeight = config.MaxJumpHeight;          // e.g., 300 units
        _maxFireRate = config.MaxFireRate;              // e.g., 10 rounds/sec
        _maxTurnSpeed = config.MaxTurnSpeed;            // e.g., 180 degrees/sec
    }

    public CheatDetectionResult ValidatePlayerInput(
        Guid playerId, PlayerInput input, PlayerState currentState, ulong tick)
    {
        var data = _playerData.GetOrAdd(playerId, _ => new AntiCheatPlayerData());

        var violations = new List<CheatViolation>();

        // 1. Movement speed validation
        float distance = Vector3.Distance(data.LastPosition, currentState.Position);
        float speed = distance * (1.0f / _tickInterval);
        if (speed > _maxMovementSpeed * 1.1f) // 10% tolerance for floating point
        {
            violations.Add(new CheatViolation
            {
                Type = CheatType.SpeedHack,
                Severity = ViolationSeverity.High,
                Details = $"Speed {speed:F1} exceeds max {_maxMovementSpeed}"
            });
        }

        // 2. Teleportation detection — large position jumps between ticks
        if (distance > _maxMovementSpeed * _tickInterval * 3f)
        {
            violations.Add(new CheatViolation
            {
                Type = CheatType.TeleportHack,
                Severity = ViolationSeverity.Critical,
                Details = $"Teleported {distance:F1} units in one tick"
            });
        }

        // 3. Fire rate validation
        float timeSinceLastShot = (tick - data.LastShotTick) * _tickInterval;
        if (timeSinceLastShot < 1.0f / _maxFireRate * 0.9f)
        {
            data.RapidFireCount++;
            if (data.RapidFireCount > 3)
            {
                violations.Add(new CheatViolation
                {
                    Type = CheatType.RapidFire,
                    Severity = ViolationSeverity.High,
                    Details = $"Fire rate {1f / timeSinceLastShot:F1} exceeds max {_maxFireRate}"
                });
            }
        }
        else
        {
            data.RapidFireCount = Math.Max(0, data.RapidFireCount - 1);
        }

        // 4. Turn speed validation (anti-aimbot)
        float turnAngle = Quaternion.Angle(data.LastRotation, currentState.Rotation);
        float turnSpeed = turnAngle / _tickInterval;
        if (turnSpeed > _maxTurnSpeed * 1.2f)
        {
            data.SuspiciousTurnCount++;
            if (data.SuspiciousTurnCount > 5)
            {
                violations.Add(new CheatViolation
                {
                    Type = CheatType.Aimbot,
                    Severity = ViolationSeverity.Medium,
                    Details = $"Turn speed {turnSpeed:F0} deg/s exceeds max {_maxTurnSpeed}"
                });
            }
        }

        // 5. Line of sight check (wallhack detection)
        if (input.IsFiring && !HasLineOfSight(currentState.Position, input.AimDirection))
        {
            violations.Add(new CheatViolation
            {
                Type = CheatType.Wallhack,
                Severity = ViolationSeverity.Medium,
                Details = "Firing without line of sight to target"
            });
        }

        // Update tracking data
        data.LastPosition = currentState.Position;
        data.LastRotation = currentState.Rotation;
        data.LastShotTick = tick;

        // Accumulate violations for pattern detection
        data.ViolationHistory.Add((tick, violations));

        return new CheatDetectionResult
        {
            PlayerId = playerId,
            Violations = violations,
            ShouldKick = violations.Any(v => v.Severity == ViolationSeverity.Critical),
            ShouldBan = data.ViolationHistory.Count(v =>
                v.Violations.Any(x => x.Severity >= ViolationSeverity.High)) > 10,
            Confidence = CalculateConfidence(data)
        };
    }

    private float CalculateConfidence(AntiCheatPlayerData data)
    {
        int highSeverity = data.ViolationHistory
            .Count(v => v.Violations.Any(x => x.Severity == ViolationSeverity.High));
        int mediumSeverity = data.ViolationHistory
            .Count(v => v.Violations.Any(x => x.Severity == ViolationSeverity.Medium));

        float confidence = Math.Min(1.0f,
            (highSeverity * 0.3f) + (mediumSeverity * 0.1f));
        return confidence;
    }
}

Post-Match Statistical Detection

Some cheats are too sophisticated to detect in real-time. Post-match analysis examines aggregate statistics across many matches to identify statistical outliers. Players with headshot rates above 95%, impossible kill-to-death ratios, or abnormally high damage-per-game metrics are flagged for manual review or automated temporary bans.

Industry Practice: Riot Games' Vanguard uses a kernel-level driver to prevent cheat injection at the OS level. Valve's VAC operates at the user-mode level and uses signature detection. EAC (Easy Anti-Cheat) and BattlEye combine both approaches. For server-side anti-cheat, focus on what you can validate authoritatively — movement plausibility, shot timing, and damage calculations.

13. Leaderboard & Ranking — Elo / Glicko-2

A robust ranking system provides players with a sense of progression and competition. The ranking system must be fair (accurately reflect skill), transparent (players understand what affects their rating), and resistant to manipulation (boosting, smurfing, account sharing).

Glicko-2 Implementation

C#
public class Glicko2Rating
{
    private const double DefaultRating = 1500.0;
    private const double DefaultDeviation = 350.0;
    private const double DefaultVolatility = 0.06;
    private const double Tau = 0.5;          // System volatility constraint
    private const double Epsilon = 0.000001;  // Convergence tolerance

    public double Rating { get; set; } = DefaultRating;
    public double RatingDeviation { get; set; } = DefaultDeviation;
    public double Volatility { get; set; } = DefaultVolatility;
    public int GamesPlayed { get; set; }

    /// <summary>
    /// Update rating after a set of match results using Glicko-2 algorithm
    /// </summary>
    public void UpdateRating(List<MatchResult_Glicko> results)
    {
        if (results.Count == 0) return;

        // Step 1: Convert to Glicko-2 scale
        double mu = (Rating - DefaultRating) / DefaultDeviation;
        double phi = RatingDeviation / DefaultDeviation;
        double sigma = Volatility;

        // Step 2: Compute the estimated pre-rating period improvement (v)
        double v = ComputeV(mu, phi, sigma, results);

        // Step 3: Compute the improvement delta (delta)
        double delta = ComputeDelta(mu, phi, v, results);

        // Step 4: Update volatility (sigma) using Illinois algorithm
        double newSigma = UpdateVolatility(phi, delta, v, sigma);

        // Step 5: Update rating deviation (phi)
        double phiStar = Math.Sqrt(phi * phi + newSigma * newSigma);
        double newPhi = 1.0 / Math.Sqrt(1.0 / (phiStar * phiStar) + 1.0 / v);

        // Step 6: Update rating (mu)
        double muPrime = mu + (newPhi * newPhi / 1.0) *
            ComputeGlicko2G(phi, results);

        // Convert back to Glicko scale
        Rating = muPrime * DefaultDeviation + DefaultRating;
        RatingDeviation = newPhi * DefaultDeviation;
        Volatility = newSigma;
        GamesPlayed += results.Count;

        // Clamp values
        Rating = Math.Max(100, Math.Min(4000, Rating));
        RatingDeviation = Math.Max(30, Math.Min(350, RatingDeviation));
    }

    private double ComputeV(double mu, double phi, double sigma,
        List<MatchResult_Glicko> results)
    {
        double sum = 0;
        foreach (var result in results)
        {
            double opponentMu = (result.OpponentRating - DefaultRating) / DefaultDeviation;
            double opponentPhi = result.OpponentDeviation / DefaultDeviation;
            double gPhi = ComputeGlicko2G(opponentPhi, null);
            double expectedScore = ComputeExpectedScore(mu, opponentMu, gPhi);
            sum += gPhi * gPhi * expectedScore * (1 - expectedScore);
        }
        return 1.0 / sum;
    }

    private double ComputeDelta(double mu, double phi, double v,
        List<MatchResult_Glicko> results)
    {
        double sum = 0;
        foreach (var result in results)
        {
            double opponentMu = (result.OpponentRating - DefaultRating) / DefaultDeviation;
            double opponentPhi = result.OpponentDeviation / DefaultDeviation;
            double gPhi = ComputeGlicko2G(opponentPhi, null);
            double expectedScore = ComputeExpectedScore(mu, opponentMu, gPhi);
            sum += gPhi * (result.Score - expectedScore);
        }
        return v * sum;
    }

    private double ComputeGlicko2G(double phi, object? _)
    {
        return 1.0 / Math.Sqrt(1.0 + (3.0 * phi * phi) / (Math.PI * Math.PI));
    }

    private double ComputeExpectedScore(double mu, double opponentMu, double g)
    {
        return 1.0 / (1.0 + Math.Exp(-g * (mu - opponentMu)));
    }

    private double UpdateVolatility(double phi, double delta, double v, double sigma)
    {
        // Illinois algorithm for finding new volatility
        double a = Math.Log(sigma * sigma);
        double A = a;

        double f = x => Math.Exp(x) * (delta * delta - phi * phi - v - Math.Exp(x))
            / (2 * Math.Pow(phi * phi + v + Math.Exp(x), 2)) - (x - a) / (Tau * Tau);

        double B, fB;
        if (delta * delta > phi * phi + v)
        {
            B = Math.Log(delta * delta - phi * phi - v);
            fB = f(B);
        }
        else
        {
            double k = 1;
            B = a - k * Tau;
            fB = f(B);
            while (fB < 0)
            {
                k++;
                B = a - k * Tau;
                fB = f(B);
            }
        }

        double fA = f(A);
        while (Math.Abs(B - A) > Epsilon)
        {
            double C = A + (A - B) * fA / (fB - fA);
            double fC = f(C);
            if (fC * fB <= 0)
            {
                A = B;
                fA = fB;
            }
            B = C;
            fB = fC;
        }

        return Math.Exp(A / 2);
    }

    public int GetRankTier()
    {
        // Map rating to competitive rank tiers
        if (Rating < 1200) return 1;       // Bronze
        if (Rating < 1500) return 2;       // Silver
        if (Rating < 1800) return 3;       // Gold
        if (Rating < 2100) return 4;       // Platinum
        if (Rating < 2400) return 5;       // Diamond
        if (Rating < 2700) return 6;       // Master
        return 7;                            // Grandmaster
    }
}

Rating Distribution & Rank Tiers

TierRating RangePercentileTypical Population
Bronze0 – 1200Bottom 30%30%
Silver1200 – 150030% – 55%25%
Gold1500 – 180055% – 75%20%
Platinum1800 – 210075% – 88%13%
Diamond2100 – 240088% – 95%7%
Master2400 – 270095% – 98.5%3.5%
Grandmaster2700+Top 1.5%1.5%
Smurf Detection: Glicko-2 handles smurfs naturally through its rating deviation (RD) parameter. A new account starts with high RD (low confidence), so wins/losses cause large rating swings. After ~30 matches, RD decreases and the rating stabilizes. Additionally, track IP/device fingerprint to detect smurfs operating multiple accounts and accelerate their placement match progression.

14. Real-Time Chat System

Real-time chat is a core social feature in multiplayer games. It includes pre-game lobby chat, in-match team voice/text chat, post-game chat, and global channels. The chat system must handle high throughput, support moderation, and integrate with the game client's UI.

Chat Architecture

graph TB Client[Game Client] --> ChatGW[Chat Gateway] ChatGW --> Redis[Redis Pub/Sub] Redis --> ChatNode1[Chat Node 1] Redis --> ChatNode2[Chat Node 2] Redis --> ChatNodeN[Chat Node N] ChatNode1 --> Moderation[Moderation Service] ChatNode1 --> Persistence[(Chat History DB)] ChatNode1 --> Voice[Voice Relay]

Chat Service Implementation

C#
public class ChatService
{
    private readonly IConnectionMultiplexer _redis;
    private readonly ISubscriber _pubsub;
    private readonly ChatModerationService _moderation;
    private readonly ConcurrentDictionary<string, ChatChannel> _channels = new();

    public ChatService(IConnectionMultiplexer redis, ChatModerationService moderation)
    {
        _redis = redis;
        _pubsub = redis.GetSubscriber();
        _moderation = moderation;

        // Subscribe to Redis pub/sub for multi-node message distribution
        _pubsub.Subscribe("chat:global", async (channel, message) =>
        {
            await HandleGlobalMessage(ChatMessage.Deserialize(message));
        });
    }

    public async Task<bool> SendMessageAsync(ChatMessage message)
    {
        // 1. Rate limiting
        if (!await CheckRateLimit(message.SenderId))
            return false;

        // 2. Moderation — profanity filter, spam detection, threat detection
        var modResult = await _moderation.ModerateAsync(message);
        if (modResult.Blocked)
        {
            await NotifySenderBlocked(message.SenderId, modResult.Reason);
            return false;
        }

        if (modResult.Filtered)
        {
            message.Content = modResult.FilteredContent;
        }

        // 3. Route to appropriate channel
        switch (message.ChannelType)
        {
            case ChannelType.Match:
                await PublishToMatchChannel(message);
                break;
            case ChannelType.Team:
                await PublishToTeamChannel(message);
                break;
            case ChannelType.Global:
                await _pubsub.PublishAsync("chat:global",
                    message.Serialize());
                break;
            case ChannelType.Whisper:
                await DeliverWhisperAsync(message);
                break;
        }

        // 4. Persist for history
        await PersistMessageAsync(message);

        return true;
    }

    private async Task PublishToMatchChannel(ChatMessage message)
    {
        string channelKey = $"chat:match:{message.MatchId}";
        await _pubsub.PublishAsync(channelKey, message.Serialize());
    }

    private async Task PublishToTeamChannel(ChatMessage message)
    {
        string channelKey = $"chat:match:{message.MatchId}:team:{message.TeamId}";
        await _pubsub.PublishAsync(channelKey, message.Serialize());
    }

    private async Task PersistMessageAsync(ChatMessage message)
    {
        var db = _redis.GetDatabase();
        string key = $"chat:history:{message.MatchId}";
        var entry = new SortedSetEntry(message.Serialize(), message.Timestamp.Ticks);
        await db.SortedSetAddAsync(key, entry);
        await db.KeyExpireAsync(key, TimeSpan.FromDays(30));
    }
}

public class ChatModerationService
{
    private readonly List<IChatFilter> _filters = new()
    {
        new ProfanityFilter(),
        new SpamDetector(),
        new LinkFilter(),
        new ThreatDetector(),
    };

    public async Task<ModerationResult> ModerateAsync(ChatMessage message)
    {
        foreach (var filter in _filters)
        {
            var result = await filter.CheckAsync(message);
            if (result.Blocked)
                return result;
            if (result.Filtered)
                message = result.FilteredMessage;
        }

        return ModerationResult.Allowed;
    }
}

15. Spectator Mode & Live Observers

Spectator mode allows players and tournament organizers to watch live matches without participating. This is critical for esports broadcasts, content creation, and player education. Spectators receive a delayed version of the game state (typically 30–90 seconds behind) to prevent ghosting — the act of sharing enemy positions with active players.

Spectator Architecture

C#
public class SpectatorManager
{
    private readonly Dictionary<Guid, SpectatorSession> _sessions = new();
    private readonly int _spectatorDelayTicks = 900; // 30 seconds at 30Hz

    public async Task<SpectatorSession?> JoinAsSpectator(
        Guid matchId, Guid viewerId, bool isCaster)
    {
        var match = await GetMatchState(matchId);
        if (match == null) return null;

        // Check if spectators are allowed for this match
        if (!match.AllowsSpectators && !isCaster)
            return null;

        // Rate limit spectator connections
        if (_sessions.Count(s => s.Value.MatchId == matchId) > 50 && !isCaster)
            return null;

        var session = new SpectatorSession
        {
            SessionId = Guid.NewGuid(),
            MatchId = matchId,
            ViewerId = viewerId,
            IsCaster = isCaster,
            DelayTicks = isCaster ? 300 : _spectatorDelayTicks, // Casters get shorter delay
            JoinedAt = DateTime.UtcNow,
            FocusPlayerId = null,  // Free camera by default
        };

        _sessions[session.SessionId] = session;
        return session;
    }

    public StateSnapshot GetDelayedSnapshot(Guid matchId, ulong currentTick)
    {
        // Spectators see the state from N ticks ago
        ulong targetTick = currentTick - (ulong)_spectatorDelayTicks;
        return GetHistoricalSnapshot(matchId, targetTick);
    }

    public void UpdateSpectatorFocus(Guid sessionId, Guid? playerId, CameraMode mode)
    {
        if (_sessions.TryGetValue(sessionId, out var session))
        {
            session.FocusPlayerId = playerId;
            session.CameraMode = mode;
        }
    }
}

public enum CameraMode
{
    FreeCamera,
    FirstPerson,
    ThirdPerson,
    TopDown,
    Replay,           // For post-match replay viewing
    DirectorAI        // AI-controlled camera for automated broadcasts
}

public class SpectatorSession
{
    public Guid SessionId { get; set; }
    public Guid MatchId { get; set; }
    public Guid ViewerId { get; set; }
    public bool IsCaster { get; set; }
    public int DelayTicks { get; set; }
    public Guid? FocusPlayerId { get; set; }
    public CameraMode CameraMode { get; set; }
    public DateTime JoinedAt { get; set; }
}
Ghosting Prevention: The spectator delay is essential for competitive integrity. Without it, a player could have a friend spectate their opponent and relay positions via voice chat. The 30-second delay makes this tactic ineffective, as the information is stale by the time it is communicated. Tournament broadcasts may use even longer delays (up to 3 minutes) for high-stakes matches.

16. Replay System & Playback

The replay system records match data for playback, analysis, and content creation. Unlike video recording, a replay records game inputs and events, allowing the game engine to reconstruct the match from any perspective. This is vastly more storage-efficient and provides interactive viewing capabilities.

Replay Recording Architecture

C#
public class ReplayRecorder
{
    private readonly ReplayBuffer _buffer;
    private readonly int _flushIntervalTicks = 900; // Flush every 30 seconds at 30Hz
    private int _ticksSinceFlush;
    private readonly string _replayId;
    private readonly List<ReplayEvent> _events = new();
    private bool _recording;

    public ReplayRecorder(string replayId, ReplayBuffer buffer)
    {
        _replayId = replayId;
        _buffer = buffer;
        _recording = true;
    }

    public void RecordSnapshot(ulong tick, MatchState state)
    {
        if (!_recording) return;

        // Record only what's needed for replay reconstruction
        var snapshot = new ReplaySnapshot
        {
            Tick = tick,
            Timestamp = DateTime.UtcNow,
            Players = state.Players.ToDictionary(
                kvp => kvp.Key,
                kvp => new ReplayPlayerSnapshot
                {
                    Position = kvp.Value.Position,
                    Rotation = kvp.Value.Rotation,
                    Health = kvp.Value.Health,
                    Shield = kvp.Value.Shield,
                    IsAlive = kvp.Value.IsAlive,
                    WeaponId = kvp.Value.InventoryItems.CurrentWeapon,
                    AnimationState = kvp.Value.MovementState.Animation,
                    BuildState = kvp.Value.BuildingState
                }),
            WorldEvents = new List<WorldEvent>(),
            ZoneState = state.World.ZoneState
        };

        _buffer.WriteSnapshot(snapshot);
        _ticksSinceFlush++;

        if (_ticksSinceFlush >= _flushIntervalTicks)
        {
            _buffer.FlushToDisk();
            _ticksSinceFlush = 0;
        }
    }

    public void RecordInput(ulong tick, Guid playerId, PlayerInput input)
    {
        if (!_recording) return;

        _buffer.WriteInput(new ReplayInput
        {
            Tick = tick,
            PlayerId = playerId,
            Input = input
        });
    }

    public void RecordEvent(ReplayEvent evt)
    {
        if (!_recording) return;
        _events.Add(evt);
        _buffer.WriteEvent(evt);
    }

    public async Task<ReplayMetadata> FinalizeAsync()
    {
        _recording = false;
        _buffer.FlushToDisk();

        var metadata = new ReplayMetadata
        {
            ReplayId = _replayId,
            TotalTicks = _buffer.CurrentTick,
            FileSize = _buffer.GetTotalBytes(),
            Duration = _buffer.GetDuration(),
            PlayerCount = _buffer.GetPlayerCount(),
            Events = _events.Count,
            CreatedAt = DateTime.UtcNow
        };

        // Upload to object storage
        var storagePath = await UploadToStorageAsync(_buffer.GetFilePath());
        metadata.StorageUrl = storagePath;

        // Compress replay data
        await CompressReplayAsync(storagePath);

        return metadata;
    }
}

public class ReplayPlayer
{
    private readonly List<ReplaySnapshot> _snapshots;
    private readonly List<ReplayInput> _inputs;
    private int _currentIndex;

    public void SeekToTick(ulong tick)
    {
        _currentIndex = _snapshots.FindIndex(s => s.Tick >= tick);
        if (_currentIndex < 0) _currentIndex = 0;
    }

    public ReplaySnapshot GetCurrentSnapshot()
    {
        return _snapshots[_currentIndex];
    }

    public bool Advance()
    {
        if (_currentIndex < _snapshots.Count - 1)
        {
            _currentIndex++;
            return true;
        }
        return false;
    }

    public List<ReplayInput> GetInputsForTick(ulong tick)
    {
        return _inputs.Where(i => i.Tick == tick).ToList();
    }
}

Replay File Format

Replay files use a chunked binary format for efficient sequential reading and writing:

Chunk TypeContentFrequencySize per Chunk
HEADERMetadata, version, checksumsOnce at start~2 KB
SNAPSHOTFull world state at tickEvery tick (30Hz)~5–15 KB
INPUTPlayer input for tickPer player per tick~20–50 bytes
EVENTGame events (kills, abilities)As they occur~100–500 bytes
AUDIOVoice chat recordingsContinuous~8 KB/s (compressed)
Storage Efficiency: A 20-minute match with 100 players at 30Hz produces approximately: 20 × 60 × 30 = 36,000 snapshots × ~8 KB = 288 MB of raw replay data. After delta compression and LZ4 compression, this typically reduces to 30–50 MB — less than a minute of 1080p video, but with the ability to view from any angle, slow down, and inspect individual player actions.

17. Reliability, Failure Modes & Disaster Recovery

Multiplayer game servers face a unique reliability challenge: stateful sessions that cannot be trivially migrated. Unlike a web request that can be retried on a different server, a game match in progress is a continuous real-time simulation. The system must be designed to minimize impact when failures occur.

Failure Mode Analysis

FailureImpactDetectionMitigation
Game server crashMatch ends abruptly for all playersHeartbeat timeout (3s)Restart match on new instance if early; end match with partial results if late
Player disconnectSingle player loses connectionClient heartbeat (5s timeout)Grace period (30s) for reconnect; AI takes over; removal if prolonged
Matchmaker failureNo new matches createdHealth check, queue depth monitoringMatchmaker is stateless — horizontal scaling; fallback to regional queue
Redis failureSession lookups fail; leaderboards staleRedis sentinel failoverRead replicas; graceful degradation (cached session data in game server memory)
Database failureMatch results not persistedConnection pool monitoringWrite-ahead log on game server; retry on recovery
Network partitionRegion isolationCross-region health checksEach region operates independently; no cross-region dependencies during gameplay
DDoS attackServer overload, packet floodTraffic anomaly detectionAnycast CDN; rate limiting; IP reputation filtering; scrubbing centers

Graceful Degradation Strategy

C#
public class ResilienceManager
{
    private readonly CircuitBreaker _matchmakerCircuit;
    private readonly CircuitBreaker _databaseCircuit;
    private readonly CircuitBreaker _redisCircuit;

    public ResilienceManager()
    {
        _matchmakerCircuit = new CircuitBreaker(5, TimeSpan.FromSeconds(30));
        _databaseCircuit = new CircuitBreaker(3, TimeSpan.FromSeconds(60));
        _redisCircuit = new CircuitBreaker(3, TimeSpan.FromSeconds(15));
    }

    public async Task OnGameServerCrash(Guid matchId, List<Guid> playerIds)
    {
        // 1. Notify all connected players
        foreach (var playerId in playerIds)
        {
            await NotifyPlayerMatchEnded(playerId, MatchEndReason.ServerCrash);
        }

        // 2. Attempt to recreate the match if it was in early stages
        var matchInfo = await GetMatchInfo(matchId);
        if (matchInfo.ElapsedTime < TimeSpan.FromMinutes(5))
        {
            // Early game — try to restart
            await AttemptMatchRestart(matchId, playerIds);
        }
        else
        {
            // Late game — award partial results based on current standings
            await AwardPartialResults(matchId);
        }

        // 3. Log the incident for operations
        await LogIncidentAsync(new Incident
        {
            Type = IncidentType.GameServerCrash,
            MatchId = matchId,
            Severity = Severity.Medium,
            AffectedPlayers = playerIds.Count,
            Timestamp = DateTime.UtcNow
        });
    }

    private async Task AttemptMatchRestart(Guid matchId, List<Guid> playerIds)
    {
        // Find a new server and restart the match
        var server = await FindAvailableServer(matchId);
        if (server == null)
        {
            // No servers available — fall back to partial results
            await AwardPartialResults(matchId);
            return;
        }

        // Re-create match with same players
        // Players who reconnect within 60 seconds are placed back in
        await CreateNewMatchWithPlayers(playerIds, server, TimeSpan.FromSeconds(60));
    }

    public async Task<T> ExecuteWithFallback<T>(
        Func<Task<T>> primary,
        Func<Task<T>> fallback,
        CircuitBreaker circuit)
    {
        if (circuit.IsOpen)
        {
            return await fallback();
        }

        try
        {
            var result = await primary();
            circuit.RecordSuccess();
            return result;
        }
        catch (Exception ex)
        {
            circuit.RecordFailure();
            return await fallback();
        }
    }
}
Critical: Game server crashes during a ranked match are one of the most impactful reliability failures. Players lose progress, rating calculations become ambiguous, and community trust erodes. The best mitigation is a highly stable game server process (extensive testing, memory leak prevention, watchdog processes) combined with fast restart capabilities and fair partial-result handling.

Rolling Deployment Strategy

Deploying updates to a live game with millions of active matches requires careful orchestration. The standard approach is a rolling deployment that: (1) stops allocating new matches to servers being updated, (2) waits for active matches to complete on those servers, (3) updates the server binaries, (4) health-checks the updated servers, (5) resumes allocation. This ensures no active match is interrupted. For emergency patches (critical bug fixes), a forced restart can be triggered, but this is reserved for severe issues only.

18. Cost Estimation

Multiplayer game servers are infrastructure-intensive. The primary cost driver is the dedicated compute required to run authoritative game simulations — these cannot be shared with other workloads and must maintain high single-thread performance.

Infrastructure Cost Breakdown

ComponentSpecMonthly Cost (per unit)Quantity (Peak)Monthly Total
Game Servers (bare-metal)16-core / 3.5GHz, 32GB RAM, 10Gbps NIC$1,20010,000$12,000,000
Matchmaker (cloud VMs)8 vCPU, 16GB RAM$40050$20,000
Redis Cluster6-node cluster, 64GB RAM each$2,000/node3 clusters (18 nodes)$36,000
PostgreSQL (RDS)db.r6g.2xlarge, Multi-AZ$3,5004 (primary + replicas)$14,000
Chat Servers8 vCPU, 16GB RAM$40020$8,000
Voice Relay Servers16 vCPU, 32GB RAM, high NIC$80030$24,000
Replay Storage (S3)~50 TB/month$0.023/GB50 TB$1,150
Bandwidth (egress)~100 TB/month$0.09/GB100 TB$9,000
CDN / DDoS ProtectionCloudflare Enterprise / AWS ShieldCustom1$10,000
Monitoring / ObservabilityDatadog / Grafana CloudCustom1$5,000
Global Load BalancingAnycast routingCustom1$3,000

Total Monthly Cost Summary

C#
public class CostEstimation
{
    public static void CalculateMonthlyCost()
    {
        var costs = new Dictionary<string, decimal>
        {
            ["Game Servers (bare-metal fleet)"] = 12_000_000m,
            ["Matchmaker Cluster"] = 20_000m,
            ["Redis Cluster"] = 36_000m,
            ["PostgreSQL (RDS)"] = 14_000m,
            ["Chat Servers"] = 8_000m,
            ["Voice Relay Servers"] = 24_000m,
            ["Replay Storage (S3)"] = 1_150m,
            ["Bandwidth Egress"] = 9_000m,
            ["CDN / DDoS Protection"] = 10_000m,
            ["Monitoring / Observability"] = 5_000m,
            ["Global Load Balancing"] = 3_000m,
        };

        decimal total = costs.Values.Sum();

        Console.WriteLine("=== Monthly Infrastructure Cost ===");
        foreach (var (component, cost) in costs)
        {
            Console.WriteLine($"  {component,-35} ${cost,15:N0}");
        }
        Console.WriteLine($"  {"TOTAL",-35} ${total,15:N0}");
        Console.WriteLine($"\n  Annual cost: ${total * 12:N0}");

        // Cost per player per month
        decimal dau = 5_000_000;
        Console.WriteLine($"\n  Cost per DAU per month: ${total / dau:N4}");
        Console.WriteLine($"  Cost per DAU per year:  ${(total * 12) / dau:N2}");
    }
}

// Output:
// === Monthly Infrastructure Cost ===
//   Game Servers (bare-metal fleet)    $    12,000,000
//   Matchmaker Cluster                 $       20,000
//   Redis Cluster                      $       36,000
//   PostgreSQL (RDS)                   $       14,000
//   Chat Servers                       $        8,000
//   Voice Relay Servers                $       24,000
//   Replay Storage (S3)                $        1,150
//   Bandwidth Egress                   $        9,000
//   CDN / DDoS Protection              $       10,000
//   Monitoring / Observability         $        5,000
//   Global Load Balancing              $        3,000
//   TOTAL                              $    12,130,150
//
//   Annual cost: $145,561,800
//
//   Cost per DAU per month: $2.4260
//   Cost per DAU per year:  $29.11

Cost Optimization Strategies

  • Reserved Instances: Commit to 1–3 year reserved contracts for game servers to reduce compute cost by 40–60%. Game server workloads are predictable and long-running, making them ideal for reservations.
  • Spot Instances for Non-Critical Workloads: Chat, replay processing, and analytics can run on spot instances with automatic failover, reducing cost by 60–80% for these components.
  • Regional Scaling: Scale game server capacity based on regional player population. During off-peak hours (e.g., 3 AM local time), reduce capacity by 60–70% through auto-scaling groups.
  • Hybrid Bare-Metal + Cloud: Use bare-metal servers (e.g., Hetzner, OVH, or Equinix Metal) for the core game server fleet at $300–800/machine/month vs $1200+ on cloud, while using cloud for burst capacity and supporting services.
  • Compression: Aggressive packet compression (LZ4) can reduce bandwidth costs by 40–60% with negligible CPU overhead.
Revenue Context: A game generating 5M DAU with a 5% conversion rate to the battle pass ($10/season, ~3 months) generates approximately: 5M × 0.05 × $10 / 3 = $833K/month in battle pass revenue alone. With additional skin/cosmetic purchases, total revenue might reach $2–5M/month. The infrastructure cost of $12M/month would require 30–60M DAU to be sustainable — this is the scale at which games like Fortnite operate. Smaller games must optimize more aggressively or reduce infrastructure requirements through smaller matches and lower tick rates.

19. Interview Questions & Answers

Q1: How would you handle a server crash mid-match in a battle royale?

Answer: The approach depends on how far into the match the crash occurs. If within the first 5 minutes (early game), restart the match with the same players on a new server. For mid/late-game crashes where significant progress has been made, award partial results based on the last known standings: the surviving team(s) receive ranking points proportional to their placement, and eliminated players receive their standard elimination rating. All players receive a "match cancellation" notification with compensation (in-game currency, XP). Implement a write-ahead log on the game server that periodically snapshots match state to persistent storage, enabling partial recovery. The key principle is that a crash should never result in a net rating loss for players — it should be treated as a neutral or slightly positive outcome for participants.

Q2: Why UDP instead of TCP for the game protocol? What about QUIC?

Answer: TCP's head-of-line blocking is the fundamental problem. When a TCP packet is lost, all subsequent packets are buffered until the retransmission completes — this can stall the entire data stream for 100–300ms. In a game, this manifests as rubber-banding and inconsistent state. UDP allows us to design our own reliability semantics: position updates are sent unreliably (the next update supersedes a lost one), while critical events (match start, player killed) are sent reliably at the application layer with selective retransmission. QUIC is excellent for auxiliary services — authentication, chat, match results — because it provides streams, encryption, and reliability without head-of-line blocking between streams. But for the core game data path, raw UDP with a custom reliability layer gives us the most control over latency and bandwidth. Some cloud gaming services use QUIC for the video stream, which makes sense because the video data needs ordered delivery.

Q3: How does lag compensation work, and why can it cause players to be hit "around corners"?

Answer: Lag compensation rewinds the server's world state to the point in time that a shooting player was seeing. The server maintains a circular buffer of the last ~1 second of world states. When a player fires, the client sends the tick number it was rendering at. The server rewinds to that tick and evaluates hit detection. This ensures that if a player saw someone on their screen and shot at them, the server will register the hit — even though the target has moved since then. The "around corners" effect occurs because the rewound positions of the target may be in a location that, on the current server state, appears to be behind a wall. From the shooter's perspective, the target was visible at the time of the shot, but from the target's perspective (and the current server state), the hit appears to come through cover. Mitigations include: (1) capping the maximum rewind to a reasonable window (e.g., 250ms), (2) validating that the shooter had line of sight at the rewound position, and (3) using lag compensation only for hitscan weapons, not projectiles.

Q4: Design the matchmaking system for a game with 10M daily active players.

Answer: Key design decisions: (1) Use regional matchmaking pools (NA-East, NA-West, EU, APAC, etc.) to minimize latency. (2) Use a skill-bucketed queue where players are grouped by rating ranges (e.g., ±100 ELO initially, expanding by ±10 per second of queue time). (3) Party matching: pre-made parties are placed as a unit, and the party's average rating determines its bucket. (4) Use Redis sorted sets for the queue, scored by rating, allowing efficient range queries. (5) The matchmaker runs as a separate, stateless service that reads from Redis and writes match candidates. (6) At 10M DAU with ~2M concurrent at peak, and average 20-minute sessions, we need ~200K concurrent matches at peak. With 100-player BR matches, that's ~2K concurrent matchmaker operations per second. This is achievable with a single matchmaker instance and Redis, but for redundancy, run 3 matchmaker instances with leader election for the active queue processor. (7) Anti-smurf measures: track IP/device fingerprint to detect alternate accounts and accelerate placement matches for suspected smurfs.

Q5: How would you implement an anti-cheat system?

Answer: Multi-layer approach: (1) Client-side: Kernel-level driver (like Vanguard) to prevent memory manipulation and hook injection. (2) Server-side real-time: Validate every player input against physical constraints — movement speed, fire rate, turn speed, trajectory plausibility. Flag violations and accumulate a confidence score. (3) Server-side physics: Run authoritative physics simulation on the server; client-predicted positions that deviate too far from the server's correction trigger investigation. (4) Post-match statistical analysis: Compute per-player statistics (headshot %, KDA, damage/game, accuracy) and compare to population distribution. Players beyond 3 standard deviations are flagged for manual review. (5) Report aggregation: Player reports weighted by reporter credibility (high-ranked players' reports carry more weight). (6) Machine learning: Train a model on known-cheater gameplay data to detect subtle cheating patterns that statistical analysis misses. The key principle is defense in depth — no single layer is sufficient, but the combination makes cheating impractical at scale.

Q6: How do you handle different tick rates between client and server?

Answer: The server runs at a fixed tick rate (e.g., 30Hz) and the client renders at its native refresh rate (60Hz, 144Hz, 240Hz). The client sends inputs at a rate matching or exceeding the server tick rate (e.g., 60Hz inputs to a 30Hz server). Between server ticks, the client uses client-side prediction to immediately apply local player inputs, showing instant response. For other players, the client uses interpolation between the last two received server states, rendering approximately 1–2 ticks behind the latest server state. This means the local player's character responds in 0ms (predicted), while other players' characters are shown with 33–66ms of latency. The server processes inputs at its fixed rate and does not care whether the client sent 1 or 3 input packets between ticks — it simply takes the latest input for each tick. This decoupled architecture allows clients to run at any refresh rate while the server maintains a consistent, deterministic simulation.

Q7: Scale discussion — how do you handle 1M concurrent players?

Answer: At 1M concurrent players with 50-player matches, we need ~20K concurrent matches, requiring ~20K game server instances. With 4 instances per physical machine, that's ~5K machines. Distributed across 6 global regions, each region handles ~833 machines and ~3.3K matches. Supporting infrastructure: Redis cluster (6 nodes per region), PostgreSQL with read replicas, 30 chat server instances, and 20 voice relay instances per region. The matchmaker is the critical scaling bottleneck — it must process ~500 match-formation requests per second at peak. This is achievable with a sharded matchmaker architecture where each shard handles a rating range or game mode. Total infrastructure cost: approximately $4–5M/month, primarily driven by bare-metal game server rental. The key scaling insight is that game servers are embarrassingly parallel — each match is fully independent, so scaling is linear with player count.

Q8: Explain the difference between authoritative server model and peer-to-peer.

Answer: In an authoritative server model, the server is the single source of truth. All player inputs are sent to the server, which processes them, runs the physics simulation, and broadcasts the resulting state back to clients. Clients cannot modify the game state directly — they can only request actions through inputs. This prevents most forms of cheating because the client never has authority over game outcomes. In peer-to-peer (P2P), one of the players' machines acts as the host, or the simulation is distributed across all peers. P2P eliminates server costs but creates critical problems: (1) the host has an unfair advantage (0ms latency), (2) any peer can cheat by manipulating their local simulation, (3) host migration when the host disconnects causes disruption, and (4) network complexity increases quadratically (each peer must communicate with every other peer). P2P is acceptable for casual, non-competitive games (e.g., Overcooked, fighting games with rollback netcode), but is unsuitable for competitive shooters where integrity is paramount. Some games use a hybrid: listen servers where one player hosts but the server code runs in a separate process, reducing host advantage through lag compensation.

20. Conclusion & Further Reading

Designing a multiplayer game server infrastructure is one of the most challenging problems in distributed systems engineering. It combines the hard real-time constraints of embedded systems, the consistency requirements of distributed databases, the networking challenges of CDN architecture, and the adversarial security considerations of financial systems. The key takeaways from this design are:

  • Server Authority is Non-Negotiable: For competitive games, the server must be the single source of truth. All game logic, physics, and state transitions happen on the server. Clients are presentation layers with prediction for responsiveness.
  • UDP is the Foundation: Real-time game networking must use UDP with custom reliability semantics. TCP's head-of-line blocking makes it unsuitable for game state updates. QUIC is excellent for auxiliary services but not for the core game path.
  • Lag Compensation is Essential: Without server-side rewind and client-side prediction, competitive gameplay is impossible at typical internet latencies. The server maintains historical state snapshots to evaluate hit detection fairly.
  • Matchmaking Must Balance Quality and Speed: Use Glicko-2 or TrueSkill for skill ratings, adaptive queue windows for wait time management, and regional pooling for latency optimization.
  • Anti-Cheat Requires Depth: No single anti-cheat technique is sufficient. Combine kernel-level client protection, server-side input validation, post-match statistical analysis, and community reporting.
  • Failure Handling Must Be Graceful: Game server crashes are inevitable at scale. Design for partial results, match restarts, and fair rating adjustments. Never penalize players for infrastructure failures.
  • Cost is the Primary Constraint: Game server infrastructure is expensive ($10–15M/month at scale). Optimize through bare-metal deployments, regional scaling, reserved instances, and aggressive compression.

Recommended Reading

  • "Game Networking Errors" — Glenn Fiedler (gafferongames.com) — The definitive series on game networking fundamentals
  • "Valve Developer Wiki: Source Multiplayer Networking" — Valve's approach to client-server networking in Source engine
  • "Microsoft TrueSkill Technical Report" — Herbrich, Minka, Graepel — The original TrueSkill paper for skill rating in team games
  • "Glicko-2 Paper" — Mark Glickman — The mathematical foundation of Glicko-2 rating systems
  • "Quake 3 Source Code" — id Software — Open-source reference implementation of an authoritative game server with client prediction
  • "Unreal Engine Network Guide" — Epic Games — Comprehensive guide to replication, relevancy, and priority in Unreal's networking system
  • " Overwatch: Netcode" — Blizzard GDC Talk — Insights into building netcode for a fast-paced team shooter
  • "Fortress Forever: Network Architecture" — Community analysis of TF2's networking model and lag compensation
Final Thought: The art of multiplayer game server design is finding the right balance between competing constraints: responsiveness vs. fairness, bandwidth vs. fidelity, security vs. performance, and cost vs. scale. The best game servers are invisible — players never notice the network, the tick rate, or the lag compensation. They simply experience fair, responsive, competitive gameplay. That invisibility is the mark of excellent engineering.

Multiplayer Game Server — Senior+ Guide