Loading content...
In competitive gaming, a 100-millisecond delay isn't just noticeable—it's the difference between winning and losing. When a professional Counter-Strike player peeks around a corner, they expect to see the world as it exists right now, not as it existed a tenth of a second ago. Yet the fundamental limitation of physics means their inputs must travel across networks that inherently introduce delay.
Real-time gaming represents the most demanding form of real-time system design. Unlike chat applications where 200ms latency is acceptable, or collaborative documents where updates within 1 second feel "instant," games often require:
This page explores the architectures, algorithms, and techniques that power real-time multiplayer games—from casual mobile games to competitive esports.
By the end of this page, you will understand: • Client-server vs peer-to-peer networking models • Latency compensation techniques (client-side prediction, server reconciliation) • State synchronization strategies (snapshots, delta compression) • Deterministic lockstep for strategy games • Matchmaking and lobby architecture • How AAA games handle millions of concurrent players
Game networking differs from traditional web applications in fundamental ways:
Protocol Choice: TCP vs UDP
| Protocol | Behavior | Gaming Use Case |
|---|---|---|
| TCP | Reliable, ordered, connection-based | Login, matchmaking, chat, purchases |
| UDP | Unreliable, unordered, connectionless | Real-time game state, player movement, actions |
Why UDP for Real-Time State?
TCP's reliability comes at a cost: if packet #5 is lost, packets #6, #7, #8 cannot be delivered until #5 is retransmitted and received (head-of-line blocking). In a game updating 60 times per second, this can cause multi-frame stalls.
With UDP:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
package networking import ( "encoding/binary" "net") // GamePacket represents a UDP packet with custom reliabilitytype GamePacket struct { Header PacketHeader Payload []byte} type PacketHeader struct { SequenceNumber uint32 // For ordering and ack AckBitfield uint32 // Acks for last 32 packets LastAckedSeq uint32 // Most recent sequence we've processed PacketType uint8 // Type of packet Flags uint8 // Reliability flags PayloadLength uint16 // Length of payload} const ( PacketTypeGameState uint8 = 1 PacketTypeInput uint8 = 2 PacketTypeReliable uint8 = 3 // Reliable message (death, spawn, etc.) PacketTypeAck uint8 = 4) const ( FlagReliable uint8 = 1 << 0 // Must be acknowledged FlagOrdered uint8 = 1 << 1 // Must be processed in order FlagCompressed uint8 = 1 << 2 // Payload is compressed) // ReliableUDPChannel provides reliability on top of UDP for critical messagestype ReliableUDPChannel struct { conn *net.UDPConn localSequence uint32 remoteSequence uint32 pendingAcks map[uint32]*PendingPacket receivedBitfield uint32} type PendingPacket struct { packet GamePacket sentTime int64 retries int} func (c *ReliableUDPChannel) SendReliable(payload []byte) error { packet := GamePacket{ Header: PacketHeader{ SequenceNumber: c.localSequence, AckBitfield: c.receivedBitfield, LastAckedSeq: c.remoteSequence, PacketType: PacketTypeReliable, Flags: FlagReliable, PayloadLength: uint16(len(payload)), }, Payload: payload, } // Store for potential retransmission c.pendingAcks[c.localSequence] = &PendingPacket{ packet: packet, sentTime: time.Now().UnixNano(), retries: 0, } c.localSequence++ return c.send(packet)} func (c *ReliableUDPChannel) SendUnreliable(payload []byte) error { // For game state - no retransmission needed packet := GamePacket{ Header: PacketHeader{ SequenceNumber: c.localSequence, AckBitfield: c.receivedBitfield, LastAckedSeq: c.remoteSequence, PacketType: PacketTypeGameState, Flags: 0, PayloadLength: uint16(len(payload)), }, Payload: payload, } c.localSequence++ return c.send(packet)} // ProcessAcks handles incoming acknowledgements, removes packets from pendingfunc (c *ReliableUDPChannel) ProcessAcks(header PacketHeader) { // Mark the last acked sequence as received delete(c.pendingAcks, header.LastAckedSeq) // Process bitfield for previous 32 packets for i := uint32(0); i < 32; i++ { if header.AckBitfield & (1 << i) != 0 { seq := header.LastAckedSeq - i - 1 delete(c.pendingAcks, seq) } }} // RetransmitPending resends packets that haven't been acknowledgedfunc (c *ReliableUDPChannel) RetransmitPending(timeout int64) { now := time.Now().UnixNano() for seq, pending := range c.pendingAcks { if now - pending.sentTime > timeout { pending.retries++ pending.sentTime = now if pending.retries > 5 { // Too many retries - consider connection dead delete(c.pendingAcks, seq) continue } c.send(pending.packet) } }} func (c *ReliableUDPChannel) send(packet GamePacket) error { // Serialize and send return nil}Network Architecture Models:
| Model | Description | Pros | Cons | Used By |
|---|---|---|---|---|
| Client-Server | Central server authoritative | Cheat resistance, consistent state | Server latency, hosting cost | FPS, MMO, most online games |
| Peer-to-Peer | Players connect directly | No server hosting cost, low latency | Cheat vulnerable, NAT issues | Fighting games, some RTS |
| Listen Server | One player hosts, acts as server | No dedicated server needed | Host advantage, drops if host leaves | Casual multiplayer |
| Lockstep | All inputs synchronized before advancing | Perfect consistency, low bandwidth | Latency = slowest player | RTS, turn-based |
| Relay Server | Server relays packets, not authoritative | NAT traversal, some cheat protection | Added latency, less consistency | Some mobile games |
In competitive games, the server is the 'source of truth.' Clients send inputs; server computes outcomes. This prevents common cheats (teleportation, speed hacks) because the server validates all state changes. The tradeoff is added latency—your action must roundtrip to the server before being 'real.'
With an authoritative server, every action has inherent roundtrip delay. A player with 50ms ping experiences at least 50ms between pressing a button and seeing the result. For fluid gameplay, we must hide this latency.
Client-Side Prediction:
The client doesn't wait for the server—it predicts the outcome of player actions immediately:
Server Reconciliation:
When prediction is wrong (due to collision with another player, server-side event, etc.), the client must correct without jarring the player:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
interface Input { sequence: number; timestamp: number; moveDirection: { x: number; y: number }; actions: string[];} interface PlayerState { position: { x: number; y: number; z: number }; velocity: { x: number; y: number; z: number }; rotation: number;} interface ServerUpdate { tick: number; lastProcessedInput: number; players: Map<string, PlayerState>;} class ClientPrediction { private pendingInputs: Input[] = []; private localState: PlayerState; private displayState: PlayerState; // What player sees (smoothed) private inputSequence: number = 0; // Called every frame processLocalInput(input: Input): void { input.sequence = ++this.inputSequence; input.timestamp = Date.now(); // 1. Apply input to local state immediately (prediction) this.localState = this.applyInput(this.localState, input); // 2. Send to server this.network.sendInput(input); // 3. Store for later reconciliation this.pendingInputs.push(input); // 4. Update display (with smoothing) this.updateDisplay(); } // Called when server update arrives processServerUpdate(update: ServerUpdate): void { const myState = update.players.get(this.playerId); if (!myState) return; // 1. Set authoritative state this.localState = { ...myState }; // 2. Remove acknowledged inputs this.pendingInputs = this.pendingInputs.filter( input => input.sequence > update.lastProcessedInput ); // 3. Re-apply remaining (unacknowledged) inputs for (const input of this.pendingInputs) { this.localState = this.applyInput(this.localState, input); } // 4. Check for misprediction const error = this.calculatePositionError(this.displayState, this.localState); if (error > this.correctionThreshold) { // Smoothly correct visible position this.scheduleCorrection(this.localState); } this.updateDisplay(); } private applyInput(state: PlayerState, input: Input): PlayerState { // Simulate one tick of movement const speed = 5.0; const dt = 1 / 60; // 60 tick rate return { ...state, position: { x: state.position.x + input.moveDirection.x * speed * dt, y: state.position.y + input.moveDirection.y * speed * dt, z: state.position.z, }, }; } private updateDisplay(): void { // Interpolate towards predicted state for smooth visuals const t = 0.2; // Interpolation factor this.displayState = { position: { x: this.displayState.position.x + (this.localState.position.x - this.displayState.position.x) * t, y: this.displayState.position.y + (this.localState.position.y - this.displayState.position.y) * t, z: this.displayState.position.z + (this.localState.position.z - this.displayState.position.z) * t, }, velocity: this.localState.velocity, rotation: this.localState.rotation, }; } private scheduleCorrection(targetState: PlayerState): void { // Animate towards correct position over ~100ms // to hide the correction from the player } private calculatePositionError(a: PlayerState, b: PlayerState): number { const dx = a.position.x - b.position.x; const dy = a.position.y - b.position.y; return Math.sqrt(dx * dx + dy * dy); } private correctionThreshold = 0.5; // Correct if error > 0.5 units}Entity Interpolation:
For other players (non-local entities), we don't predict—we interpolate between received states:
Why render in the past? Because server updates arrive irregularly and may be delayed. Buffering ensures smooth motion even with jitter.
Tradeoff: Other players appear slightly behind their actual server positions (typically 50-100ms behind). This is why in fast-paced shooters, you sometimes feel like you shot someone but missed—you shot where they appeared to be, not where they actually were.
Games like Valorant and Overwatch use lag compensation: when a player shoots, the server rewinds time to where enemies appeared on that player's screen, then evaluates the hit. This makes shots feel fair to shooters but can make defenders feel like they died 'behind cover' because they actually moved after the shooter fired from their perspective.
Sending complete game state every frame is prohibitively expensive. A modern game might have:
At 60Hz with naive serialization, this could require megabits per second per player. We need compression techniques:
Snapshot Compression Techniques:
| Technique | Description | Typical Savings | Complexity |
|---|---|---|---|
| Delta Compression | Send only changes from previous state | 60-90% | Medium |
| Quantization | Reduce precision (float32 → fixed-point) | 50%+ | Low |
| Bit Packing | Pack multiple values into minimal bits | 30-60% | Medium |
| Interest Management | Send only nearby/relevant entities | 50-95% | High |
| Entropy Coding | Huffman/arithmetic coding on deltas | 20-40% | Medium |
| Interpolation Send Rate | Send at 20Hz instead of 60Hz | 67% | Low |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
package sync import ( "bytes" "encoding/binary") // EntityState represents the state of a game entitytype EntityState struct { ID uint32 Position [3]float32 Rotation [4]float32 // Quaternion Velocity [3]float32 Animation uint16 Health uint8 Flags uint8} // DeltaCompressor compresses game state using delta encodingtype DeltaCompressor struct { baseline map[uint32]EntityState tickNumber uint32 baselineTick uint32} // CompressDelta creates a delta-compressed snapshotfunc (dc *DeltaCompressor) CompressDelta( tick uint32, entities []EntityState,) []byte { buf := new(bytes.Buffer) // Write tick info binary.Write(buf, binary.LittleEndian, tick) binary.Write(buf, binary.LittleEndian, dc.baselineTick) for _, entity := range entities { baseline, hasBaseline := dc.baseline[entity.ID] if !hasBaseline { // New entity - send full state dc.writeFullEntity(buf, entity) continue } // Calculate which fields changed changed := dc.calculateDelta(baseline, entity) if changed == 0 { // No change - skip entirely continue } // Write entity ID and change mask binary.Write(buf, binary.LittleEndian, entity.ID) binary.Write(buf, binary.LittleEndian, changed) // Write only changed fields dc.writeDelta(buf, baseline, entity, changed) } return buf.Bytes()} // FieldMask tracks which fields changedconst ( FieldPosition uint16 = 1 << 0 FieldRotation uint16 = 1 << 1 FieldVelocity uint16 = 1 << 2 FieldAnimation uint16 = 1 << 3 FieldHealth uint16 = 1 << 4 FieldFlags uint16 = 1 << 5) func (dc *DeltaCompressor) calculateDelta(a, b EntityState) uint16 { var changed uint16 if !nearEqual(a.Position, b.Position, 0.001) { changed |= FieldPosition } if !nearEqualQuat(a.Rotation, b.Rotation, 0.001) { changed |= FieldRotation } if !nearEqual(a.Velocity, b.Velocity, 0.01) { changed |= FieldVelocity } if a.Animation != b.Animation { changed |= FieldAnimation } if a.Health != b.Health { changed |= FieldHealth } if a.Flags != b.Flags { changed |= FieldFlags } return changed} func (dc *DeltaCompressor) writeDelta( buf *bytes.Buffer, baseline, current EntityState, changed uint16,) { if changed&FieldPosition != 0 { // Quantize position to 16-bit fixed-point deltas for i := 0; i < 3; i++ { delta := current.Position[i] - baseline.Position[i] quantized := quantizeFloat(delta, -100, 100, 16) binary.Write(buf, binary.LittleEndian, quantized) } } if changed&FieldRotation != 0 { // Smallest-three quaternion compression // Only send 3 components, derive 4th dc.writeSmallestThreeQuat(buf, current.Rotation) } if changed&FieldVelocity != 0 { for i := 0; i < 3; i++ { quantized := quantizeFloat(current.Velocity[i], -50, 50, 12) binary.Write(buf, binary.LittleEndian, quantized) } } if changed&FieldAnimation != 0 { binary.Write(buf, binary.LittleEndian, current.Animation) } if changed&FieldHealth != 0 { buf.WriteByte(current.Health) } if changed&FieldFlags != 0 { buf.WriteByte(current.Flags) }} // Quantize float to n-bit integerfunc quantizeFloat(value, min, max float32, bits int) uint16 { normalized := (value - min) / (max - min) maxValue := (1 << bits) - 1 return uint16(normalized * float32(maxValue))} // Smallest-three quaternion compression// Quaternion has unit length, so we only need 3 components// The 4th can be derived: w = sqrt(1 - x² - y² - z²)func (dc *DeltaCompressor) writeSmallestThreeQuat(buf *bytes.Buffer, q [4]float32) { // Find largest component largest := 0 for i := 1; i < 4; i++ { if abs(q[i]) > abs(q[largest]) { largest = i } } // Write 2 bits for which component is largest // Write 10 bits each for the other 3 components // Total: 32 bits instead of 128 bits var packed uint32 packed |= uint32(largest) << 30 j := 0 for i := 0; i < 4; i++ { if i != largest { quantized := quantizeFloat(q[i], -0.707107, 0.707107, 10) packed |= uint32(quantized) << (j * 10) j++ } } binary.Write(buf, binary.LittleEndian, packed)} func nearEqual(a, b [3]float32, epsilon float32) bool { for i := 0; i < 3; i++ { if abs(a[i]-b[i]) > epsilon { return false } } return true} func nearEqualQuat(a, b [4]float32, epsilon float32) bool { for i := 0; i < 4; i++ { if abs(a[i]-b[i]) > epsilon { return false } } return true} func abs(x float32) float32 { if x < 0 { return -x } return x}Interest Management (Spatial Partitioning):
In large worlds, players don't need updates for entities far away. Partition the world and only send updates for nearby areas:
| Approach | Description | Use Case |
|---|---|---|
| Grid-based | Divide world into grid cells, subscribe to nearby cells | Open world games |
| Distance-based | Send updates based on distance from player | Battle royale, MMO |
| Line-of-sight | Only send visible entities | Shooter games |
| Importance-based | Prioritize nearby + relevant entities | Complex simulations |
Example: Fortnite divides its map into cells. Your client only receives updates for players in your cell and adjacent cells. As you move, your subscriptions update.
Set a bandwidth budget per client (e.g., 50 KB/s). When there's more data to send than budget allows, prioritize: (1) local player state always sent, (2) nearby players, (3) visible entities, (4) world state. Drop lowest-priority updates first. This ensures smooth gameplay even when the world is busy.
Real-time strategy (RTS) games face a unique challenge: thousands of units with complex AI. Sending the state of every unit every frame is impossible. The solution: deterministic lockstep.
Core Principle: If all players start with the same initial state and apply the same inputs in the same order, they'll arrive at the same final state—even with thousands of units.
How It Works:
Determinism Requirements:
For lockstep to work, the simulation must be perfectly deterministic:
| Requirement | Challenge | Solution |
|---|---|---|
| No floating-point variance | Different CPUs compute floats differently | Use fixed-point math |
| Same random numbers | Random must be identical | Seeded RNG, same seed for all |
| Same execution order | Multi-threading introduces order variance | Single-threaded simulation or deterministic scheduling |
| No uninitialized memory | Garbage values differ | Initialize everything |
| Same time steps | Variable frame times cause drift | Fixed timestep simulation |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
// Deterministic lockstep simulation // Use fixed-point math to avoid floating-point varianceclass FixedPoint { private value: bigint; private static SCALE = 1000n; // 3 decimal places constructor(value: number | bigint) { if (typeof value === 'number') { this.value = BigInt(Math.round(value * 1000)); } else { this.value = value; } } add(other: FixedPoint): FixedPoint { return new FixedPoint(this.value + other.value); } sub(other: FixedPoint): FixedPoint { return new FixedPoint(this.value - other.value); } mul(other: FixedPoint): FixedPoint { return new FixedPoint((this.value * other.value) / FixedPoint.SCALE); } div(other: FixedPoint): FixedPoint { return new FixedPoint((this.value * FixedPoint.SCALE) / other.value); } toNumber(): number { return Number(this.value) / 1000; }} // Seeded random number generator (deterministic)class SeededRNG { private seed: number; constructor(seed: number) { this.seed = seed; } // Mulberry32 - fast, deterministic PRNG next(): number { let t = this.seed += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; } nextInt(min: number, max: number): number { return Math.floor(this.next() * (max - min + 1)) + min; }} interface TurnInput { playerId: string; turnNumber: number; commands: GameCommand[]; checksum: number; // For desync detection} interface GameCommand { type: 'move' | 'attack' | 'build' | 'upgrade'; entityIds: number[]; targetPosition?: { x: FixedPoint; y: FixedPoint }; targetEntityId?: number;} class LockstepSimulation { private turnNumber: number = 0; private players: string[]; private pendingInputs: Map<string, TurnInput[]> = new Map(); private turnDelay: number = 2; // Process turn N-2 when we're at turn N private stateChecksum: number = 0; // Collection of inputs to send private localCommands: GameCommand[] = []; constructor(players: string[], seed: number) { this.players = players; for (const player of players) { this.pendingInputs.set(player, []); } this.rng = new SeededRNG(seed); } private rng: SeededRNG; // Called every simulation tick (e.g., 100ms / 10 ticks per second) tick(): boolean { // Send our inputs for this turn this.broadcastInput({ playerId: this.localPlayerId, turnNumber: this.turnNumber, commands: this.localCommands, checksum: this.stateChecksum, }); this.localCommands = []; // Can we simulate the next turn? const turnToSimulate = this.turnNumber - this.turnDelay; if (turnToSimulate < 0) { this.turnNumber++; return true; // Still in startup delay } if (!this.haveAllInputsForTurn(turnToSimulate)) { // Wait for slow players console.log(`Waiting for inputs for turn ${turnToSimulate}`); return false; // Don't advance } // Simulate the turn const inputs = this.getInputsForTurn(turnToSimulate); this.simulateTurn(inputs); // Verify all players have same state for (const input of inputs) { if (input.checksum !== this.stateChecksum) { console.error(`DESYNC! Player ${input.playerId} has different state`); // Handle desync - usually disconnect or resync } } this.turnNumber++; return true; } private simulateTurn(inputs: TurnInput[]): void { // Sort inputs deterministically (by player ID) inputs.sort((a, b) => a.playerId.localeCompare(b.playerId)); // Apply all commands for (const input of inputs) { for (const command of input.commands) { this.executeCommand(command); } } // Update game simulation (AI, physics, etc.) this.updateSimulation(); // Update checksum for desync detection this.stateChecksum = this.calculateStateChecksum(); } private executeCommand(command: GameCommand): void { // Deterministic command execution switch (command.type) { case 'move': for (const entityId of command.entityIds) { this.units.get(entityId)?.setMoveTarget(command.targetPosition!); } break; case 'attack': for (const entityId of command.entityIds) { this.units.get(entityId)?.setAttackTarget(command.targetEntityId!); } break; // ... other commands } } private updateSimulation(): void { // Update all units in deterministic order (by ID) const unitIds = Array.from(this.units.keys()).sort((a, b) => a - b); for (const unitId of unitIds) { const unit = this.units.get(unitId)!; unit.update(this.rng); // RNG calls must be deterministic } } private calculateStateChecksum(): number { // Hash of relevant game state for desync detection let hash = 0; for (const [id, unit] of this.units) { hash ^= id; hash ^= Math.floor(unit.position.x.toNumber() * 1000); hash ^= Math.floor(unit.position.y.toNumber() * 1000); hash ^= unit.health; } return hash >>> 0; } private haveAllInputsForTurn(turn: number): boolean { for (const player of this.players) { const inputs = this.pendingInputs.get(player)!; if (!inputs.some(i => i.turnNumber === turn)) { return false; } } return true; } private getInputsForTurn(turn: number): TurnInput[] { const result: TurnInput[] = []; for (const player of this.players) { const inputs = this.pendingInputs.get(player)!; const input = inputs.find(i => i.turnNumber === turn); if (input) { result.push(input); } } return result; } // External interface private localPlayerId: string = ''; private units: Map<number, any> = new Map(); private broadcastInput(input: TurnInput): void { /* ... */ }}Starcraft, Age of Empires, and many RTS games use deterministic lockstep. It enables thousands of units with minimal bandwidth (just inputs, ~1 KB/s) but requires perfect determinism and makes the game as slow as the slowest player. Modern RTS games often add prediction for local units while still using lockstep for authoritative simulation.
Before players can play together, they need to find each other. Matchmaking and lobby systems are critical infrastructure for multiplayer games.
Matchmaking Goals:
These goals often conflict—exact skill matching increases wait time; regional matching limits the player pool.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
package matchmaking import ( "container/heap" "sync" "time") type Player struct { ID string SkillRating int // MMR/ELO score Region string // Geographic region PartyID string // If in a party with others QueuedAt time.Time GameMode string Preferences MatchPreferences} type MatchPreferences struct { MaxPing int // Maximum acceptable ping AcceptFillParty bool // Willing to fill incomplete party} type Match struct { ID string Players []*Player GameMode string Region string ServerID string} // Matchmaker implements skill-based matchmaking with expanding searchtype Matchmaker struct { queues map[string]*PlayerQueue // per-gamemode queues mu sync.RWMutex matchSize int baseWindow int // Initial skill window (e.g., ±50) maxWindow int // Maximum skill window (e.g., ±500) expandRate int // Expand window by this much per second of wait} type PlayerQueue struct { players []*Player mu sync.Mutex} func NewMatchmaker(matchSize, baseWindow, maxWindow, expandRate int) *Matchmaker { return &Matchmaker{ queues: make(map[string]*PlayerQueue), matchSize: matchSize, baseWindow: baseWindow, maxWindow: maxWindow, expandRate: expandRate, }} // AddToQueue adds a player to the matchmaking queuefunc (m *Matchmaker) AddToQueue(player *Player) { player.QueuedAt = time.Now() m.mu.Lock() queue, exists := m.queues[player.GameMode] if !exists { queue = &PlayerQueue{} m.queues[player.GameMode] = queue } m.mu.Unlock() queue.mu.Lock() queue.players = append(queue.players, player) queue.mu.Unlock()} // FindMatches attempts to create matches from the queuefunc (m *Matchmaker) FindMatches(gameMode string) []*Match { m.mu.RLock() queue, exists := m.queues[gameMode] m.mu.RUnlock() if !exists { return nil } queue.mu.Lock() defer queue.mu.Unlock() var matches []*Match now := time.Now() // Sort by queue time (longest waiting first) sort.Slice(queue.players, func(i, j int) bool { return queue.players[i].QueuedAt.Before(queue.players[j].QueuedAt) }) matched := make(map[string]bool) for _, anchor := range queue.players { if matched[anchor.ID] { continue } // Calculate expanded skill window based on wait time waitSeconds := int(now.Sub(anchor.QueuedAt).Seconds()) skillWindow := min(m.baseWindow + waitSeconds*m.expandRate, m.maxWindow) // Find compatible players candidates := m.findCompatiblePlayers( queue.players, anchor, skillWindow, matched, ) if len(candidates) >= m.matchSize-1 { // Create match match := &Match{ ID: generateMatchID(), Players: append([]*Player{anchor}, candidates[:m.matchSize-1]...), GameMode: gameMode, Region: anchor.Region, } matches = append(matches, match) // Mark as matched matched[anchor.ID] = true for _, p := range match.Players { matched[p.ID] = true } } } // Remove matched players from queue remaining := make([]*Player, 0) for _, player := range queue.players { if !matched[player.ID] { remaining = append(remaining, player) } } queue.players = remaining return matches} func (m *Matchmaker) findCompatiblePlayers( pool []*Player, anchor *Player, skillWindow int, excluded map[string]bool,) []*Player { var compatible []*Player for _, candidate := range pool { if excluded[candidate.ID] || candidate.ID == anchor.ID { continue } // Check skill compatibility skillDiff := abs(candidate.SkillRating - anchor.SkillRating) if skillDiff > skillWindow { continue } // Check region compatibility if candidate.Region != anchor.Region { continue } // Check party conflicts if anchor.PartyID != "" && candidate.PartyID != "" && anchor.PartyID != candidate.PartyID { // Both in parties, but different parties // Can still match if party sizes allow } compatible = append(compatible, candidate) } // Sort by skill proximity sort.Slice(compatible, func(i, j int) bool { diffI := abs(compatible[i].SkillRating - anchor.SkillRating) diffJ := abs(compatible[j].SkillRating - anchor.SkillRating) return diffI < diffJ }) return compatible} // Skill Rating Systemstype EloCalculator struct { kFactor int // How much ratings change per game} func (e *EloCalculator) CalculateNewRatings( winnerRating, loserRating int,) (newWinner, newLoser int) { // Expected score based on rating difference expectedWinner := 1.0 / (1.0 + pow(10, float64(loserRating-winnerRating)/400)) expectedLoser := 1.0 - expectedWinner // Actual scores (1 for win, 0 for loss) newWinner = winnerRating + int(float64(e.kFactor)*(1.0-expectedWinner)) newLoser = loserRating + int(float64(e.kFactor)*(0.0-expectedLoser)) return newWinner, newLoser} func min(a, b int) int { if a < b { return a } return b} func abs(x int) int { if x < 0 { return -x } return x} func pow(base float64, exp float64) float64 { return 1 // Simplified} func generateMatchID() string { return "match-" + time.Now().Format("20060102150405")}Lobby Flow:
Once matched, players enter a lobby:
Handling Dropouts:
Elo (chess): Simple, based on win/loss. Glicko-2: Adds uncertainty decay—inactive players become less certain. TrueSkill (Microsoft): Designed for team games, handles parties and unequal teams. OpenSkill: Modern open-source alternative. Choose based on your game's needs—team size, party support, and calculation complexity.
Popular games need infrastructure that scales to millions of concurrent players:
Typical Architecture:
Game Server Scaling Patterns:
| Pattern | Description | Use Case |
|---|---|---|
| Pre-provisioned Pools | Fixed number of warm servers per region | Predictable load, low latency startup |
| Auto-scaling | Scale server count based on queue depth | Variable load, cost optimization |
| Container Orchestration | Kubernetes for game server lifecycle | Modern infrastructure, hybrid cloud |
| Dedicated Hardware | Bare metal for consistent performance | High-tickrate competitive games |
| Spot/Preemptible | Use cheap instances for non-critical | Development, testing, casual games |
Agones (Kubernetes for Games):
Google's open-source Agones provides Kubernetes-native game server orchestration:
apiVersion: "agones.dev/v1"
kind: Fleet
metadata:
name: game-server-fleet
spec:
replicas: 100
template:
spec:
container: game-server
ports:
- name: default
containerPort: 7654
scheduling: Packed # Pack servers onto fewer nodes
Session Management:
When a game ends, player progress must be saved reliably:
Epic Games runs Fortnite on AWS with custom orchestration. At peak, they manage thousands of game servers across multiple regions. Each 100-player match runs on dedicated server. They use Kubernetes for stateless services but custom orchestration for game servers due to unique requirements (UDP networking, precise resource allocation, graceful shutdown for match completion).
Cheating ruins games. Anti-cheat in a real-time system requires balancing detection effectiveness with performance and privacy.
Common Cheats and Mitigations:
| Cheat Type | How It Works | Detection/Prevention |
|---|---|---|
| Aimbot | Automatically aims at enemies | Server-side hit validation, humanness checks, replay review |
| Wallhack | See through walls | Only send visible player data (culling server-side) |
| Speed hack | Move faster than allowed | Server validates movement against physics limits |
| Teleport | Instantly move to any location | Server authoritative position, reject invalid moves |
| Packet manipulation | Modify network traffic | Encrypted/signed packets, server validation |
| Memory editing | Modify game memory (health, ammo) | Client-side detection (kernel drivers) |
Server-Authoritative Design as Primary Defense:
The most effective anti-cheat is making the server the source of truth:
Client says: "I teleported to (100, 200)"
Server responds: "No. Your position is (50, 51). Here's the authoritative state."
Information Hiding:
Don't send information clients shouldn't have:
Statistical Detection:
Machine learning can detect cheaters from behavioral patterns:
Kernel-Level Anti-Cheat:
Tools like EasyAntiCheat, Vanguard, and BattlEye run at kernel level to detect memory modification and process injection. Controversial due to privacy concerns but effective against sophisticated cheats.
Anti-cheat is an ongoing battle. As you deploy detections, cheat developers adapt. Focus on defense in depth: server authority for primary protection, statistical detection for sophisticated cheats, kernel-level for client-side manipulation. No single solution catches everything.
Real-time gaming pushes the boundaries of distributed systems, requiring extreme attention to latency, consistency, and scale.
You now understand the architectural patterns for real-time gaming systems. Next, we'll explore Scaling Considerations—the cross-cutting concerns that apply to all real-time systems, including capacity planning, degradation strategies, and the tradeoffs inherent in real-time architecture.