Network Protocols for Games

Learning Objectives

  • • Understand why HTTP isn't suitable for real-time games
  • • Master UDP vs TCP trade-offs for different game types
  • • Learn message serialization with Protocol Buffers
  • • Build UDP server with connection management
  • • Handle packet loss and out-of-order delivery

Lesson 2.1: Why Not HTTP?

❌ Why HTTP Fails for Games

Developers new to games often ask: "Why not just use HTTP/REST like web apps?" Let's see why this approach fails for real-time games.

The HTTP Problem

Scenario: Player moves in game at 60 updates/sec

HTTP/REST Approach

T=0ms   Client: POST /game/move {"x": 10, "y": 20}
        └─> Network (50ms)
T=50ms  Server receives, processes
T=60ms  Server responds 200 OK with full game state
        └─> Network (50ms)
T=110ms Client receives → Finally sees update

Result: 110ms latency before player sees anything. Unplayable!

Why HTTP Fails for Games

  • Request/Response Model: Must wait for response
  • High Overhead: Headers, compression, parsing
  • No Streaming: Can't continuously send state
  • Polling: If you poll for state updates, you're constantly sending requests = huge waste

Lesson 2.2: TCP vs UDP Trade-offs

TCP (Transmission Control Protocol)

Characteristics:
- Connection-oriented (handshake required)
- Guaranteed delivery (retransmits lost packets)
- In-order delivery (packets arrive in sequence)
- Automatic congestion control
- Slower (overhead for guarantees)

Overhead per packet: 20 bytes minimum
Latency: Higher (retransmits, waits for ACK)

Best for: Turn-based games, MMO chat, file transfers
NOT good for: Fast-paced shooters

Real-World Examples

  • • Turn-based strategy (Chess.com)
  • • Card games (Hearthstone)
  • • Text chat in any game
  • • Persistent messaging (trade requests, emails)

UDP (User Datagram Protocol)

Characteristics:
- Connectionless (no handshake)
- No delivery guarantee (sent, might be lost)
- No ordering guarantee (packets arrive out of order)
- No congestion control (app must handle)
- Faster (minimal overhead)

Overhead per packet: 8 bytes
Latency: Lower (send immediately)

Best for: Real-time shooters, fighting games
Tradeoff: App must handle loss/ordering

Real-World Examples

  • • FPS shooters (Valorant, CS:GO)
  • • Battle royale (Fortnite)
  • • Fighting games (Street Fighter)
  • • Action games (any real-time)

Visual Comparison

TCP Timeline

T=0ms   Client sends packet
        └─> Network (50ms)
T=50ms  Server receives
        └─> Server responds ACK (50ms)
T=100ms Client receives ACK
        └─> Can send next packet
Total: 100ms to send one update (too slow!)

UDP Timeline

T=0ms   Client sends packet
        └─> Network (50ms)
T=50ms  Server receives
        (No waiting for ACK, immediately ready for next)
Total: 50ms latency (much better!)

💡 Hybrid Approaches

Most modern games use both protocols for different purposes:

TCP Used For:
  • • Authentication
  • • Lobby chat
  • • Ranked queue
  • • Inventory updates
UDP Used For:
  • • In-game position
  • • Shooting
  • • Abilities
  • • Animations

Lesson 2.3: Message Serialization

Format Comparison

Format Size Speed Ease Network
JSON 80B Slow Easy Bandwidth hog
MessagePack 30B Fast Medium Good
Protobuf 16B Fast Medium Excellent
Binary 14B Fastest Hard Best

Recommendation for Games

  • Prototype/development: JSON (easy debugging)
  • Production: Protocol Buffers (good balance)
  • Ultra-competitive (esports): Binary (every byte counts)

Protocol Buffers Example

syntax = "proto3";

package game;

// Player input from client
message PlayerInput {
  int32 player_id = 1;
  float move_x = 2;           // -1 to 1
  float move_y = 3;           // -1 to 1
  uint32 tick = 4;            // Which tick this input is for
  bool shoot = 5;
  bool jump = 6;
  uint32 target_player_id = 7;
}

// World update from server to client
message WorldUpdate {
  uint32 tick = 1;
  int64 server_time_ms = 2;
  repeated ProjectileState projectiles = 4;
  repeated Entity entities = 5;
}

message PlayerState {
  int32 player_id = 1;
  Vector3 position = 2;
  Vector3 velocity = 3;
  float rotation = 4;
  int32 health = 5;
  string animation = 6;
  repeated StatusEffect effects = 7;
}

message Vector3 {
  float x = 1;
  float y = 2;
  float z = 3;
}

Usage in Go

import "game.pb"  // Generated from protobuf

// Encode message
input := &game.PlayerInput{
    PlayerId: 5,
    MoveX:    0.5,
    MoveY:    0.0,
    Tick:     120,
    Shoot:    true,
}

// Convert to bytes
data, err := proto.Marshal(input)
if err != nil {
    log.Fatal(err)
}

// Send over network
conn.WriteToUDP(data, remoteAddr)

