DEV Community

Cover image for Building Real-Time Multiplayer Games: JavaScript Networking Techniques & Code Examples
Aarav Joshi
Aarav Joshi

Posted on

Building Real-Time Multiplayer Games: JavaScript Networking Techniques & Code Examples

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Online multiplayer gaming has transformed how we play and interact with others in virtual worlds. Creating these experiences requires specialized knowledge of networking, state management, and performance optimization. In this article, I'll explore the essential techniques for building real-time multiplayer games with JavaScript, complete with practical code examples.

WebSocket Communication

WebSockets form the foundation of most real-time multiplayer games, providing full-duplex communication channels between clients and servers.

// Server-side WebSocket setup with ws library const WebSocket = require('ws'); const server = new WebSocket.Server({ port: 8080 }); server.on('connection', (socket) => { console.log('Client connected'); socket.on('message', (message) => { // Parse incoming messages const data = JSON.parse(message); // Broadcast to all clients server.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(data)); } }); }); }); 
Enter fullscreen mode Exit fullscreen mode

For client-side implementation:

// Client-side WebSocket connection const socket = new WebSocket('ws://localhost:8080'); socket.onopen = () => { console.log('Connected to server'); }; socket.onmessage = (event) => { const data = JSON.parse(event.data); // Update game state based on server data updateGameState(data); }; // Send player actions to server function sendPlayerAction(action) { socket.send(JSON.stringify(action)); } 
Enter fullscreen mode Exit fullscreen mode

For bandwidth optimization, consider using binary protocols like Protocol Buffers or MessagePack instead of JSON.

State Synchronization

Keeping game state consistent across clients is one of the biggest challenges. I typically use a combination of techniques:

// Server maintains authoritative state const gameState = { players: {}, projectiles: [], timestamp: 0 }; // Send state updates at fixed intervals setInterval(() => { const stateUpdate = createStateUpdate(); broadcastToClients(stateUpdate); }, 50); // 20 updates per second function createStateUpdate() { // Only include data that has changed return { timestamp: Date.now(), players: getChangedPlayerData(), projectiles: gameState.projectiles }; } 
Enter fullscreen mode Exit fullscreen mode

On the client side, interpolation helps create smooth transitions between state updates:

