WebSockets & Real-Time Apps

Real-Time Gaming Concepts

20 min Lesson 29 of 35

Real-Time Gaming Concepts

Real-time multiplayer gaming requires specialized networking techniques to provide smooth, responsive gameplay despite network latency. In this lesson, we'll explore essential concepts for building multiplayer games.

Game Loop Architecture

The game loop is the core of any real-time game, coordinating updates, rendering, and network synchronization.

// Client-side game loop class GameClient { constructor() { this.lastUpdateTime = Date.now(); this.targetFPS = 60; this.frameTime = 1000 / this.targetFPS; this.isRunning = false; this.gameState = {}; } start() { this.isRunning = true; this.lastUpdateTime = Date.now(); this.loop(); } loop() { if (!this.isRunning) return; const now = Date.now(); const deltaTime = now - this.lastUpdateTime; // Fixed timestep for physics if (deltaTime >= this.frameTime) { this.update(deltaTime / 1000); // Convert to seconds this.render(); this.lastUpdateTime = now - (deltaTime % this.frameTime); } requestAnimationFrame(() => this.loop()); } update(deltaTime) { // Update game logic this.updatePlayerInput(deltaTime); this.updatePhysics(deltaTime); this.interpolateEntities(deltaTime); this.sendInputToServer(); } render() { // Render current game state this.clearCanvas(); this.renderEntities(); this.renderUI(); } stop() { this.isRunning = false; } } // Server-side game loop class GameServer { constructor() { this.tickRate = 30; // Server updates per second this.tickInterval = 1000 / this.tickRate; this.lastTick = Date.now(); this.gameState = { players: new Map(), projectiles: [], timestamp: Date.now() }; } start() { setInterval(() => this.tick(), this.tickInterval); } tick() { const now = Date.now(); const deltaTime = (now - this.lastTick) / 1000; // Process player inputs this.processInputs(); // Update game state this.updatePhysics(deltaTime); this.detectCollisions(); this.updateGameLogic(deltaTime); // Broadcast state to all clients this.broadcastState(); this.lastTick = now; } processInputs() { for (const [playerId, player] of this.gameState.players) { while (player.inputQueue.length > 0) { const input = player.inputQueue.shift(); this.applyInput(player, input); } } } applyInput(player, input) { // Validate and apply player input if (input.action === 'move') { player.velocity.x = input.direction.x * player.speed; player.velocity.y = input.direction.y * player.speed; player.lastProcessedInput = input.sequenceNumber; } } broadcastState() { const state = { timestamp: Date.now(), players: Array.from(this.gameState.players.entries()).map( ([id, player]) => ({ id, position: player.position, rotation: player.rotation, lastProcessedInput: player.lastProcessedInput }) ) }; io.emit('game:state', state); } }

Client-Side Prediction

Client-side prediction allows players to see immediate feedback for their actions, reducing perceived latency.

// Client-side prediction implementation class PredictivePlayer { constructor(socket) { this.socket = socket; this.position = { x: 0, y: 0 }; this.velocity = { x: 0, y: 0 }; this.speed = 200; // Input tracking this.inputSequenceNumber = 0; this.pendingInputs = []; this.lastProcessedInput = 0; this.setupNetworkHandlers(); } setupNetworkHandlers() { // Receive authoritative state from server this.socket.on('game:state', (state) => { this.reconcile(state); }); } handleInput(input) { // Assign sequence number input.sequenceNumber = ++this.inputSequenceNumber; input.timestamp = Date.now(); // Store for reconciliation this.pendingInputs.push(input); // Apply input immediately (prediction) this.applyInput(input); // Send to server this.socket.emit('game:input', input); } applyInput(input) { if (input.action === 'move') { const deltaTime = 1 / 60; // Assume 60 FPS this.velocity.x = input.direction.x * this.speed; this.velocity.y = input.direction.y * this.speed; this.position.x += this.velocity.x * deltaTime; this.position.y += this.velocity.y * deltaTime; } } reconcile(serverState) { // Find our player in server state const serverPlayer = serverState.players.find( p => p.id === this.socket.id ); if (!serverPlayer) return; // Update last processed input this.lastProcessedInput = serverPlayer.lastProcessedInput; // Set position to server's authoritative position this.position = { ...serverPlayer.position }; // Remove processed inputs this.pendingInputs = this.pendingInputs.filter( input => input.sequenceNumber > this.lastProcessedInput ); // Re-apply pending inputs for (const input of this.pendingInputs) { this.applyInput(input); } } update(deltaTime) { // Update position based on velocity this.position.x += this.velocity.x * deltaTime; this.position.y += this.velocity.y * deltaTime; } } // Usage const player = new PredictivePlayer(socket); // Handle keyboard input document.addEventListener('keydown', (e) => { const input = { action: 'move', direction: getDirectionFromKey(e.key), timestamp: Date.now() }; player.handleInput(input); });
Prediction Benefits: Client-side prediction eliminates input lag, making games feel responsive even with 100-150ms latency. Players see their actions immediately while the server validates them.