// On receiving end
var receivedInput game.PlayerInput
err = proto.Unmarshal(packetData, &receivedInput)
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Player %d moved (%.2f, %.2f)\n", 
    receivedInput.PlayerId, 
    receivedInput.MoveX, 
    receivedInput.MoveY)

Lab 2: Build UDP Server with Broadcast

Objective

Build a UDP server that accepts connections from multiple clients, tracks connected clients, broadcasts messages to all connected clients, and handles timeouts.

Requirements

  • • Accept UDP connections
  • • Track multiple clients by IP:Port
  • • Broadcast received messages to all clients
  • • Detect timeouts and remove disconnected clients
  • • Print stats every 5 seconds

Starter Code

package main

import (
    "fmt"
    "log"
    "net"
    "time"
)

type GameServer struct {
    conn              *net.UDPConn
    clients           map[int]*ClientConnection
    clientsByAddr     map[string]int
    nextPlayerID      int
    disconnectTimeout time.Duration
    ticker            *time.Ticker
}

type ClientConnection struct {
    PlayerID     int
    RemoteAddr   *net.UDPAddr
    LastPacketAt time.Time
    IsConnected  bool
}

// Broadcast message from one player to all others
type BroadcastMessage struct {
    SenderID  int
    Message   string
    Timestamp int64
}

func NewGameServer(addr string, port int) (*GameServer, error) {
    // TODO: Create UDP listener on addr:port
    // Return GameServer or error if binding fails
    
    return &GameServer{
        clients:           make(map[int]*ClientConnection),
        clientsByAddr:     make(map[string]int),
        nextPlayerID:      1,
        disconnectTimeout: 30 * time.Second,
    }, nil
}

func (g *GameServer) Start() {
    // TODO: Start listening loop
    // 1. Read UDP packets in loop
    // 2. For each packet: call HandlePacket()
}

func (g *GameServer) HandlePacket(data []byte, remoteAddr *net.UDPAddr) {
    // TODO:
    // 1. Check if sender is known (by IP:Port)
    // 2. If new: assign PlayerID, register client
    // 3. If known: update LastPacketAt
    // 4. Parse message and broadcast to all
}

func (g *GameServer) Broadcast(message BroadcastMessage) {
    // TODO:
    // 1. Convert message to bytes
    // 2. Send to all connected clients
    // 3. Log broadcast
}

func (g *GameServer) CheckTimeouts() {
    // TODO:
    // 1. Check each client's LastPacketAt
    // 2. If older than disconnectTimeout:
    //    - Remove from clients
    //    - Remove from clientsByAddr
    //    - Notify others that they left
}

func (g *GameServer) PrintStats() {
    fmt.Printf("[%s] Connected: %d players\n", 
        time.Now().Format("15:04:05"), 
        len(g.clients))
    
    for playerID, client := range g.clients {
        fmt.Printf("  Player %d: %s (last: %.1fs ago)\n",
            playerID,
            client.RemoteAddr.String(),
            time.Since(client.LastPacketAt).Seconds())
    }
}

func main() {
    server, err := NewGameServer("127.0.0.1", 9000)
    if err != nil {
        log.Fatal(err)
    }
    
    // Start server loop
    go server.Start()
    
    // Periodically check timeouts
    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()
        
        for range ticker.C {
            server.CheckTimeouts()
            server.PrintStats()
        }
    }()
    
    // Run for 60 seconds
    time.Sleep(60 * time.Second)
}

Client Simulator (for testing)

// Run this in separate process to test server

func TestClient(id int, serverAddr string) {
    conn, err := net.Dial("udp", serverAddr)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    
    // Send initial message
    msg := fmt.Sprintf("Hello, I'm client %d", id)
    conn.Write([]byte(msg))
    
    // Send periodic messages
    ticker := time.NewTicker(10 * time.Second)
    for i := 0; i < 5; i++ {
        <-ticker.C
        msg := fmt.Sprintf("Message %d from client %d", i, id)
        conn.Write([]byte(msg))
    }
}

Expected Output

[09:32:15] Connected: 2 players
  Player 1: 127.0.0.1:54321 (last: 0.2s ago)
  Player 2: 127.0.0.1:54322 (last: 0.1s ago)

[09:32:20] Connected: 2 players
  Player 1: 127.0.0.1:54321 (last: 5.2s ago)
  Player 2: 127.0.0.1:54322 (last: 5.1s ago)

[09:32:25] Connected: 1 players
  Player 2: 127.0.0.1:54322 (last: 0.1s ago)
[Player 1 TIMED OUT]

✅ Success Criteria

  • • Server accepts UDP connections on specified port
  • • Tracks multiple clients by IP:Port combination
  • • Broadcasts received messages to all connected clients
  • • Detects timeouts and removes disconnected clients
  • • Prints connection stats every 5 seconds

Ready for Week 3?

Next week we'll dive into state synchronization, client-side prediction, and keeping all players in sync while handling network issues.

Continue to Week 3: State Synchronization →