Networking Basics
Building Connected Game Experiences
Master the fundamentals of game networking! Learn client-server architecture, protocols, packet handling, latency compensation, and create smooth multiplayer experiences! šš®š
Understanding Game Networking
š The Post Office Analogy
Think of networking like a postal system:
- Packets: Letters containing game data
- IP Address: Your street address
- Port: Your apartment number
- Router: The local post office
- Latency: Mail delivery time
- Packet Loss: Lost mail
- Bandwidth: Size of mailbox
Interactive Network Simulator
Visualize network architectures, packet flow, and latency effects in real-time!
Network Architecture:
Protocol:
Optimizations:
Status: Connected
Ping: 50ms
Uptime: 0s
Sent: 0 packets
Received: 0 packets
Lost: 0 (0%)
Bandwidth: 0 KB/s
Queue: 0 packets
Lag: 0ms
Server Time: 0ms
Client Time: 0ms
Drift: 0ms
Networking Implementation in Python
import socket
import threading
import json
import time
import struct
from typing import Dict, List, Optional, Any, Callable, Set
from dataclasses import dataclass, asdict
from enum import Enum
import queue
class PacketType(Enum):
CONNECT = "connect"
DISCONNECT = "disconnect"
STATE = "state"
INPUT = "input"
EVENT = "event"
PING = "ping"
PONG = "pong"
ACK = "ack"
@dataclass
class Packet:
"""Network packet structure"""
type: PacketType
sequence: int
timestamp: float
data: Dict[str, Any]
reliable: bool = False
def serialize(self) -> bytes:
"""Serialize packet to bytes"""
json_data = json.dumps({
'type': self.type.value,
'seq': self.sequence,
'time': self.timestamp,
'data': self.data,
'rel': self.reliable
})
# Add header with packet size
data = json_data.encode('utf-8')
header = struct.pack('!I', len(data))
return header + data
@staticmethod
def deserialize(data: bytes) -> 'Packet':
"""Deserialize bytes to packet"""
json_data = json.loads(data.decode('utf-8'))
return Packet(
type=PacketType(json_data['type']),
sequence=json_data['seq'],
timestamp=json_data['time'],
data=json_data['data'],
reliable=json_data.get('rel', False)
)
class NetworkManager:
"""Base network manager for client and server"""
def __init__(self, host: str = 'localhost', port: int = 5555) -> None:
self.host: str = host
self.port: int = port
self.socket: Optional[socket.socket] = None
self.running: bool = False
self.sequence_number: int = 0
self.pending_acks: Dict[int, Packet] = {}
self.received_sequences: Set[int] = set()
self.send_queue: queue.Queue = queue.Queue()
self.receive_queue: queue.Queue = queue.Queue()
self.latency: float = 0
self.packet_loss: float = 0
self.callbacks: Dict[PacketType, List[Callable]] = {
packet_type: [] for packet_type in PacketType
}
def register_callback(self, packet_type: PacketType, callback: Callable) -> None:
"""Register callback for packet type"""
self.callbacks[packet_type].append(callback)
def send_packet(self, packet_type: PacketType, data: Dict[str, Any],
reliable: bool = False) -> None:
"""Queue packet for sending"""
packet = Packet(
type=packet_type,
sequence=self.sequence_number,
timestamp=time.time(),
data=data,
reliable=reliable
)
self.sequence_number += 1
if reliable:
self.pending_acks[packet.sequence] = packet
self.send_queue.put(packet)
def process_packet(self, packet: Packet) -> None:
"""Process received packet"""
# Check for duplicates
if packet.sequence in self.received_sequences:
return
self.received_sequences.add(packet.sequence)
# Send ACK for reliable packets
if packet.reliable:
self.send_packet(PacketType.ACK, {'ack_seq': packet.sequence})
# Handle ACK
if packet.type == PacketType.ACK:
ack_seq = packet.data.get('ack_seq')
if ack_seq in self.pending_acks:
del self.pending_acks[ack_seq]
return
# Call registered callbacks
for callback in self.callbacks[packet.type]:
callback(packet)
def calculate_latency(self, ping_time: float) -> float:
"""Calculate round-trip latency"""
return (time.time() - ping_time) * 1000 # Convert to ms
class GameServer(NetworkManager):
"""Game server implementation"""
def __init__(self, host: str = '0.0.0.0', port: int = 5555) -> None:
super().__init__(host, port)
self.clients: Dict[str, Dict[str, Any]] = {}
self.game_state: Dict[str, Dict[str, Any]] = {}
self.tick_rate: int = 60
def start(self) -> None:
"""Start server"""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind((self.host, self.port))
self.socket.listen(5)
self.running = True
print(f"Server listening on {self.host}:{self.port}")
# Start threads
threading.Thread(target=self.accept_clients, daemon=True).start()
threading.Thread(target=self.game_loop, daemon=True).start()
def accept_clients(self) -> None:
"""Accept new client connections"""
while self.running:
try:
client_socket, address = self.socket.accept()
client_id = f"{address[0]}:{address[1]}"
self.clients[client_id] = {
'socket': client_socket,
'address': address,
'state': {},
'last_input': None,
'ping': 0
}
print(f"Client connected: {client_id}")
# Start client handler
threading.Thread(
target=self.handle_client,
args=(client_id,),
daemon=True
).start()
except Exception as e:
print(f"Accept error: {e}")
def handle_client(self, client_id: str) -> None:
"""Handle client communication"""
client = self.clients[client_id]
buffer = b''
while self.running and client_id in self.clients:
try:
# Receive data
data = client['socket'].recv(4096)
if not data:
break
buffer += data
# Process complete packets
while len(buffer) >= 4:
# Read packet size
size = struct.unpack('!I', buffer[:4])[0]
if len(buffer) < 4 + size:
break
# Extract packet
packet_data = buffer[4:4+size]
buffer = buffer[4+size:]
# Process packet
packet = Packet.deserialize(packet_data)
self.process_client_packet(client_id, packet)
except Exception as e:
print(f"Client error {client_id}: {e}")
break
# Client disconnected
self.disconnect_client(client_id)
def process_client_packet(self, client_id: str, packet: Packet) -> None:
"""Process packet from client"""
client = self.clients.get(client_id)
if not client:
return
if packet.type == PacketType.INPUT:
client['last_input'] = packet.data
elif packet.type == PacketType.PING:
# Send pong
self.send_to_client(client_id, PacketType.PONG,
{'ping_time': packet.data['time']})
elif packet.type == PacketType.STATE:
client['state'].update(packet.data)
# Process with base class
self.process_packet(packet)
def send_to_client(self, client_id: str, packet_type: PacketType,
data: Dict[str, Any], reliable: bool = False) -> None:
"""Send packet to specific client"""
client = self.clients.get(client_id)
if not client:
return
packet = Packet(
type=packet_type,
sequence=self.sequence_number,
timestamp=time.time(),
data=data,
reliable=reliable
)
self.sequence_number += 1
try:
client['socket'].send(packet.serialize())
except:
self.disconnect_client(client_id)
def broadcast(self, packet_type: PacketType, data: Dict[str, Any],
exclude: Optional[str] = None) -> None:
"""Broadcast packet to all clients"""
for client_id in list(self.clients.keys()):
if client_id != exclude:
self.send_to_client(client_id, packet_type, data)
def disconnect_client(self, client_id: str) -> None:
"""Disconnect and remove client"""
if client_id in self.clients:
try:
self.clients[client_id]['socket'].close()
except:
pass
del self.clients[client_id]
print(f"Client disconnected: {client_id}")
# Notify other clients
self.broadcast(PacketType.EVENT, {
'event': 'player_disconnected',
'player_id': client_id
})
def game_loop(self) -> None:
"""Main game loop"""
tick_interval = 1.0 / self.tick_rate
last_tick = time.time()
while self.running:
current_time = time.time()
delta_time = current_time - last_tick
if delta_time >= tick_interval:
self.update_game_state(delta_time)
self.send_state_updates()
last_tick = current_time
time.sleep(0.001)
def update_game_state(self, delta_time: float) -> None:
"""Update game state"""
# Process client inputs
for client_id, client in self.clients.items():
if client['last_input']:
# Apply input to game state
self.apply_input(client_id, client['last_input'], delta_time)
client['last_input'] = None
def apply_input(self, client_id: str, input_data: Dict[str, Any],
delta_time: float) -> None:
"""Apply client input to game state"""
# Example: Move player based on input
if client_id not in self.game_state:
self.game_state[client_id] = {
'position': {'x': 0, 'y': 0},
'velocity': {'x': 0, 'y': 0}
}
player = self.game_state[client_id]
# Apply movement
if 'move' in input_data:
player['velocity']['x'] = input_data['move'].get('x', 0) * 100
player['velocity']['y'] = input_data['move'].get('y', 0) * 100
# Update position
player['position']['x'] += player['velocity']['x'] * delta_time
player['position']['y'] += player['velocity']['y'] * delta_time
def send_state_updates(self) -> None:
"""Send game state to all clients"""
state_data = {
'timestamp': time.time(),
'players': self.game_state
}
self.broadcast(PacketType.STATE, state_data)
class GameClient(NetworkManager):
"""Game client implementation"""
def __init__(self) -> None:
super().__init__()
self.connected: bool = False
self.local_state: Dict[str, Any] = {}
self.server_state: Dict[str, Any] = {}
self.input_buffer: List[Dict[str, Any]] = []
def connect(self, host: str, port: int) -> bool:
"""Connect to server"""
self.host = host
self.port = port
try:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.connect((host, port))
self.connected = True
self.running = True
print(f"Connected to server {host}:{port}")
# Start receive thread
threading.Thread(target=self.receive_loop, daemon=True).start()
# Send connect packet
self.send_packet(PacketType.CONNECT, {'name': 'Player'})
return True
except Exception as e:
print(f"Connection failed: {e}")
return False
def receive_loop(self) -> None:
"""Receive packets from server"""
buffer = b''
while self.running and self.connected:
try:
data = self.socket.recv(4096)
if not data:
break
buffer += data
# Process complete packets
while len(buffer) >= 4:
size = struct.unpack('!I', buffer[:4])[0]
if len(buffer) < 4 + size:
break
packet_data = buffer[4:4+size]
buffer = buffer[4+size:]
packet = Packet.deserialize(packet_data)
self.receive_queue.put(packet)
except Exception as e:
print(f"Receive error: {e}")
break
self.disconnect()
def send_input(self, input_data: Dict[str, Any]) -> None:
"""Send input to server"""
self.input_buffer.append(input_data)
self.send_packet(PacketType.INPUT, input_data)
def update(self, delta_time: float) -> None:
"""Update client"""
# Process received packets
while not self.receive_queue.empty():
packet = self.receive_queue.get()
self.process_packet(packet)
if packet.type == PacketType.STATE:
self.server_state = packet.data
self.reconcile_state()
# Send queued packets
while not self.send_queue.empty():
packet = self.send_queue.get()
try:
self.socket.send(packet.serialize())
except:
self.disconnect()
def reconcile_state(self) -> None:
"""Reconcile local and server state"""
# Client-side prediction reconciliation
server_time = self.server_state.get('timestamp', 0)
# Remove old inputs
self.input_buffer = [
inp for inp in self.input_buffer
if inp.get('timestamp', 0) > server_time
]
# Re-apply unacknowledged inputs
for input_data in self.input_buffer:
self.apply_local_input(input_data)
def apply_local_input(self, input_data: Dict[str, Any]) -> None:
"""Apply input locally for prediction"""
# Update local state immediately
if 'move' in input_data:
if 'position' not in self.local_state:
self.local_state['position'] = {'x': 0, 'y': 0}
self.local_state['position']['x'] += input_data['move'].get('x', 0)
self.local_state['position']['y'] += input_data['move'].get('y', 0)
def disconnect(self) -> None:
"""Disconnect from server"""
if self.connected:
self.send_packet(PacketType.DISCONNECT, {})
self.connected = False
self.running = False
try:
self.socket.close()
except:
pass
print("Disconnected from server")
Best Practices
ā” Networking Best Practices
- Minimize Data: Send only what changes
- Prioritize Packets: Critical data first
- Handle Disconnects: Graceful recovery
- Validate Input: Never trust the client
- Interpolate Movement: Smooth visual updates
- Predict Locally: Hide latency with prediction
- Compress Data: Reduce bandwidth usage
- Test Conditions: Simulate poor networks
Key Takeaways
- š„ļø Client-server is most common for games
- š¦ TCP guarantees delivery, UDP is faster
- ā±ļø Latency is the enemy of real-time games
- š® Prediction hides network delays
- šÆ Interpolation smooths movement
- š State synchronization is critical
- š”ļø Always validate on the server
- š Monitor network metrics
šļøāāļø Practice Exercise
šļøāāļø Exercise 1: Connection Scaling Visualizer — Client-Server O(N) vs Full-Mesh O(N²)
Objective: Build a side-by-side topology visualizer that draws client-server (one server, N clients, N edges) and full-mesh peer-to-peer (N peers, N(N−1)/2 edges) for the same player count N, animates per-period packet bursts on every connection so the mesh's quadratic packet load is directly visible, and exposes a live HUD showing N, the two connection counts, and their ratio (N−1)/2 — making the lesson's foundational architectural-choice argument from the network-topology figure (CS scales linearly, mesh scales quadratically, so games default to CS past a handful of peers) directly experienceable as N changes.
Instructions:
- Create an 800×480 pygame window with two side-by-side panels — left half is client-server topology, right half is full-mesh peer-to-peer topology.
- Maintain a single
ninteger for player count (range [2, 12]), with-decrementing and=/+incrementing; both panels redraw onnchange. - CS panel: draw one red server node at the top of the left half +
nblue client nodes arranged in an arc below, with one line from each client to the server (nedges total). - Mesh panel: draw
nmagenta peer nodes evenly in a circle in the right half, with a line from every peer to every other peer (n*(n-1)//2edges total). - Spawn a packet burst every 1.5 seconds: one packet per CS edge (client → server) and one packet per mesh edge (peer i → peer j for j > i). Advance each packet's progress by
dtper frame; cull onprog >= 1.0; render in flight as yellow circles traveling along their edges. - HUD across the bottom shows:
N = {n},Client-Server connections: {cs_count} LINEAR O(N),Full-Mesh connections: {mesh_count} QUADRATIC O(N²),Ratio mesh/CS = (N-1)/2 = {ratio:.1f}x, and live in-flight packet counts. - Verify the connection-count math at three reference Ns: at N=4 CS=4 vs Mesh=6 (ratio 1.5×); at N=8 CS=8 vs Mesh=28 (3.5×); at N=12 CS=12 vs Mesh=66 (5.5×) — the mesh's quadratic explosion is the headline lesson made visceral.
š” Hint
The connection-count formula for a complete graph on N nodes is N*(N-1)//2 — every distinct pair of nodes contributes one edge. For client-server, one node is special (the server), and every other node has exactly one edge to it: N edges total. The two scale separately: CS is O(N), full-mesh is O(N²). At N=16, CS has 16 edges while mesh has 120 — that's why competitive games rarely run full-mesh past ~6 players.
ā Example Solution
import math
import pygame
pygame.init()
W, H = 800, 480
screen = pygame.display.set_mode((W, H))
pygame.display.set_caption('Connection Scaling - CS vs Full Mesh')
font = pygame.font.SysFont('arial', 16)
small = pygame.font.SysFont('arial', 12)
clock = pygame.time.Clock()
n = 4 # player count, range [2, 12]
def cs_layout(n):
server = (200, 80)
clients = []
for i in range(n):
a = (math.pi * 2 / n) * i - math.pi / 2
clients.append((200 + 110 * math.cos(a), 280 + 90 * math.sin(a)))
return server, clients
def mesh_layout(n):
cx, cy = 600, 240
return [(cx + 110 * math.cos((math.pi * 2 / n) * i - math.pi / 2),
cy + 110 * math.sin((math.pi * 2 / n) * i - math.pi / 2))
for i in range(n)]
packets_cs = [] # [client_idx, prog]
packets_mesh = [] # [i, j, prog]
last_burst = -1.5
t = 0.0
running = True
while running:
dt = clock.tick(60) / 1000.0
t += dt
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key in (pygame.K_MINUS, pygame.K_KP_MINUS):
n = max(2, n - 1); packets_cs.clear(); packets_mesh.clear()
elif event.key in (pygame.K_EQUALS, pygame.K_PLUS, pygame.K_KP_PLUS):
n = min(12, n + 1); packets_cs.clear(); packets_mesh.clear()
elif event.key == pygame.K_ESCAPE:
running = False
if t - last_burst >= 1.5:
last_burst = t
for i in range(n):
packets_cs.append([i, 0.0])
for j in range(i + 1, n):
packets_mesh.append([i, j, 0.0])
for p in packets_cs: p[1] += dt
for p in packets_mesh: p[2] += dt
packets_cs[:] = [p for p in packets_cs if p[1] < 1.0]
packets_mesh[:] = [p for p in packets_mesh if p[2] < 1.0]
server, clients = cs_layout(n)
peers = mesh_layout(n)
cs_count = n
mesh_count = n * (n - 1) // 2
ratio = (n - 1) / 2.0
screen.fill((15, 15, 25))
screen.blit(font.render('Client-Server O(N)', True, (200, 220, 255)), (110, 30))
screen.blit(font.render('Full Mesh O(N**2)', True, (255, 200, 220)), (510, 30))
for c in clients:
pygame.draw.line(screen, (80, 160, 240), server, c, 2)
pygame.draw.circle(screen, (240, 80, 80), server, 14)
screen.blit(small.render('SERVER', True, (255, 255, 255)),
(server[0] - 22, server[1] + 18))
for c in clients:
pygame.draw.circle(screen, (80, 160, 240), (int(c[0]), int(c[1])), 9)
for idx, prog in packets_cs:
if idx < len(clients):
c = clients[idx]
x = c[0] + (server[0] - c[0]) * prog
y = c[1] + (server[1] - c[1]) * prog
pygame.draw.circle(screen, (255, 255, 100), (int(x), int(y)), 4)
for i in range(n):
for j in range(i + 1, n):
pygame.draw.line(screen, (220, 100, 200), peers[i], peers[j], 1)
for p in peers:
pygame.draw.circle(screen, (220, 100, 200), (int(p[0]), int(p[1])), 9)
for i, j, prog in packets_mesh:
if i < len(peers) and j < len(peers):
x = peers[i][0] + (peers[j][0] - peers[i][0]) * prog
y = peers[i][1] + (peers[j][1] - peers[i][1]) * prog
pygame.draw.circle(screen, (255, 255, 100), (int(x), int(y)), 4)
hud = [
f'N = {n} (- / + to change, range [2, 12])',
f'Client-Server connections: {cs_count} LINEAR O(N)',
f'Full-Mesh connections: {mesh_count} QUADRATIC O(N**2)',
f'Ratio mesh/CS = (N-1)/2 = {ratio:.1f}x',
f'In-flight packets: CS={len(packets_cs)} Mesh={len(packets_mesh)}',
]
for i, line in enumerate(hud):
screen.blit(font.render(line, True, (220, 220, 220)),
(20, H - 105 + i * 18))
pygame.display.flip()
pygame.quit()
šÆ Quick Quiz
Question 1: A 16-player multiplayer FPS is choosing between client-server (one authoritative host with 16 client connections) and full-mesh peer-to-peer (every player connected directly to every other). Why does virtually every commercial multiplayer FPS pick client-server?
Question 2: TCP guarantees in-order, no-loss delivery; UDP delivers packets as-fast-as-possible with no ordering and no retransmission. Which protocol-choice pattern matches the lesson's TCP/UDP guidance for a real-time competitive shooter?
Question 3: A player has a 100 Mbps fiber connection but consistently 200 ms ping to the game server, and reports the game 'feels laggy'. Which constraint is the headline problem, and what's the lesson's prescribed mitigation?
What's Next?
Now that you understand networking basics, next we'll implement a complete multiplayer game with real-time synchronization!