Server Reconciliation

Server reconciliation corrects client predictions when they diverge from the authoritative server state.

// Advanced reconciliation with smoothing class ReconciledEntity { constructor() { this.position = { x: 0, y: 0 }; this.serverPosition = { x: 0, y: 0 }; this.renderPosition = { x: 0, y: 0 }; this.reconciliationThreshold = 10; // pixels this.smoothingFactor = 0.2; } reconcileWithServer(serverPosition) { const distance = Math.hypot( serverPosition.x - this.position.x, serverPosition.y - this.position.y ); if (distance > this.reconciliationThreshold) { // Significant mismatch - snap to server position console.warn(`Reconciliation: distance=${distance.toFixed(2)}px`); // Smooth correction over several frames this.serverPosition = { ...serverPosition }; } } update(deltaTime) { // Smoothly interpolate towards server position if (this.serverPosition) { this.position.x += (this.serverPosition.x - this.position.x) * this.smoothingFactor; this.position.y += (this.serverPosition.y - this.position.y) * this.smoothingFactor; // Clear server position when close enough const distance = Math.hypot( this.serverPosition.x - this.position.x, this.serverPosition.y - this.position.y ); if (distance < 1) { this.serverPosition = null; } } } getRenderPosition() { return this.position; } }

Lag Compensation

Lag compensation techniques ensure fair gameplay by accounting for network latency in hit detection and interactions.

// Server-side lag compensation for shooting class LagCompensation { constructor() { this.historyDuration = 1000; // 1 second of history this.playerHistory = new Map(); // playerId -> position history } recordPlayerPosition(playerId, position, timestamp) { if (!this.playerHistory.has(playerId)) { this.playerHistory.set(playerId, []); } const history = this.playerHistory.get(playerId); history.push({ position, timestamp }); // Keep only recent history const cutoff = timestamp - this.historyDuration; const validHistory = history.filter(h => h.timestamp > cutoff); this.playerHistory.set(playerId, validHistory); } getHistoricalPosition(playerId, timestamp) { const history = this.playerHistory.get(playerId); if (!history || history.length === 0) return null; // Find position at requested timestamp for (let i = 0; i < history.length - 1; i++) { const current = history[i]; const next = history[i + 1]; if (timestamp >= current.timestamp && timestamp <= next.timestamp) { // Interpolate between positions const t = (timestamp - current.timestamp) / (next.timestamp - current.timestamp); return { x: current.position.x + (next.position.x - current.position.x) * t, y: current.position.y + (next.position.y - current.position.y) * t }; } } return history[history.length - 1].position; } handleShot(shooterId, targetId, clientTimestamp, shotPosition) { const shooterLatency = this.getPlayerLatency(shooterId); // Rewind time to when shot was fired on shooter's screen const historicalTimestamp = Date.now() - shooterLatency; // Get target's position at that historical time const targetPosition = this.getHistoricalPosition( targetId, historicalTimestamp ); if (!targetPosition) return false; // Check if shot hit target at historical position const distance = Math.hypot( shotPosition.x - targetPosition.x, shotPosition.y - targetPosition.y ); const hitRadius = 20; // Collision radius return distance <= hitRadius; } getPlayerLatency(playerId) { // Track average latency for each player const player = this.getPlayer(playerId); return player ? player.averageLatency : 100; } } // Usage on server const lagComp = new LagCompensation(); io.on('connection', (socket) => { socket.on('game:shoot', (data) => { const hit = lagComp.handleShot( socket.id, data.targetId, data.clientTimestamp, data.position ); if (hit) { io.emit('game:hit', { shooterId: socket.id, targetId: data.targetId }); } }); });
Lag Compensation Trade-offs: While lag compensation improves fairness for high-latency players, it can cause "shot behind walls" situations. Balance compensation with anti-cheat measures and reasonable latency limits.

Interpolation

Entity interpolation smooths movement between server updates, creating fluid animation despite low update rates.