let previousState = null; let currentState = null; let interpolationFactor = 0; function processStateUpdate(update) { previousState = currentState; currentState = update; interpolationFactor = 0; } function renderGameState(deltaTime) { if (!previousState || !currentState) return; // Advance interpolation factor interpolationFactor = Math.min(interpolationFactor + deltaTime / INTERPOLATION_PERIOD, 1); // Interpolate between states for (const playerId in currentState.players) { const prevPos = previousState.players[playerId]?.position || currentState.players[playerId].position; const currentPos = currentState.players[playerId].position; // Linear interpolation between positions const renderPos = { x: prevPos.x + (currentPos.x - prevPos.x) * interpolationFactor, y: prevPos.y + (currentPos.y - prevPos.y) * interpolationFactor }; renderPlayer(playerId, renderPos); } } 
Enter fullscreen mode Exit fullscreen mode

Input Prediction

To create responsive gameplay even with network latency, I implement client-side prediction:

// Client-side input handling with prediction const pendingInputs = []; let lastProcessedInput = 0; function processInput(input) { // Apply input locally immediately applyInput(localPlayerState, input); // Remember this input for later reconciliation input.sequence = ++lastProcessedInput; pendingInputs.push(input); // Send to server socket.send(JSON.stringify({ type: 'player_input', input: input })); } function applyInput(playerState, input) { // Move player based on input if (input.up) playerState.y -= playerState.speed; if (input.down) playerState.y += playerState.speed; if (input.left) playerState.x -= playerState.speed; if (input.right) playerState.x += playerState.speed; } // When server state arrives, reconcile differences function handleServerUpdate(serverState) { // Update local state with server authoritative data localPlayerState = serverState.players[myPlayerId]; // Remove inputs that server has already processed pendingInputs = pendingInputs.filter(input => input.sequence > serverState.lastProcessedInput ); // Re-apply remaining inputs pendingInputs.forEach(input => { applyInput(localPlayerState, input); }); } 
Enter fullscreen mode Exit fullscreen mode

Collision Detection

Efficient collision detection is crucial for multiplayer games. Spatial partitioning techniques like quad trees can significantly improve performance:

class QuadTree { constructor(boundary, capacity) { this.boundary = boundary; this.capacity = capacity; this.entities = []; this.divided = false; this.children = []; } insert(entity) { if (!this.boundary.contains(entity)) return false; if (this.entities.length < this.capacity && !this.divided) { this.entities.push(entity); return true; } if (!this.divided) this.subdivide(); for (const child of this.children) { if (child.insert(entity)) return true; } } subdivide() { // Create four children quadrants const x = this.boundary.x; const y = this.boundary.y; const w = this.boundary.width / 2; const h = this.boundary.height / 2; this.children = [ new QuadTree(new Rect(x, y, w, h), this.capacity), new QuadTree(new Rect(x + w, y, w, h), this.capacity), new QuadTree(new Rect(x, y + h, w, h), this.capacity), new QuadTree(new Rect(x + w, y + h, w, h), this.capacity) ]; this.divided = true; // Move existing entities to children for (const entity of this.entities) { this.insert(entity); } this.entities = []; } query(range, found = []) { if (!this.boundary.intersects(range)) return found; for (const entity of this.entities) { if (range.contains(entity)) found.push(entity); } if (this.divided) { for (const child of this.children) { child.query(range, found); } } return found; } } 
Enter fullscreen mode Exit fullscreen mode

For server-side collision detection:

function detectCollisions() { const quadTree = new QuadTree(new Rect(0, 0, worldWidth, worldHeight), 10); // Insert all entities into quad tree for (const entity of allEntities) { quadTree.insert(entity); } const collisions = []; // Check collisions for each entity for (const entity of allEntities) { // Create a range slightly larger than the entity const range = new Rect( entity.x - entity.radius, entity.y - entity.radius, entity.radius * 2, entity.radius * 2 ); // Find potential collision candidates const candidates = quadTree.query(range); // Check actual collisions for (const other of candidates) { if (entity === other) continue; if (checkCollision(entity, other)) { collisions.push([entity, other]); } } } return collisions; } 
Enter fullscreen mode Exit fullscreen mode

Authority Delegation

I've found the best approach is using an authoritative server model while giving clients limited authority for non-critical elements:

// Server-side: Authoritative movement validation function validateMovement(playerId, newPosition) { const player = players[playerId]; const lastPosition = player.position; // Calculate maximum possible distance based on time and speed const elapsedTime = Date.now() - player.lastUpdateTime; const maxDistance = player.speed * elapsedTime / 1000; // Calculate actual distance moved const distanceMoved = distance(lastPosition, newPosition); if (distanceMoved > maxDistance * 1.1) { // 10% tolerance console.log(`Invalid movement detected for player ${playerId}`); // Correct the player's position return false; } return true; } // Client-side: Handle local effects and animations function handleNonCriticalEffects(action) { // Client can play sounds, create particles, etc. if (action.type === 'jump') { playJumpSound(); createDustParticles(action.position); } } 
Enter fullscreen mode Exit fullscreen mode

Room Management

Managing game rooms efficiently is essential for matchmaking and scaling:

class GameRoomManager { constructor() { this.rooms = new Map(); this.playerToRoom = new Map(); } createRoom(options = {}) { const roomId = generateUniqueId(); const room = { id: roomId, players: new Map(), state: 'waiting', // waiting, playing, ended maxPlayers: options.maxPlayers || 8, gameOptions: options }; this.rooms.set(roomId, room); return roomId; } joinRoom(roomId, player) { const room = this.rooms.get(roomId); if (!room) { throw new Error('Room not found'); } if (room.state !== 'waiting') { throw new Error('Cannot join a game in progress'); } if (room.players.size >= room.maxPlayers) { throw new Error('Room is full'); } room.players.set(player.id, player); this.playerToRoom.set(player.id, roomId); // Notify other players about new joinee this.broadcastToRoom(roomId, { type: 'player_joined', playerId: player.id, playerInfo: player.publicInfo }); // Check if room is full and should start if (room.players.size === room.maxPlayers) { this.startGame(roomId); } return room; } leaveRoom(playerId) { const roomId = this.playerToRoom.get(playerId); if (!roomId) return; const room = this.rooms.get(roomId); room.players.delete(playerId); this.playerToRoom.delete(playerId); // Notify others this.broadcastToRoom(roomId, { type: 'player_left', playerId: playerId }); // Clean up empty rooms if (room.players.size === 0) { this.rooms.delete(roomId); } } broadcastToRoom(roomId, message) { const room = this.rooms.get(roomId); if (!room) return; for (const player of room.players.values()) { player.send(message); } } startGame(roomId) { const room = this.rooms.get(roomId); if (!room) return; room.state = 'playing'; room.startTime = Date.now(); // Initialize game state const initialGameState = createInitialGameState(room); // Notify all players game is starting this.broadcastToRoom(roomId, { type: 'game_started', initialState: initialGameState }); // Start game loop for this room startGameLoop(roomId); } } 
Enter fullscreen mode Exit fullscreen mode

Delta Compression

Reducing bandwidth usage is critical for multiplayer games. Delta compression dramatically reduces the data sent over the network:

// Server-side: Send only what changed let lastSentState = {}; function createDeltaUpdate() { const currentState = getFullGameState(); const delta = {}; let hasChanges = false; // Compare with last sent state for (const playerId in currentState.players) { if (!lastSentState.players?.[playerId] || hasPlayerChanged(currentState.players[playerId], lastSentState.players[playerId])) { if (!delta.players) delta.players = {}; delta.players[playerId] = currentState.players[playerId]; hasChanges = true; } } // Add removed players for (const playerId in lastSentState.players || {}) { if (!currentState.players[playerId]) { if (!delta.removedPlayers) delta.removedPlayers = []; delta.removedPlayers.push(playerId); hasChanges = true; } } // Same for other game objects... lastSentState = JSON.parse(JSON.stringify(currentState)); // Deep copy // Add timestamp delta.timestamp = Date.now(); return hasChanges ? delta : null; } // Client-side: Apply delta updates function applyDeltaUpdate(delta) { // Update players if (delta.players) { for (const playerId in delta.players) { gameState.players[playerId] = { ...gameState.players[playerId], ...delta.players[playerId] }; } } // Remove players if (delta.removedPlayers) { for (const playerId of delta.removedPlayers) { delete gameState.players[playerId]; } } // ... handle other game objects gameState.timestamp = delta.timestamp; } 
Enter fullscreen mode Exit fullscreen mode

Lag Compensation

To create a fair experience despite varying connection qualities, I implement lag compensation techniques:

// Server-side: Store position history for lag compensation const HISTORY_LENGTH = 1000; // ms class Player { constructor(id) { this.id = id; this.positionHistory = []; // Other player properties } updatePosition(position, timestamp) { this.currentPosition = position; // Record position in history this.positionHistory.push({ position: {...position}, timestamp }); // Remove old history entries const cutoffTime = timestamp - HISTORY_LENGTH; while (this.positionHistory.length > 0 && this.positionHistory[0].timestamp < cutoffTime) { this.positionHistory.shift(); } } getPositionAt(timestamp) { // Find position at or just before the requested time for (let i = this.positionHistory.length - 1; i >= 0; i--) { if (this.positionHistory[i].timestamp <= timestamp) { return this.positionHistory[i].position; } } // Fall back to oldest position if nothing found return this.positionHistory.length > 0 ? this.positionHistory[0].position : this.currentPosition; } } // When processing a shot from a player with latency function processShot(playerId, shotData) { const shooter = players.get(playerId); // Calculate timestamp when the shot actually happened on client const clientTimestamp = shotData.timestamp; const latency = playerLatencies.get(playerId) || 0; const serverTimestamp = Date.now(); const adjustedTimestamp = serverTimestamp - latency; // Get positions of all players at that time const playerPositions = new Map(); for (const [id, player] of players) { playerPositions.set(id, player.getPositionAt(adjustedTimestamp)); } // Check for hits using historical positions const hits = calculateHits(shotData, playerPositions); // Process hits for (const hitPlayerId of hits) { damagePlayer(hitPlayerId, shotData.damage); } } 
Enter fullscreen mode Exit fullscreen mode

Network Monitoring

Adapting to varying network conditions improves the player experience:

// Client-side network quality monitoring class NetworkMonitor { constructor() { this.pingSamples = []; this.maxSamples = 10; this.lastPingTime = 0; this.averagePing = 0; this.packetLoss = 0; this.jitter = 0; this.qualityLevel = 'unknown'; } sendPing() { this.lastPingTime = performance.now(); socket.send(JSON.stringify({ type: 'ping', timestamp: this.lastPingTime })); } receivePong(serverTimestamp) { const now = performance.now(); const rtt = now - this.lastPingTime; // Add to ping samples this.pingSamples.push(rtt); if (this.pingSamples.length > this.maxSamples) { this.pingSamples.shift(); } // Calculate average ping this.averagePing = this.pingSamples.reduce((sum, ping) => sum + ping, 0) / this.pingSamples.length; // Calculate jitter if (this.pingSamples.length > 1) { let jitterSum = 0; for (let i = 1; i < this.pingSamples.length; i++) { jitterSum += Math.abs(this.pingSamples[i] - this.pingSamples[i-1]); } this.jitter = jitterSum / (this.pingSamples.length - 1); } // Determine quality level this.updateQualityLevel(); // Adapt game settings based on quality this.adaptGameSettings(); } updateQualityLevel() { if (this.averagePing < 50 && this.jitter < 10) { this.qualityLevel = 'excellent'; } else if (this.averagePing < 100 && this.jitter < 20) { this.qualityLevel = 'good'; } else if (this.averagePing < 200 && this.jitter < 50) { this.qualityLevel = 'fair'; } else { this.qualityLevel = 'poor'; } } adaptGameSettings() { // Adapt prediction time predictionFactor = Math.min(1.0, this.averagePing / 100); // Adapt interpolation buffer INTERPOLATION_PERIOD = Math.max(50, this.averagePing * 1.5); // Adapt update rate if (this.qualityLevel === 'poor') { clientUpdateRate = 10; // 10 updates per second } else if (this.qualityLevel === 'fair') { clientUpdateRate = 20; } else { clientUpdateRate = 30; } } } 
Enter fullscreen mode Exit fullscreen mode

Security Measures

Preventing cheating is crucial for a fair multiplayer experience:

// Server-side: Validate all client actions function validateAction(playerId, action) { const player = players.get(playerId); // Validate action is possible if (action.type === 'shoot') { // Check weapon cooldown if (player.lastShotTime && Date.now() - player.lastShotTime < player.weapon.cooldown) { console.log(`Rejected rapid fire from ${playerId}`); return false; } // Check ammo if (player.ammo <= 0) { console.log(`Rejected no-ammo shot from ${playerId}`); return false; } // Update player state player.lastShotTime = Date.now(); player.ammo--; return true; } // Add validation for other action types return true; } // Client-side: JWT authentication async function authenticatePlayer(username, password) { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) { throw new Error('Authentication failed'); } const { token } = await response.json(); // Store token localStorage.setItem('gameAuthToken', token); // Use token for WebSocket authentication const socket = new WebSocket(`ws://game.example.com/socket?token=${token}`); return socket; } // For message encryption, add a layer of encryption on top of WebSocket function sendEncryptedMessage(socket, message) { const messageString = JSON.stringify(message); const encryptedMessage = CryptoJS.AES.encrypt(messageString, SECRET_KEY).toString(); socket.send(encryptedMessage); } function decryptMessage(encryptedMessage) { const bytes = CryptoJS.AES.decrypt(encryptedMessage, SECRET_KEY); const decryptedString = bytes.toString(CryptoJS.enc.Utf8); return JSON.parse(decryptedString); } 
Enter fullscreen mode Exit fullscreen mode

Building real-time multiplayer games with JavaScript is complex but rewarding. The techniques covered here—WebSockets, state synchronization, input prediction, collision detection, authority delegation, room management, delta compression, lag compensation, network monitoring, and security measures—provide a solid foundation for creating engaging multiplayer experiences.

I've found that successful multiplayer game development requires constant iteration and fine-tuning. Network conditions vary wildly, and player behaviors are unpredictable. The best games adapt to these challenges while maintaining consistent, fair gameplay.

By implementing these techniques appropriately for your specific game requirements, you can create multiplayer experiences that feel responsive and fair regardless of network conditions.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)