System Design of VALORANT

Design VALORANT's game server architecture with working Python simulations.

Introduction

30 Second Summary

Every time you fire a shot in an online game, your click travels across the internet, gets validated by a server, and the result appears on your screen. All in under 8 milliseconds. Behind that seamless experience is a mountain of engineering trade-offs that determine whether the game feels fair or frustrating.

In this project, you will design VALORANT's system architecture from scratch, build working Python simulations of its core subsystems, and create professional diagrams you can present in a system design interview.

What You'll Build

When you run your code, you'll see a 128-tick server simulation reporting frame times and games-per-core density, a lag compensation demo proving that shots land with rewind that would miss without it, and a matchmaking system forming balanced teams with quality scores.

By the end of this project, you'll have:

  • A high-level architecture diagram showing VALORANT's two-layer system (game servers and platform microservices) that you can whiteboard in an interview and explain the data flow between clients, servers, and services.
  • A working 128-tick game server simulation that demonstrates frame budgets, multi-game-per-core density, and server-authoritative input validation matching Riot's real engineering constraints.
  • A lag compensation and client prediction system that shows exactly why shots that look perfect on-screen would miss without server-side state rewind, plus how client-side prediction keeps gameplay responsive despite network latency.
  • An MMR-based matchmaking algorithm with expanding search windows, team balancing, and the Rank Rating convergence system that keeps competitive play fair.
  • Secret Mission: Add capacity planning calculations and design the load testing strategy Riot used to simulate 2 million concurrent players.

Are there any prerequisites?

You'll need basic Python knowledge (classes, functions, and data structures) and a general understanding of client-server networking. No game development experience is required.

Before We Start

In this project, you'll build working Python simulations of VALORANT's core subsystems: the 128-tick game server, lag compensation, and matchmaking. Each simulation runs in Python, so you need it installed and working on your machine.

You'll also create architecture diagrams that make your system design interview answer visual and memorable. Let's get your tools ready.

In this step, get ready to:
  • Install Python 3.14 and verify it works.
  • Create the project folder with your simulation files.
  • Set up draw.io for architecture diagrams.
Install Python 3.14

Python powers every simulation you'll build in this project. You need version 3.14 specifically installed and accessible from your terminal.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Check if Python is already installed by running this command:
python --version

✔️ I see version 3.14 or higher

Python 3.14 is ready. You can close PowerShell and continue to the next substep.

ⓧ I see an older version

You have an older version of Python installed. You need to install 3.14 to ensure compatibility with this project's code.

  • Open python.org/downloads in your browser.
  • Download the Python 3.14 installer for Windows.
  • Run the installer.
  • Check Add python.exe to PATH at the bottom of the installer window before clicking Install Now.
  • After installation completes, close PowerShell entirely.
  • Reopen PowerShell using the Windows key search (a new session picks up the updated PATH).
  • Run python --version again to confirm it now shows 3.14.

Still seeing the old version?

The old Python may still be first in your PATH. Open Settings, search for environment variables, and edit your Path variable. Move the Python 3.14 entry above any older Python paths.

help me fix my Python PATH on Windows so python --version shows 3.14

ⓧ Command not found

Python is not installed yet. Let's install it now.

  • Open python.org/downloads in your browser.
  • Download the Python 3.14 installer for Windows.
  • Run the installer.
  • Check Add python.exe to PATH at the bottom of the installer window before clicking Install Now.
  • After installation completes, close PowerShell entirely.
  • Reopen PowerShell using the Windows key search.
  • Run python --version to confirm it shows 3.14.

Still getting command not found?

The PATH checkbox may not have been checked during installation. Uninstall Python from Settings > Apps > Installed apps, then reinstall making sure to check the PATH checkbox.

help me get python --version working on Windows after installation

Create the project folder and files