// Entity interpolation for smooth movement class InterpolatedEntity { constructor() { this.positionBuffer = []; this.renderDelay = 100; // Delay rendering by 100ms this.position = { x: 0, y: 0 }; } addServerUpdate(position, timestamp) { this.positionBuffer.push({ position, timestamp }); // Keep buffer size reasonable if (this.positionBuffer.length > 60) { this.positionBuffer.shift(); } } update() { const now = Date.now(); const renderTime = now - this.renderDelay; // Find two positions to interpolate between let previous = null; let next = null; for (let i = 0; i < this.positionBuffer.length - 1; i++) { if (this.positionBuffer[i].timestamp <= renderTime && this.positionBuffer[i + 1].timestamp >= renderTime) { previous = this.positionBuffer[i]; next = this.positionBuffer[i + 1]; break; } } if (!previous || !next) { // Use latest position if no interpolation possible if (this.positionBuffer.length > 0) { const latest = this.positionBuffer[this.positionBuffer.length - 1]; this.position = { ...latest.position }; } return; } // Interpolate between positions const totalTime = next.timestamp - previous.timestamp; const elapsedTime = renderTime - previous.timestamp; const t = totalTime > 0 ? elapsedTime / totalTime : 0; this.position.x = previous.position.x + (next.position.x - previous.position.x) * t; this.position.y = previous.position.y + (next.position.y - previous.position.y) * t; // Clean up old positions this.positionBuffer = this.positionBuffer.filter( p => p.timestamp > renderTime - 1000 ); } getRenderPosition() { return this.position; } } // Usage const entities = new Map(); socket.on('game:state', (state) => { state.players.forEach(playerData => { if (!entities.has(playerData.id)) { entities.set(playerData.id, new InterpolatedEntity()); } const entity = entities.get(playerData.id); entity.addServerUpdate(playerData.position, state.timestamp); }); }); // In game loop function render() { entities.forEach(entity => { entity.update(); const pos = entity.getRenderPosition(); drawPlayer(pos.x, pos.y); }); }

State Synchronization

Efficient state synchronization minimizes bandwidth while keeping all clients updated.

// Delta compression for state updates class StateSync { constructor() { this.lastSentState = new Map(); } createDeltaUpdate(fullState, clientId) { const lastState = this.lastSentState.get(clientId); if (!lastState) { // First update - send full state this.lastSentState.set(clientId, this.cloneState(fullState)); return { type: 'full', data: fullState }; } // Calculate delta const delta = { type: 'delta', added: [], updated: [], removed: [] }; // Find added and updated entities fullState.entities.forEach((entity, id) => { const lastEntity = lastState.entities.get(id); if (!lastEntity) { delta.added.push({ id, ...entity }); } else if (this.hasChanged(entity, lastEntity)) { delta.updated.push({ id, ...this.getDifference(entity, lastEntity) }); } }); // Find removed entities lastState.entities.forEach((entity, id) => { if (!fullState.entities.has(id)) { delta.removed.push(id); } }); // Update last sent state this.lastSentState.set(clientId, this.cloneState(fullState)); return delta; } hasChanged(entity1, entity2) { return entity1.position.x !== entity2.position.x || entity1.position.y !== entity2.position.y || entity1.rotation !== entity2.rotation || entity1.health !== entity2.health; } getDifference(entity, lastEntity) { const diff = {}; if (entity.position.x !== lastEntity.position.x || entity.position.y !== lastEntity.position.y) { diff.position = entity.position; } if (entity.rotation !== lastEntity.rotation) { diff.rotation = entity.rotation; } if (entity.health !== lastEntity.health) { diff.health = entity.health; } return diff; } cloneState(state) { return { entities: new Map(state.entities), timestamp: state.timestamp }; } } // Server usage const stateSync = new StateSync(); function broadcastState() { const fullState = getCurrentGameState(); io.sockets.sockets.forEach((socket) => { const update = stateSync.createDeltaUpdate(fullState, socket.id); // Delta updates are typically 80-95% smaller than full state socket.emit('game:state', update); }); }
Networking Best Practices:
  • Update rate: 20-30 Hz for most games, 60+ Hz for competitive FPS
  • Use unreliable transport (UDP-like) for frequent position updates
  • Use reliable transport for critical events (damage, scoring)
  • Implement client-side prediction for local player only
  • Interpolate other players 100-200ms in the past
  • Use delta compression to reduce bandwidth
Practice Exercise:
  1. Implement a complete client-side prediction system with server reconciliation for a simple top-down movement game
  2. Create an interpolation system that smoothly renders other players between server updates
  3. Build a lag compensation system for a shooting game that rewinds time for hit detection
  4. Implement delta compression to reduce state update bandwidth by 80%
  5. Create a latency display that shows ping and packet loss for debugging