You need a dedicated folder with three Python files. Each file will hold one subsystem simulation.

  • Press the Windows key to open your search bar.
  • Type VS Code and press Enter to open VS Code.
  • Open the integrated terminal by pressing Ctrl+` (the backtick key is above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir valorant-system-design
cd valorant-system-design
  • Create the three simulation files by running these commands:
New-Item game_server.py -ItemType File
New-Item netcode.py -ItemType File
New-Item matchmaking.py -ItemType File
  • Confirm the files exist by running:
ls

You should see game_server.py, netcode.py, and matchmaking.py listed in the output.

Don't see your files?

Make sure you ran cd valorant-system-design before creating the files. If you created them in the wrong folder, delete them and try again from the cd ~/Desktop step.

help me create my project files in the right folder

Your project files are created. Now open this folder in VS Code so you can work with the files in the editor.

  • In VS Code, click File in the menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the valorant-system-design folder.
  • Click Select Folder.

The Explorer sidebar in VS Code now shows your three .py files.

Set up draw.io for architecture diagrams

Your architecture diagram is what makes a system design answer visual and shareable. You'll use draw.io, a free browser-based diagramming tool.

  • Open your browser and navigate to app.diagrams.net.
  • When prompted to choose a storage location, select the option to save to your device (local storage).
  • Choose a blank diagram from the template options.
  • Save the diagram immediately. Click File > Save As, name it architecture.drawio, and save it inside your valorant-system-design folder on your Desktop.

You should see the blank draw.io canvas open in your browser and architecture.drawio appear in the VS Code Explorer sidebar.

Don't see the file in VS Code?

Make sure you saved the .drawio file to the valorant-system-design folder on your Desktop, not to your Downloads folder. You can also click the refresh icon in VS Code's Explorer panel to update the file list.

help me save my draw.io diagram to the right folder

Why draw.io?

System design interviews expect you to diagram while you explain. draw.io produces clean, editable visuals you can export as PNG for a portfolio or pull up during a live interview. No account required.

Your workspace is ready. Next up, you'll design VALORANT's high-level architecture and create a professional diagram showing how the game server layer and platform microservices fit together.

Design the High-Level Architecture

Your environment is ready. Now it is time to start thinking like a system designer.

In a system design interview, you always start with the big picture before diving into any single component. VALORANT's architecture splits into two distinct layers, and understanding this separation is what lets you reason about scaling, failure isolation, and team ownership.

In this step, get ready to:
  • Design the two-layer architecture (game servers + platform microservices) in draw.io.
  • Add the networking layer showing Riot Direct, anycast routing, and protocol choices.
  • Document the key design decisions in a new design document.
Design the two-layer architecture in draw.io

VALORANT's system splits into two layers that scale independently. The first is the game server executable that handles moment-to-moment gameplay at 128 ticks per second. The second is the platform layer with over 30 microservices handling everything outside of active gameplay.

  • Switch to the draw.io tab in your browser where you have architecture.drawio open.
  • Drag a large rectangle from the shape library on the left onto the canvas.
  • Double-click the rectangle and label it Game Server Layer.
  • Inside this rectangle, add three smaller rectangles labeled Game Instance 1, Game Instance 2, and Game Instance 3. These represent multiple game instances running on a single CPU core.
  • Add a text label below the game server layer rectangle that reads 128-tick, single-threaded, 3+ games per core.

What is the game server layer?

Each game server instance is a single-threaded process that simulates one 5v5 match at 128 ticks per second. Riot packs 3 or more of these instances onto every CPU core, meaning a 36-core host runs 108 simultaneous games (1080 players).

  • Drag a second large rectangle below the first one and label it Platform Microservices Layer.
  • Inside this rectangle, add smaller rectangles for the key services: Party, Matchmaking, Agent Selection, Provisioning, Results, Store, and Loadout.
  • Add a text label that reads 30+ stateless microservices per shard.
  • Add a group of small rectangles or stick figures on the left side of the canvas and label the group Clients.
  • Draw arrows from the Clients group to both the Game Server Layer and the Platform Microservices Layer. To connect shapes, hover over the source shape until you see blue directional arrows, then drag from the arrow to the target shape.
  • Save your diagram by pressing Cmd+S (macOS) or Ctrl+S (Windows).

You should now see two distinct layers on your canvas with client connections pointing to both. The game server layer has multiple instances inside it, and the platform layer has individual service boxes.

Shapes not connecting?

Make sure you hover over the edge of the source shape until you see a blue highlight, then drag to the target shape. If connection points are not visible, select View in the menu bar, then check Connection Points.

Help me connect shapes with arrows in draw.io

Add the networking layer to your diagram

Players connect to VALORANT through Riot Direct, Riot's own internet backbone. This custom network uses anycast routing to send players to the nearest entry point automatically.

  • Add a horizontal row of circles or rounded rectangles between the Clients and both layers. Label each one PoP (Point of Presence). Add at least three to represent different regions (e.g. PoP US-East, PoP EU-West, PoP AP-South).
  • Draw arrows from the Clients to the PoP nodes. Label these connections Riot Direct (Anycast).
  • Draw arrows from the PoPs to the Game Server Layer. Label these connections UDP (real-time game state).
  • Draw arrows from the PoPs to the Platform Microservices Layer. Label these connections HTTPS (platform traffic).
  • Add a text label or annotation near the Game Server Layer that reads AWS-hosted, regional pods.
  • Save your diagram again.

Your diagram now shows the full network path: clients reach regional PoPs via Riot Direct's anycast routing, then game traffic flows over UDP to game servers while platform requests go over HTTPS to the microservices layer.

Why UDP for game traffic and HTTPS for platform traffic?

UDP avoids head-of-line blocking. If one packet is lost, subsequent packets still arrive immediately. TCP would force all data to wait for the lost packet to be retransmitted. For a competitive FPS where every millisecond matters, that wait is unacceptable.

Platform traffic (store purchases, loadout changes, party invites) is not latency-critical. Standard HTTPS gives you reliability and encryption without worrying about a few extra milliseconds.

Document the key design decisions

A great system design answer is not just a diagram. You need to articulate why each architectural decision was made. This design document captures the reasoning you would explain in an interview.

  • Switch to VS Code and create a new file called design_document.md in your valorant-system-design folder.
  • Add the document header and the first two design rationale sections by pasting this content:
# VALORANT System Design Document

## High-Level Architecture

VALORANT's architecture separates into two distinct layers: the **game server layer** and the **platform microservices layer**. This separation enables independent scaling, failure isolation, and team ownership.

### Why Microservices Over Monolith

Riot Games learned from League of Legends' monolithic architecture. A single deployable meant any team's change could break the whole system. VALORANT chose microservices from the start: over 30 stateless services per shard, each independently deployable and scalable.

### Why UDP Over TCP for Game Traffic

TCP's head-of-line blocking is unacceptable for a competitive FPS. If one packet is lost, TCP forces all subsequent packets to wait for retransmission. UDP lets the game skip stale packets and always use the most recent state. Platform traffic (store, contracts, social) uses standard HTTPS since it is not latency-critical.

What does this document cover so far?

The first section explains why Riot avoided a monolith. League of Legends taught them that a single deployable creates coupling between every team.

The UDP section addresses the core networking trade-off. In a game where peeking a corner takes 200ms, waiting for a retransmission is a competitive disadvantage.

  • Now add the remaining two sections below the existing content in design_document.md:
### Why Server-Authoritative

The server never trusts a client's view of the world. Every input is validated server-side before being applied to game state. This makes the server the single source of truth, preventing entire categories of cheats (speed hacks, teleportation, wallhacks that modify game state). The cost is increased server compute, but it is the only viable approach for competitive integrity.

### Shard Model

Each shard serves a regional player population. A shard contains:
- Its own fleet of game server pods (128-tick executables, single-threaded, multiple instances per CPU core)
- A complete set of platform microservice instances (30+ services: party, matchmaking, agent selection, provisioning, results, store, loadout)
- Regional Points of Presence (PoPs) for network routing via Riot Direct

A failure in one region does not affect others. Each region scales independently based on its player population.

What do the remaining sections cover?

The server-authoritative section explains why the server does all validation. It costs more CPU, but eliminates cheats by design rather than detection.

The shard model section describes how each region operates as an independent unit. This is the answer to the interview question about failure isolation and regional scaling.

  • Save design_document.md.
  • Open the VS Code markdown preview by pressing Cmd+Shift+V (macOS) or Ctrl+Shift+V (Windows). You should see your document rendered with headings, bold text, and bullet points.

Your rendered preview shows four clearly separated design decisions, each with a concise rationale that you could explain to an interviewer in under 30 seconds.

Preview not rendering markdown?

Make sure the file extension is .md. VS Code uses the extension to determine which preview to show. If you named it design_document.txt, rename it to design_document.md.

Help me enable markdown preview in VS Code

✔️ Awesome, I've got everything!

Great. Make sure you have saved both design_document.md and your architecture.drawio diagram.

ⓧ I'd like to double check the full code

Here is the complete design_document.md file. Compare yours against it:

# VALORANT System Design Document

## High-Level Architecture

VALORANT's architecture separates into two distinct layers: the **game server layer** and the **platform microservices layer**. This separation enables independent scaling, failure isolation, and team ownership.

### Why Microservices Over Monolith

Riot Games learned from League of Legends' monolithic architecture. A single deployable meant any team's change could break the whole system. VALORANT chose microservices from the start: over 30 stateless services per shard, each independently deployable and scalable.

### Why UDP Over TCP for Game Traffic

TCP's head-of-line blocking is unacceptable for a competitive FPS. If one packet is lost, TCP forces all subsequent packets to wait for retransmission. UDP lets the game skip stale packets and always use the most recent state. Platform traffic (store, contracts, social) uses standard HTTPS since it is not latency-critical.

### Why Server-Authoritative

The server never trusts a client's view of the world. Every input is validated server-side before being applied to game state. This makes the server the single source of truth, preventing entire categories of cheats (speed hacks, teleportation, wallhacks that modify game state). The cost is increased server compute, but it is the only viable approach for competitive integrity.

### Shard Model

Each shard serves a regional player population. A shard contains:
- Its own fleet of game server pods (128-tick executables, single-threaded, multiple instances per CPU core)
- A complete set of platform microservice instances (30+ services: party, matchmaking, agent selection, provisioning, results, store, loadout)
- Regional Points of Presence (PoPs) for network routing via Riot Direct

A failure in one region does not affect others. Each region scales independently based on its player population.
Verify your architecture

Before moving on, confirm you can articulate what each layer does and trace the full connection path.

  • Look at your draw.io diagram. Trace the full path of a player's connection from client to game server, naming each hop (Client, PoP, Game Server Layer).
  • Review your design_document.md. For each of the four sections, confirm you can explain the trade-off in one sentence.

Your diagram should show: Clients connect to regional PoPs via Riot Direct anycast. PoPs route game traffic over UDP to the game server layer (AWS-hosted, 128-tick). PoPs route platform traffic over HTTPS to 30+ microservices. Each region operates as an independent shard.

Why start with the architecture diagram?

Starting with the diagram before writing any code mirrors how real system design interviews work. Interviewers want to see you reason top-down: layer separation, protocol choices, failure boundaries. The code you write in later steps will implement individual components within this architecture.

Your architecture is documented and diagrammed. Next up, you will bring the most constrained component to life: the 128-tick game server that processes an entire frame in under 7.8125ms.

Build the 128-Tick Game Server

Your architecture diagram shows the big picture. Now it is time to zoom into the hardest engineering component: the game server itself.

VALORANT processes a frame every 7.8125ms (128 ticks per second). That alone is tight. But Riot also needs 3+ game instances per CPU core to keep hosting costs viable. Understanding this constraint is what separates a shallow system design answer from a deep one.

In this step, get ready to:
  • Build a 128-tick game server class with server-authoritative input validation.
  • Add a performance reporting system that measures frame budget and games-per-core density.
  • Run a 10-player simulation and document how Riot achieved their performance targets.
Implement the core game server class

The game server needs data structures to represent positions, player inputs, and world state. Each structure maps to a concept in the real system: the server receives inputs, validates them, and advances the authoritative game state every tick.

  • In VS Code, open game_server.py and add the imports and first two data models by pasting the following code:
import time
from dataclasses import dataclass, field
from typing import Dict, List
from collections import deque

# 3D position vector for player coordinates
@dataclass
class Vector3:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

# Represents a single input frame sent from a client
@dataclass
class PlayerInput:
    player_id: str
    tick: int
    sequence: int
    move_direction: Vector3
    actions: List[str] = field(default_factory=list)
    timestamp: float = 0.0

What does this code do?

Vector3 stores x/y/z coordinates for positions in 3D space.

PlayerInput captures everything the server receives from a client each tick: which player sent it, what tick it belongs to, a sequence number for ordering, and the intended movement direction.

  • Below the PlayerInput class, add the remaining two data models:
# Server's authoritative record of each player
@dataclass
class PlayerState:
    player_id: str
    position: Vector3 = field(default_factory=Vector3)
    health: int = 100
    is_alive: bool = True

# Complete game world snapshot at a given tick
@dataclass
class GameState:
    tick: int = 0
    timestamp: float = 0.0
    players: Dict[str, PlayerState] = field(default_factory=dict)

What does this code do?

PlayerState is the server's truth about a player: their position, health, and whether they are alive. The server owns this state. Clients never modify it directly.

GameState holds the full world at a given tick. Every player's state lives inside the players dictionary, indexed by ID.

  • Save game_server.py and verify the data models load correctly by running this command in your terminal:
python -c "from game_server import Vector3; print(Vector3(1, 2, 3))"

You should see Vector3(x=1, y=2, z=3) printed. This confirms your data classes are importable and working.

Seeing a ModuleNotFoundError?

Make sure your terminal is inside the valorant-system-design folder. Run cd ~/Desktop/valorant-system-design and try again.

help me fix this import error for game_server.py

Now you need the GameServer class itself. This is the core loop that processes inputs at 128 ticks per second.

  • Below GameState, add the GameServer class with its initialization and input handling methods:
class GameServer:
    # 128-tick server: 7.8125ms frame budget per tick
    TICK_RATE = 128
    TICK_INTERVAL = 1.0 / TICK_RATE
    MAX_PLAYERS = 10

    def __init__(self, server_id: str):
        self.server_id = server_id
        self.state = GameState()
        self.input_buffer: Dict[str, deque] = {}
        self.running = False
        self.tick_times: List[float] = []

    # Register a player in the game session
    def add_player(self, player_id: str, position: Vector3 = None):
        if len(self.state.players) >= self.MAX_PLAYERS:
            raise ValueError("Server full")
        self.state.players[player_id] = PlayerState(
            player_id=player_id,
            position=position or Vector3()
        )
        self.input_buffer[player_id] = deque(maxlen=8)

    # Buffer incoming player input (target: half a frame on average)
    def receive_input(self, player_input: PlayerInput):
        if player_input.player_id in self.input_buffer:
            self.input_buffer[player_input.player_id].append(player_input)

What does this code do?

TICK_RATE = 128 sets the server's update frequency. TICK_INTERVAL calculates the frame budget: 7.8125ms per tick.

add_player() registers a player and creates their input buffer (capped at 8 frames). receive_input() queues incoming client inputs for processing on the next tick.

  • Below receive_input, add the tick processing and input validation methods:
    # Process one tick: consume inputs, validate, simulate, advance
    def process_tick(self):
        tick_start = time.perf_counter()
        for player_id, buffer in self.input_buffer.items():
            if buffer:
                player_input = buffer.popleft()
                self._apply_input(player_id, player_input)
        self._simulate_physics()
        self.state.tick += 1
        self.state.timestamp = time.time()
        tick_duration = (time.perf_counter() - tick_start) * 1000
        self.tick_times.append(tick_duration)
        return self.state

    def _apply_input(self, player_id: str, player_input: PlayerInput):
        player = self.state.players.get(player_id)
        if not player or not player.is_alive:
            return
        move = player_input.move_direction
        max_speed = 6.75  # Server rejects impossible speeds
        magnitude = (move.x**2 + move.y**2 + move.z**2) ** 0.5
        if magnitude > max_speed:
            scale = max_speed / magnitude
            move = Vector3(move.x * scale, move.y * scale, move.z * scale)
        player.position.x += move.x * self.TICK_INTERVAL
        player.position.y += move.y * self.TICK_INTERVAL
        player.position.z += move.z * self.TICK_INTERVAL

    def _simulate_physics(self):
        pass

Why cap movement speed on the server?

This is server-authoritative anti-cheat in action. The server never trusts the client's claimed movement. If a client reports moving faster than max_speed = 6.75, the server clamps it. Speed hackers send inputs the server rejects silently.

process_tick() measures its own execution time with time.perf_counter() so we can track how much of the 7.8125ms budget each frame consumes.

  • Save game_server.py and verify the class works by running:
python -c "from game_server import GameServer; s = GameServer('test'); s.add_player('p1'); s.process_tick(); print(f'Tick: {s.state.tick}')"

You should see Tick: 1 printed. The server processed one tick and advanced the game state.

Seeing a NameError or IndentationError?

Check that GameServer is defined below GameState in the same file. All methods inside the class must be indented by 4 spaces.

help me fix my GameServer class definition

Add the performance reporting system

The tick loop works, but Riot's real constraint is density: how many game instances fit on a single CPU core. The run() method ties the loop together with timing and a performance report that answers that question directly.

  • Below _simulate_physics, add the run() method that executes the server loop and reports metrics:
    def run(self, duration_seconds: float = 1.0):
        self.running = True
        total_ticks = int(duration_seconds * self.TICK_RATE)
        print(f"[Server {self.server_id}] Starting {self.TICK_RATE}-tick server")
        print(f"  Frame budget: {self.TICK_INTERVAL * 1000:.4f}ms")
        print(f"  Target ticks: {total_ticks}")
        print(f"  Players: {len(self.state.players)}")
        print()
        start_time = time.perf_counter()
        for i in range(total_ticks):
            tick_start = time.perf_counter()
            self.process_tick()
            elapsed = time.perf_counter() - tick_start
            sleep_time = self.TICK_INTERVAL - elapsed
            if sleep_time > 0:
                time.sleep(sleep_time)
        total_time = time.perf_counter() - start_time
        self.running = False
        avg_tick = sum(self.tick_times) / len(self.tick_times) if self.tick_times else 0
        max_tick = max(self.tick_times) if self.tick_times else 0
        # Performance report: timing and density metrics
        print(f"[Server {self.server_id}] Performance Report:")
        print(f"  Total time: {total_time*1000:.2f}ms (target: {duration_seconds*1000:.2f}ms)")
        print(f"  Ticks processed: {self.state.tick}")
        print(f"  Avg frame time: {avg_tick:.4f}ms")
        print(f"  Max frame time: {max_tick:.4f}ms")
        print(f"  Budget utilization: {(avg_tick / (self.TICK_INTERVAL*1000)) * 100:.1f}%")
        games_per_core = (self.TICK_INTERVAL * 1000) / avg_tick if avg_tick > 0 else 0
        print(f"  Estimated games-per-core: {games_per_core:.1f}")
        print(f"  (Riot target: 3+ gpc on 36-core hosts = 108 games/host)")

What do the key metrics mean?

Budget utilization shows what percentage of the 7.8125ms frame budget each tick actually uses. Lower means more headroom.

Games-per-core is the critical density metric. It divides the total frame budget by the average frame time. Riot's target is 3+ gpc on 36-core hosts. That means 108 games per host and 1080 players per server.

Run the simulation with 10 players

Time to see the server in action. You will add a main() function that creates a 5v5 match and runs the 128-tick loop for one second.

  • At the bottom of game_server.py, below the GameServer class, add the main() function:
def main():
    server = GameServer("us-east-1-001")
    # Add 10 players (5v5) with starting positions
    for i in range(10):
        server.add_player(f"player_{i}", Vector3(x=float(i * 10), y=0, z=0))
    # Simulate receiving one input per player
    for i in range(10):
        server.receive_input(PlayerInput(
            player_id=f"player_{i}",
            tick=0,
            sequence=1,
            move_direction=Vector3(x=1.0, y=0, z=0),
            timestamp=time.time()
        ))
    # Run the server for 1 second (128 ticks)
    server.run(duration_seconds=1.0)


if __name__ == "__main__":
    main()

What does this simulate?

This creates a game server with ID us-east-1-001 (mimicking an AWS region), adds 10 players spaced along the x-axis, queues one movement input per player, and runs for exactly 1 second (128 ticks).

The real VALORANT server processes far more complex state (abilities, projectiles, wall penetration), but this captures the core loop structure and timing constraints.

  • Save game_server.py.

Before you run this, think about what you expect to see. Given a 128-tick server running for 1 second, how many ticks should it process? What do you think the budget utilization will be for a simple simulation like this?

  • Run the simulation:
python game_server.py

You should see the server report 128 ticks processed, with a low average frame time and high games-per-core estimate. The budget utilization will be low because our simulation is simple. In production, VALORANT's servers spend their budget on abilities, projectile physics, and hitbox calculations.

Seeing fewer than 128 ticks?

Make sure the run() method uses int(duration_seconds * self.TICK_RATE) for total_ticks. If you see a large max frame time, another process on your machine may have caused a scheduling delay. Run it again.

help me debug my game server output

✔️ Awesome, I've got everything!

Great. Confirm you have saved game_server.py and the output shows all metrics.

ⓧ I'd like to double check the full code

Your game_server.py is over 30 lines, so confirm each section is present in this order:

  • Imports (time, dataclass, field, Dict, List, deque).
  • Vector3 dataclass with x, y, z float fields.
  • PlayerInput dataclass with player_id, tick, sequence, move_direction, actions, timestamp.
  • PlayerState dataclass with player_id, position, health, is_alive.
  • GameState dataclass with tick, timestamp, players dict.
  • GameServer class with TICK_RATE=128, TICK_INTERVAL, MAX_PLAYERS=10.
  • __init__, add_player, receive_input, process_tick, _apply_input (with max_speed=6.75), _simulate_physics, run methods.
  • main() function adding 10 players and running for 1 second.
  • if __name__ == "__main__": main() guard at the bottom.

Now document how Riot achieved their density targets. The simulation shows how tight the frame budget is. In production, Riot needed specific optimizations to hit 3+ games per core.

  • Open design_document.md and add a new section below your existing architecture content:
## Game Server Density Optimizations

Riot achieved 3+ games per core (108 games per 36-core host) through:

### RPCs Over Replicated Variables
- Replaced Unreal Engine's replicated variable system with RPCs
- 100x to 10000x performance improvement for state-changing events
- Only sends data when state actually changes, not every tick

### NUMA-Aware Memory Allocation
- Memory access improved from 50% NUMA local to 97-99% NUMA local
- ~5% performance boost from keeping memory close to the processing core

### Linux CFS Scheduler Tuning
- Migration cost changed from default 0.5ms to 0
- ~4% performance boost by reducing context-switch overhead
- C-States disabled below C1E for better core latency

### Hardware
- Intel Xeon Scalable processors
- 36-core hosts as the standard unit
- Actual achieved frame budget: ~2.6ms per game instance

Why do these optimizations matter for interviews?

The difference between 1 game per core and 3+ games per core is a 3x cost reduction. At scale (thousands of hosts across multiple regions), that translates to millions in infrastructure savings.

When an interviewer asks "how would you optimize this?", these are concrete, measurable answers: change the networking model (100x improvement), tune memory access patterns (5% boost), tune the OS scheduler (4% boost).

  • Save design_document.md.

Your game server simulation proves the timing constraints are real. Next up, you will build the lag compensation system that makes online hit registration feel fair despite network latency.

Implement Netcode and Lag Compensation

Your game server processes inputs at 128 ticks per second, but there's a fundamental problem it can't solve alone.

When you fire a shot in an online FPS, your target has already moved on the server by the time your shot packet arrives. A 60ms round-trip means your opponent is 8 ticks ahead of where you saw them. Without compensation, perfectly aimed shots would miss constantly.

In this step, you'll build two systems that solve this: lag compensation (server-side rewind for fair hit detection) and client-side prediction with server reconciliation (instant-feeling movement despite latency).

In this step, get ready to:
  • Build a lag compensation system that rewinds server state to validate shots fairly.
  • Implement client-side prediction and server reconciliation for responsive movement.
  • Run demo scenarios proving why lag compensation is essential for competitive FPS games.
Implement the lag compensation system

Lag compensation stores a ring buffer of recent world snapshots. When a shot arrives tagged with the client's firing tick, the server rewinds to that historical snapshot, checks the hit, then returns to the present.

  • Switch to netcode.py in VS Code and add the imports and data classes by pasting the following code:
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from collections import deque
from copy import deepcopy


@dataclass
class Vector3:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

    def distance_to(self, other: "Vector3") -> float:
        return ((self.x - other.x)**2 + (self.y - other.y)**2 + (self.z - other.z)**2) ** 0.5


@dataclass
class PlayerSnapshot:
    """A snapshot of player state at a specific tick."""
    player_id: str
    tick: int
    position: Vector3 = field(default_factory=Vector3)
    hitbox_radius: float = 0.5


@dataclass
class ShotEvent:
    """A shot fired by a client, sent with the client's local timestamp."""
    shooter_id: str
    target_position: Vector3
    client_tick: int
    server_receive_tick: int = 0

What do these data classes represent?

  • Vector3 holds 3D positions and calculates distances between points (used for hit detection).
  • PlayerSnapshot captures a player's position and hitbox at a specific tick. These form the building blocks of the rewind buffer.
  • ShotEvent represents a shot as the client saw it, tagged with the client's local tick so the server knows how far back to rewind.
  • Add the WorldSnapshot data class and LagCompensation class below ShotEvent:
@dataclass
class WorldSnapshot:
    """Complete world state at a specific tick."""
    tick: int
    players: Dict[str, PlayerSnapshot] = field(default_factory=dict)


class LagCompensation:
    """
    Implements VALORANT's lag compensation (server-side rewind).

    How it works (from Riot's netcode blog):
    - Server stores N ticks of world history
    - When a shot is received, server rewinds to the client's tick
    - Hit detection is performed in the rewound state
    - Server then returns to present and applies damage if confirmed

    VALORANT specifics:
    - Rewind uses bone positions (simplified to hitbox here)
    - Sphere cast from firing position down aim vector
    - Prunes actors that could not possibly be hit
    """

    HISTORY_DURATION_MS = 200
    TICK_RATE = 128
    MAX_HISTORY_TICKS = int(HISTORY_DURATION_MS / (1000 / TICK_RATE))

    def __init__(self):
        self.history: deque = deque(maxlen=self.MAX_HISTORY_TICKS)
        self.current_tick = 0

    def record_snapshot(self, snapshot: WorldSnapshot):
        """Store a world snapshot for potential rewind."""
        self.history.append(deepcopy(snapshot))
        self.current_tick = snapshot.tick

What does the 200ms history cap do?

The server stores up to 200ms of world history (about 25 snapshots at 128 ticks/second). Each time a tick completes, record_snapshot() deep-copies the current world state into the ring buffer.

The 200ms cap is a security measure. If a player claims their shot happened further back than 200ms, the server rejects it outright. This prevents abuse from artificially inflated latency.

  • Add the process_shot method inside the LagCompensation class, below record_snapshot:
    def process_shot(self, shot: ShotEvent) -> Dict:
        """
        Process a shot with lag compensation.

        Steps:
        1. Calculate which historical tick to rewind to
        2. Find the closest snapshot in history
        3. Perform hit detection in rewound state
        4. Return to present state
        """
        rewind_ticks = self.current_tick - shot.client_tick
        rewind_ms = rewind_ticks * (1000 / self.TICK_RATE)

        result = {
            "shooter": shot.shooter_id,
            "rewind_ticks": rewind_ticks,
            "rewind_ms": rewind_ms,
            "hit": False,
            "hit_player": None,
            "reason": ""
        }

        if rewind_ms > self.HISTORY_DURATION_MS:
            result["reason"] = f"Rewind {rewind_ms:.1f}ms exceeds max {self.HISTORY_DURATION_MS}ms"
            return result

        rewound_snapshot = self._find_snapshot(shot.client_tick)
        if not rewound_snapshot:
            result["reason"] = "No snapshot found for requested tick"
            return result
  • Continue process_shot by adding the hit detection loop directly below (same indentation level, inside the method):
        for player_id, player_snap in rewound_snapshot.players.items():
            if player_id == shot.shooter_id:
                continue

            distance = shot.target_position.distance_to(player_snap.position)
            if distance <= player_snap.hitbox_radius:
                result["hit"] = True
                result["hit_player"] = player_id
                result["reason"] = (
                    f"Hit confirmed at tick {shot.client_tick} "
                    f"(rewound {rewind_ticks} ticks / {rewind_ms:.1f}ms)"
                )
                break

        if not result["hit"]:
            result["reason"] = "No hit in rewound state"

        return result

How does the rewind hit detection work?

The server calculates how many ticks to rewind based on the gap between its current tick and the client's firing tick. If that gap exceeds 200ms, the shot is rejected.

For valid shots, the server finds the closest historical snapshot and checks whether the shot position is within any enemy's hitbox radius at that past moment. This is why shots that look perfect on your screen actually register correctly on the server.

  • Add the _find_snapshot helper method below process_shot, still inside the LagCompensation class:
    def _find_snapshot(self, target_tick: int) -> Optional[WorldSnapshot]:
        """Find the closest snapshot to the target tick."""
        if not self.history:
            return None

        closest = None
        min_diff = float("inf")

        for snapshot in self.history:
            diff = abs(snapshot.tick - target_tick)
            if diff < min_diff:
                min_diff = diff
                closest = snapshot

        return closest
  • Now add the demo function at the bottom of the file (outside the class, at the top indentation level):
def demo_lag_compensation():
    """Demonstrate lag compensation with a concrete scenario."""
    print("=" * 60)
    print("LAG COMPENSATION DEMO")
    print("=" * 60)
    print()
    print("Scenario: Player A shoots at Player B.")
    print("Player A has 60ms ping (about 8 ticks of round-trip delay).")
    print("By the time the server receives the shot, Player B has moved.")
    print("Lag compensation rewinds to where Player B WAS when A fired.")
    print()

    lag_comp = LagCompensation()

    for tick in range(30):
        snapshot = WorldSnapshot(tick=tick)

        snapshot.players["player_a"] = PlayerSnapshot(
            player_id="player_a", tick=tick,
            position=Vector3(0, 0, 0)
        )

        player_b_x = 3.0 + tick * 0.15
        snapshot.players["player_b"] = PlayerSnapshot(
            player_id="player_b", tick=tick,
            position=Vector3(player_b_x, 0, 0)
        )

        lag_comp.record_snapshot(snapshot)
  • Continue demo_lag_compensation by adding the shot processing, comparison output, and a temporary main() directly below:
    print("Current server tick: 30")
    print(f"Player B's CURRENT position: x={3.0 + 30 * 0.15:.2f}")
    print(f"Player B's position at tick 22 (when A fired): x={3.0 + 22 * 0.15:.2f}")
    print()

    shot = ShotEvent(
        shooter_id="player_a",
        target_position=Vector3(3.0 + 22 * 0.15, 0, 0),
        client_tick=22,
        server_receive_tick=30
    )

    result = lag_comp.process_shot(shot)

    print("WITH lag compensation (rewind to client's firing time):")
    print(f"  Hit: {result['hit']}")
    print(f"  Hit player: {result['hit_player']}")
    print(f"  Rewind: {result['rewind_ticks']} ticks ({result['rewind_ms']:.1f}ms)")
    print(f"  Reason: {result['reason']}")
    print()

    print("-" * 40)
    print("WITHOUT lag compensation (checking current state only):")
    shot_no_rewind = ShotEvent(
        shooter_id="player_a",
        target_position=Vector3(3.0 + 22 * 0.15, 0, 0),
        client_tick=30,
        server_receive_tick=30
    )
    result_no_rewind = lag_comp.process_shot(shot_no_rewind)
    print(f"  Hit: {result_no_rewind['hit']} (Player B has moved away!)")
    print(f"  This would feel unfair to Player A who had their crosshair on target.")
    print()
  • Add the main() entry point at the very bottom of the file:
def main():
    demo_lag_compensation()


if __name__ == "__main__":
    main()
  • Save netcode.py.
  • Run the lag compensation demo by running this command in your terminal:
python netcode.py

You should see the scenario print, showing that WITH lag compensation the shot hits Player B, and WITHOUT it the shot misses because Player B has moved away from the original aim point.

Seeing a NameError or IndentationError?

  • If you see NameError: name 'LagCompensation' is not defined, make sure demo_lag_compensation() is outside the class (no indentation).
  • If you see an IndentationError, check that process_shot and _find_snapshot are indented with 4 spaces inside the LagCompensation class.
  • Make sure the hit detection loop (for player_id, player_snap...) is indented at 8 spaces (inside process_shot).

Help me debug my lag compensation code in netcode.py

Add client-side prediction and server reconciliation

Lag compensation solves hit detection, but movement still feels laggy without prediction. Client-side prediction applies your inputs locally for instant feedback, then reconciles with the server's authoritative state when corrections arrive.

  • In netcode.py, add the ClientPrediction class below the LagCompensation class (above demo_lag_compensation):
class ClientPrediction:
    """
    Demonstrates client-side prediction and server reconciliation.

    From VALORANT's netcode:
    - Clients and servers both simulate at 128 fixed timesteps per second
    - Client sends input AND movement results to server
    - Server runs its own simulation and sends corrections if disagreement
    - Corrections are rare, small, and only seen by the affected player
    """

    TICK_RATE = 128
    TICK_INTERVAL = 1.0 / TICK_RATE

    def __init__(self, player_id: str):
        self.player_id = player_id
        self.position = Vector3()
        self.pending_inputs: List[Dict] = []
        self.sequence = 0
  • Add the predict_movement method inside the ClientPrediction class, below __init__:
    def predict_movement(self, move_direction: Vector3) -> Vector3:
        """
        Apply input locally for instant feedback (client-side prediction).
        Store the input for later reconciliation.
        """
        self.sequence += 1

        new_position = Vector3(
            x=self.position.x + move_direction.x * self.TICK_INTERVAL,
            y=self.position.y + move_direction.y * self.TICK_INTERVAL,
            z=self.position.z + move_direction.z * self.TICK_INTERVAL
        )

        self.pending_inputs.append({
            "sequence": self.sequence,
            "move_direction": move_direction,
            "predicted_position": Vector3(new_position.x, new_position.y, new_position.z)
        })

        self.position = new_position
        return self.position
  • Add the reconcile method below predict_movement:
    def reconcile(self, server_position: Vector3, last_processed_sequence: int):
        """
        Server reconciliation: correct client state if server disagrees.

        From VALORANT:
        - Server commits its prediction as truth
        - Client adjusts simulation state to match server
        - Then replays all unconfirmed inputs from server state
        - Corrections are rare and small in good network conditions
        """
        self.pending_inputs = [
            inp for inp in self.pending_inputs
            if inp["sequence"] > last_processed_sequence
        ]

        self.position = Vector3(server_position.x, server_position.y, server_position.z)

        for inp in self.pending_inputs:
            move = inp["move_direction"]
            self.position.x += move.x * self.TICK_INTERVAL
            self.position.y += move.y * self.TICK_INTERVAL
            self.position.z += move.z * self.TICK_INTERVAL

        return self.position

How does reconciliation prevent jitter?

When the server sends a correction, the client doesn't just snap to the new position. It discards already-confirmed inputs, resets to the server's authoritative state, then replays all remaining unconfirmed inputs from that new starting point.

Both client and server simulate at exactly 128 timesteps per second, decoupled from render framerate. This means corrections are rare and small in magnitude. Only the affected player sees them.

  • Add the demo_client_prediction function below demo_lag_compensation (outside any class):
def demo_client_prediction():
    """Demonstrate client prediction and server reconciliation."""
    print("=" * 60)
    print("CLIENT PREDICTION & SERVER RECONCILIATION DEMO")
    print("=" * 60)
    print()
    print("VALORANT decouples simulation from render framerate.")
    print("Both client and server simulate at exactly 128 ticks/second.")
    print("Client predicts locally, server corrects if disagreement occurs.")
    print()

    client = ClientPrediction("player_a")

    print("Client predicts 5 movement inputs locally:")
    move = Vector3(x=5.0, y=0, z=0)
    for i in range(5):
        pos = client.predict_movement(move)
        print(f"  Tick {i+1}: predicted position = ({pos.x:.4f}, {pos.y:.4f}, {pos.z:.4f})")

    print(f"\nClient's predicted position after 5 inputs: ({client.position.x:.4f}, 0, 0)")
    print(f"Pending (unconfirmed) inputs: {len(client.pending_inputs)}")

    server_pos = Vector3(x=0.1100, y=0, z=0)
    print(f"\nServer confirms sequences 1-3 with position: ({server_pos.x:.4f}, 0, 0)")
    print("(Server detected a collision the client missed)")

    reconciled = client.reconcile(server_pos, last_processed_sequence=3)
    print(f"\nAfter reconciliation (replay inputs 4-5 from server state):")
    print(f"  Corrected position: ({reconciled.x:.4f}, {reconciled.y:.4f}, {reconciled.z:.4f})")
    print(f"  Remaining pending inputs: {len(client.pending_inputs)}")
    print()
    print("  From VALORANT's blog: 'Corrections are rare, small in magnitude,")
    print("  and only seen by the player who encountered network issues.'")
  • Update main() to call both demo functions. Find the existing main() and replace it with:
def main():
    demo_lag_compensation()
    print("\n")
    demo_client_prediction()


if __name__ == "__main__":
    main()
  • Save netcode.py.

Before you run this, think about what the client prediction demo will show. If the client predicted 5 inputs moving at x=5.0 per tick, but the server says you're actually at x=0.11 after 3 inputs (because of a collision), what will the reconciled position be after replaying inputs 4 and 5?

  • Run the full netcode simulation:
python netcode.py

You should see two demos. The lag compensation demo shows a shot hitting with rewind that misses without it, printing the rewind amount in ticks and milliseconds. The client prediction demo shows 5 predicted inputs, then a server correction that triggers reconciliation with the corrected position.

Only seeing one demo or getting an error?

  • If only the lag compensation demo prints, check that main() calls both demo_lag_compensation() and demo_client_prediction().
  • If you see NameError: name 'ClientPrediction' is not defined, make sure the ClientPrediction class is defined above the demo functions.
  • If reconciled values look wrong, verify TICK_INTERVAL is set to 1.0 / TICK_RATE in the ClientPrediction class.

Help me fix my client prediction or reconciliation code

✔️ Awesome, I've got everything!

Great. Double check you've saved netcode.py and both demos produce output when you run the file.

ⓧ I'd like to double check the full code

Your netcode.py is long, so scroll through and confirm these sections exist in order:

  • Imports (time, dataclasses, typing, deque, deepcopy).
  • Data classes: Vector3 (with distance_to), PlayerSnapshot, ShotEvent, WorldSnapshot.
  • LagCompensation class with record_snapshot, process_shot, and _find_snapshot.
  • ClientPrediction class with predict_movement and reconcile.
  • demo_lag_compensation() function (creates 30 snapshots, fires with and without rewind).
  • demo_client_prediction() function (5 predicted inputs, server correction, reconciliation).
  • main() calling both demos, with if __name__ == "__main__" guard.

Your netcode simulation proves exactly why lag compensation is non-negotiable in competitive FPS games. Next up, you'll build the matchmaking system that forms fair matches and drives long-term player engagement.

Build the Matchmaking System

Fair matches are what keep players in a competitive game long-term. VALORANT's matchmaking solves a core tension: how do you measure true skill accurately while also giving players a satisfying sense of progression?

The answer is decoupling. A hidden MMR (Match Making Rating) tracks real skill for creating fair matches. A separate, visible Rank Rating gives players something to grind toward. In this step, you'll implement both systems and see how they interact.

In this step, get ready to:
  • Build the matchmaking queue with expanding MMR windows and team balancing.
  • Implement the Rank Rating convergence system that tests whether players belong at a higher rank.
  • Run the full demo to see matches form and RR gains boost during convergence.
Implement the matchmaking queue

The matchmaking queue needs data models for players, ranks, and matches before it can match anyone. You'll start with the foundational types, then build the matching algorithm on top.

  • Switch back to VS Code and open matchmaking.py in your valorant-system-design folder.
  • Add the imports and the Rank enum by pasting this code:
import time
import random
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from enum import Enum


class Rank(Enum):
    """VALORANT competitive ranks."""
    IRON = 1
    BRONZE = 2
    SILVER = 3
    GOLD = 4
    PLATINUM = 5
    DIAMOND = 6
    ASCENDANT = 7
    IMMORTAL = 8
    RADIANT = 9

What does this code do?

The Rank enum maps VALORANT's competitive rank names to numeric values (1 through 9). This lets the matchmaking algorithm do arithmetic on ranks. Iron is 1 at the bottom, Radiant is 9 at the top.

  • Add the Player and Match dataclasses below the Rank enum:
@dataclass
class Player:
    """
    Represents a player in the matchmaking system.

    From VALORANT's ranking system:
    - MMR (Match Making Rating): hidden skill ladder, used to create fair matches
    - Rank Rating (RR): visible rank, separate from MMR
    - Convergence: system tests if you belong at estimated rank
    """
    player_id: str
    mmr: float
    rank_rating: int = 0
    rank: Rank = Rank.SILVER
    games_played: int = 0
    wins: int = 0
    queue_enter_time: float = 0.0
    region: str = "us-east"
    ping_ms: int = 30

    @property
    def win_rate(self) -> float:
        return self.wins / self.games_played if self.games_played > 0 else 0.5

Why is MMR separate from rank?

MMR is the hidden truth the system uses to build fair games. Rank Rating is what the player sees and grinds toward. Separating them lets the system place players low after calibration and then "converge" them upward with boosted gains if they're performing above their displayed rank.

The queue_enter_time field tracks how long a player has been waiting. The search window expands over time so no one waits forever.

  • Add the Match dataclass below Player:
@dataclass
class Match:
    """A formed match with two teams."""
    match_id: str
    team_a: List[Player] = field(default_factory=list)
    team_b: List[Player] = field(default_factory=list)
    avg_mmr: float = 0.0
    mmr_spread: float = 0.0
    quality_score: float = 0.0
  • Save matchmaking.py.
  • Run a quick syntax check to confirm the data models load without errors:
python matchmaking.py

The script exits silently with no errors. That confirms your imports and dataclasses are valid Python.

Seeing a syntax error?

Check that the @dataclass decorator appears directly above each class with no blank lines between the decorator and the class keyword. Also confirm your from enum import Enum line matches the import block exactly.

Help me debug my matchmaking.py syntax error

Now you'll build the core matching algorithm. The MatchmakingQueue class seeds with the longest-waiting player, finds nearby candidates by MMR, and greedily fills a 10-player lobby.

  • Add the MatchmakingQueue class with its constants, initializer, and the add_player and get_mmr_window methods below the Match class:
class MatchmakingQueue:
    """
    VALORANT-style matchmaking with MMR-based skill matching.

    Key design decisions:
    - MMR is the primary matching criterion (create fair games)
    - Search window expands over time (balance queue time vs match quality)
    - Region/ping considered for server assignment
    - Seed with longest-waiting player (fairness)
    """

    TEAM_SIZE = 5
    MATCH_SIZE = TEAM_SIZE * 2
    BASE_MMR_WINDOW = 100
    MAX_MMR_WINDOW = 400
    WINDOW_EXPAND_RATE = 50
    WINDOW_EXPAND_INTERVAL = 30

    def __init__(self):
        self.queue: List[Player] = []
        self.formed_matches: List[Match] = []
        self.match_counter = 0

    def add_player(self, player: Player):
        """Add a player to the matchmaking queue."""
        player.queue_enter_time = time.time()
        self.queue.append(player)

What do these constants control?

  • BASE_MMR_WINDOW = 100 means the system starts by looking for players within 100 MMR of the seed player.
  • WINDOW_EXPAND_RATE = 50 grows that window by 50 every 30 seconds of waiting.
  • MAX_MMR_WINDOW = 400 caps the expansion so wildly different skill levels never get matched together.
  • Add the get_mmr_window method and the first part of find_match (candidate selection) below add_player:
    def get_mmr_window(self, player: Player) -> float:
        """
        Calculate the current MMR search window for a player.
        Window expands over time to prevent infinite queue times.
        """
        wait_time = time.time() - player.queue_enter_time
        expansions = int(wait_time / self.WINDOW_EXPAND_INTERVAL)
        window = self.BASE_MMR_WINDOW + (expansions * self.WINDOW_EXPAND_RATE)
        return min(window, self.MAX_MMR_WINDOW)

    def find_match(self) -> Optional[Match]:
        """
        Attempt to form a match from the queue.
        """
        if len(self.queue) < self.MATCH_SIZE:
            return None

        self.queue.sort(key=lambda p: p.queue_enter_time)

        seed = self.queue[0]
        mmr_window = self.get_mmr_window(seed)

        candidates = []
        for player in self.queue[1:]:
            mmr_diff = abs(player.mmr - seed.mmr)
            if mmr_diff <= mmr_window:
                candidates.append((player, mmr_diff))

        candidates.sort(key=lambda x: x[1])

        if len(candidates) < self.MATCH_SIZE - 1:
            return None

How does seeding with the longest-waiting player work?

Sorting by queue_enter_time ensures the player who has waited longest becomes the seed. The algorithm then searches outward from that seed's MMR, finding the 9 closest candidates. This guarantees fairness: no player waits indefinitely while newer arrivals get matched first.

  • Continue the find_match method by adding the team balancing and quality scoring section directly below the last line you added:
        match_players = [seed] + [c[0] for c in candidates[:self.MATCH_SIZE - 1]]

        team_a, team_b = self._balance_teams(match_players)

        all_mmrs = [p.mmr for p in match_players]
        avg_mmr = sum(all_mmrs) / len(all_mmrs)
        mmr_spread = max(all_mmrs) - min(all_mmrs)

        team_a_avg = sum(p.mmr for p in team_a) / len(team_a)
        team_b_avg = sum(p.mmr for p in team_b) / len(team_b)
        team_diff = abs(team_a_avg - team_b_avg)

        quality = 1.0 - (mmr_spread / self.MAX_MMR_WINDOW) * 0.5 - (team_diff / 100) * 0.5
        quality = max(0, min(1, quality))

        self.match_counter += 1
        match = Match(
            match_id=f"match_{self.match_counter:04d}",
            team_a=team_a,
            team_b=team_b,
            avg_mmr=avg_mmr,
            mmr_spread=mmr_spread,
            quality_score=quality
        )

        matched_ids = {p.player_id for p in match_players}
        self.queue = [p for p in self.queue if p.player_id not in matched_ids]

        self.formed_matches.append(match)
        return match

What is the quality score?

The quality score (0 to 1) penalizes two things: wide MMR spread across all 10 players, and a big MMR gap between the two teams. A score of 1.0 means perfectly balanced. In a real system, low-quality matches would be held until better candidates appear.

  • Add the _balance_teams method below find_match to complete the MatchmakingQueue class:
    def _balance_teams(self, players: List[Player]) -> Tuple[List[Player], List[Player]]:
        """
        Balance players into two teams with similar average MMR.
        Uses greedy assignment after sorting by MMR.
        """
        sorted_players = sorted(players, key=lambda p: p.mmr, reverse=True)
        team_a: List[Player] = []
        team_b: List[Player] = []

        for player in sorted_players:
            sum_a = sum(p.mmr for p in team_a) if team_a else 0
            sum_b = sum(p.mmr for p in team_b) if team_b else 0

            if len(team_a) >= self.TEAM_SIZE:
                team_b.append(player)
            elif len(team_b) >= self.TEAM_SIZE:
                team_a.append(player)
            elif sum_a <= sum_b:
                team_a.append(player)
            else:
                team_b.append(player)

        return team_a, team_b

Why greedy balancing instead of exhaustive search?

Sorting players by MMR and greedily assigning the highest-rated player to the team with the lower total is fast and produces near-optimal balance. An exhaustive search of all possible team splits (252 combinations for 10 players) would be more accurate but adds latency to queue times for negligible improvement.

  • Save matchmaking.py and run the syntax check again:
python matchmaking.py

Still no errors. The full MatchmakingQueue class is valid and ready for a demo function.

Seeing an IndentationError?

Each method inside MatchmakingQueue must be indented by exactly 4 spaces relative to the class body. The code inside find_match (including the continued section) must be at 8 spaces. Check that you didn't accidentally add or lose a level when pasting the continuation.

Help me fix my MatchmakingQueue indentation

Implement the Rank Rating convergence system

The Rank Rating system is what makes competitive climbing feel satisfying. When your hidden MMR says you belong higher than your visible rank, the system boosts your gains and softens your losses to push you toward your true skill level.

  • Add the RankRatingSystem class with the calculate_rr_change method below the MatchmakingQueue class:
class RankRatingSystem:
    """
    VALORANT's Rank Rating (RR) system.

    From VALORANT dev blog:
    - MMR is the hidden ladder; RR is visible
    - Convergence: placed low, gains are high early to test upward
    - At convergence: even RR per win/loss
    - Above MMR: lose more, gain less (pushback to true skill)
    """

    BASE_RR_GAIN = 20
    BASE_RR_LOSS = 20
    CONVERGENCE_BONUS = 10
    RANK_THRESHOLD = 100

    def calculate_rr_change(self, player: Player, won: bool) -> int:
        """
        Calculate RR change after a match.

        Key insight from VALORANT:
        - If rank < MMR: bonus RR on wins, reduced loss (convergence UP)
        - If rank = MMR: balanced gains/losses
        - If rank > MMR: reduced gains, increased loss (pushback DOWN)
        """
        mmr_rank_value = player.mmr / 100
        current_rank_value = player.rank.value

        rank_vs_mmr = mmr_rank_value - current_rank_value
  • Continue calculate_rr_change by adding the convergence logic directly below the last line:
        if won:
            rr_change = self.BASE_RR_GAIN
            if rank_vs_mmr > 0.5:
                rr_change += self.CONVERGENCE_BONUS
            elif rank_vs_mmr < -0.5:
                rr_change -= self.CONVERGENCE_BONUS
        else:
            rr_change = -self.BASE_RR_LOSS
            if rank_vs_mmr > 0.5:
                rr_change += int(self.CONVERGENCE_BONUS * 0.5)
            elif rank_vs_mmr < -0.5:
                rr_change -= int(self.CONVERGENCE_BONUS * 0.5)

        return rr_change

How does convergence work in practice?

When rank_vs_mmr > 0.5 the player's hidden skill is above their displayed rank. They earn +30 RR per win (base 20 + bonus 10) and only lose -15 per loss (base 20 minus half the bonus). This accelerates their climb to where they actually belong.

When rank_vs_mmr < -0.5 it's the opposite: the player is ranked above their true skill. They earn less and lose more until they settle at the correct rank.

  • Add the apply_result method below calculate_rr_change to handle promotions and demotions:
    def apply_result(self, player: Player, won: bool) -> dict:
        """Apply match result and return the RR change details."""
        rr_change = self.calculate_rr_change(player, won)
        old_rank = player.rank

        player.rank_rating += rr_change
        player.games_played += 1
        if won:
            player.wins += 1

        promoted = False
        demoted = False
        if player.rank_rating >= self.RANK_THRESHOLD:
            if player.rank.value < Rank.RADIANT.value:
                player.rank = Rank(player.rank.value + 1)
                player.rank_rating -= self.RANK_THRESHOLD
                promoted = True
        elif player.rank_rating < 0:
            if player.rank.value > Rank.IRON.value:
                player.rank = Rank(player.rank.value - 1)
                player.rank_rating += self.RANK_THRESHOLD
                demoted = True
            else:
                player.rank_rating = 0

        return {
            "player_id": player.player_id,
            "rr_change": rr_change,
            "new_rr": player.rank_rating,
            "rank": player.rank.name,
            "promoted": promoted,
            "demoted": demoted
        }

What triggers a promotion?

When rank_rating hits 100 (RANK_THRESHOLD), the player promotes to the next rank and their RR resets by subtracting 100. If RR drops below 0, they demote and get 100 added back. Iron players can't demote further, so their RR floors at 0.

  • Save matchmaking.py and run the syntax check:
python matchmaking.py

No errors. Both the queue and the Rank Rating system are structurally sound.

Run the full demo

The algorithms are complete. Now you'll add two demo functions that exercise both systems with realistic data, then run them together to see matches form and rank convergence in action.

  • Add the demo_matchmaking function below the RankRatingSystem class:
def demo_matchmaking():
    """Demonstrate the matchmaking system."""
    print("=" * 60)
    print("MATCHMAKING DEMO")
    print("=" * 60)
    print()

    queue = MatchmakingQueue()

    print("Adding 20 players to queue with varying MMR:")
    for i in range(20):
        mmr = random.gauss(1500, 200)
        player = Player(
            player_id=f"player_{i:03d}",
            mmr=round(mmr),
            region="us-east",
            ping_ms=random.randint(15, 50)
        )
        queue.add_player(player)
        if i < 5:
            print(f"  {player.player_id}: MMR={player.mmr}")
    print(f"  ... and 15 more")
    print(f"\nTotal in queue: {len(queue.queue)}")
    print()
  • Continue demo_matchmaking by adding the match-forming loop directly below:
    matches_formed = 0
    while True:
        match = queue.find_match()
        if not match:
            break
        matches_formed += 1

        team_a_avg = sum(p.mmr for p in match.team_a) / len(match.team_a)
        team_b_avg = sum(p.mmr for p in match.team_b) / len(match.team_b)

        print(f"Match {match.match_id}:")
        print(f"  Quality: {match.quality_score:.2f} | MMR Spread: {match.mmr_spread:.0f}")
        print(f"  Team A (avg MMR {team_a_avg:.0f}): {[p.player_id for p in match.team_a]}")
        print(f"  Team B (avg MMR {team_b_avg:.0f}): {[p.player_id for p in match.team_b]}")
        print(f"  Team difference: {abs(team_a_avg - team_b_avg):.1f} MMR")
        print()

    print(f"Matches formed: {matches_formed}")
    print(f"Players remaining in queue: {len(queue.queue)}")
  • Add the demo_rank_rating function below demo_matchmaking:
def demo_rank_rating():
    """Demonstrate the Rank Rating convergence system."""
    print("\n" + "=" * 60)
    print("RANK RATING CONVERGENCE DEMO")
    print("=" * 60)
    print()
    print("Simulating a Gold player with MMR suggesting they belong in Platinum.")
    print("Watch how RR gains are boosted during convergence.")
    print()

    rr_system = RankRatingSystem()

    player = Player(
        player_id="test_player",
        mmr=550,
        rank=Rank.GOLD,
        rank_rating=50
    )

    print(f"Starting: {player.rank.name} {player.rank_rating}RR (MMR: {player.mmr})")
    print(f"MMR suggests: ~PLATINUM")
    print()

    results = [True, True, True, False, True, True, False, True, True, True]
  • Continue demo_rank_rating by adding the game simulation loop and the main function below:
    for i, won in enumerate(results):
        result = rr_system.apply_result(player, won)
        status = ""
        if result["promoted"]:
            status = " *** PROMOTED! ***"
        elif result["demoted"]:
            status = " --- DEMOTED ---"

        print(
            f"  Game {i+1}: {'WIN' if won else 'LOSS'} | "
            f"RR change: {result['rr_change']:+d} | "
            f"Now: {result['rank']} {result['new_rr']}RR{status}"
        )

    print(f"\nFinal: {player.rank.name} {player.rank_rating}RR")
    print(f"Win rate: {player.win_rate:.0%} ({player.wins}W/{player.games_played - player.wins}L)")
    print(f"\nNotice: RR gains were boosted (+{rr_system.BASE_RR_GAIN + rr_system.CONVERGENCE_BONUS} per win)")
    print(f"because the system detected rank was below MMR (convergence).")


def main():
    random.seed(42)
    demo_matchmaking()
    demo_rank_rating()


if __name__ == "__main__":
    main()

Why seed the random number generator?

random.seed(42) ensures every run produces the same player MMR values and match results. This makes the output deterministic so you can verify your implementation matches the expected behavior. In a real matchmaking system, randomness would come from actual player sign-ups.

  • Save matchmaking.py.

Before you run this, what do you expect to see? With 20 players and a 10-player match size, how many matches should form? Will the Gold player with Platinum MMR promote within 10 games?

  • Run the full matchmaking demo:
python matchmaking.py

You should see two sections of output. The matchmaking demo shows matches formed from the queue with quality scores and team compositions. The convergence demo shows the Gold player earning +30 RR per win (boosted by the convergence bonus) and eventually promoting to Platinum.

What should I see?

The matchmaking demo forms matches with quality scores close to 1.0 and small team MMR differences (under 50 MMR gap between teams is excellent). Two matches should form from 20 players, leaving 0 remaining in queue.

The RR demo shows the Gold player earning +30 RR on wins (not the base +20) because convergence is active. They should promote to Platinum after a few wins. Losses only cost -15 RR instead of the base -20.

Seeing a NameError or AttributeError?

A NameError for MatchmakingQueue or RankRatingSystem means the class isn't defined above the demo functions. Check that your class definitions appear before the demo_matchmaking() function.

If you see AttributeError on find_match, confirm that find_match is properly indented inside the MatchmakingQueue class and that both halves of the method are at the same indentation level (8 spaces from the file's left margin).

Help me debug my matchmaking.py runtime error

✔️ Awesome, I've got everything!

Great. Make sure you've saved matchmaking.py and your output shows matches forming with quality scores and the convergence demo with promotions.

ⓧ I'd like to double check the full code

Your matchmaking.py should contain these sections in order. Scroll through your file and confirm each one is present:

  • Imports (time, random, dataclasses, typing, enum).
  • Rank enum with IRON through RADIANT (values 1-9).
  • Player dataclass with mmr, rank_rating, rank, and a win_rate property.
  • Match dataclass with team_a, team_b, and quality_score.
  • MatchmakingQueue class with add_player, get_mmr_window, find_match, and _balance_teams.
  • RankRatingSystem class with calculate_rr_change and apply_result.
  • demo_matchmaking() generating 20 players and forming matches.
  • demo_rank_rating() simulating 10 games for a Gold player with Platinum MMR.
  • main() with random.seed(42) calling both demos.

Your matchmaking system is complete. You've built three core systems that every competitive game needs: a 128-tick server loop, lag compensation with state rewind, and skill-based matchmaking with rank convergence. Together, they form a system design answer you can present in any interview.

Secret mission

Capacity Planning and Load Testing Strategy

In interviews, after you explain the architecture, the follow-up is always 'how does this scale?' In this secret mission, you'll calculate real infrastructure requirements and design the load testing approach Riot used to validate their system before launch.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources or paid services, so there are no ongoing costs.

Resources you used:

  • valorant-system-design project folder containing game_server.py, netcode.py, matchmaking.py, capacity_planning.py, design_document.md, and architecture.drawio.
  • Python 3.14 installed on your system.

Keep everything running

No action needed. Choose this if you plan to extend your simulations or use this material for interview prep.

  • Your design document, architecture diagram, and Python simulations are a reusable interview preparation asset. Consider initializing a git repository in your valorant-system-design folder for version control.
  • This is Part 1 of 2. Part 2 covers Riot Direct networking, Vanguard anti-cheat kernel architecture, and the full microservices platform at scale.

Pause - I'll come back to this later

Since everything runs locally with no background processes, there is nothing to shut down. Your files will remain on your Desktop until you return.

  • Your valorant-system-design folder and all its contents will stay exactly where you left them.
  • Python 3.14 remains installed and ready for your next session.

Delete - I don't want to use this again

Remove all project files and optionally uninstall Python if you no longer need it.

  • Delete the project folder. On macOS/Linux, open your terminal and run:
cd ~/Desktop
rm -rf valorant-system-design

On Windows, open PowerShell and run:

cd ~\Desktop
Remove-Item -Recurse -Force valorant-system-design
  • To uninstall Python 3.14 on Windows: press the Windows key, search for Add or remove programs, find Python 3.14 in the list, and click Uninstall. Only do this if you have no other Python projects that depend on it.
  • draw.io (app.diagrams.net) is browser-based and leaves nothing installed on your machine. No cleanup needed.

Nice Work!

Nice Work!

Nice work! You've designed VALORANT's complete system architecture and built working Python simulations of its three hardest engineering challenges.

You've learned how to:

  • Design a two-layer architecture (game servers + platform microservices) and build a 128-tick server simulation that demonstrates frame budgets, server-authoritative input validation, and multi-game-per-core density.
  • Implement lag compensation with server-side state rewind and client-side prediction with server reconciliation, proving why both are essential for fair hit registration in competitive FPS games.
  • Build an MMR-based matchmaking system with expanding search windows, team balancing, and the Rank Rating convergence mechanic that decouples visible rank from hidden skill.
  • Secret Mission: Calculated real capacity requirements for 1M concurrent players and designed Riot's 2M-player load testing architecture with mock game servers and distributed harness containers.

Ready to quiz yourself?