WebSockets & Real-Time Apps

Building a Multiplayer Game Project

20 min Lesson 34 of 35

Building a Multiplayer Game Project

In this lesson, we'll build a real-time multiplayer game using WebSockets. We'll create a simple but complete game that demonstrates player synchronization, room management, and real-time game state updates.

Game Architecture

Our multiplayer game will include:

Game Features:
  • Player authentication and unique identifiers
  • Game room creation and joining
  • Real-time player movement synchronization
  • Collision detection and score tracking
  • Game state management (waiting, playing, game over)
  • Spectator mode for observers

Server Setup with Game Logic

Let's create the game server with Socket.io:

const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: '*', methods: ['GET', 'POST'] } }); // Game state management const games = new Map(); const players = new Map(); class Game { constructor(roomId, maxPlayers = 4) { this.roomId = roomId; this.maxPlayers = maxPlayers; this.players = new Map(); this.state = 'waiting'; // waiting, playing, finished this.startTime = null; this.gameLoop = null; this.collectibles = []; this.scores = new Map(); } addPlayer(playerId, playerData) { if (this.players.size >= this.maxPlayers) { return { success: false, error: 'Room is full' }; } this.players.set(playerId, { id: playerId, name: playerData.name, x: Math.random() * 800, y: Math.random() * 600, color: this.getRandomColor(), score: 0, isReady: false }); this.scores.set(playerId, 0); return { success: true }; } removePlayer(playerId) { this.players.delete(playerId); this.scores.delete(playerId); if (this.players.size === 0) { this.stop(); } } getRandomColor() { const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8']; return colors[Math.floor(Math.random() * colors.length)]; } setPlayerReady(playerId) { const player = this.players.get(playerId); if (player) { player.isReady = true; // Start game if all players are ready const allReady = Array.from(this.players.values()).every(p => p.isReady); if (allReady && this.players.size >= 2) { this.start(); } } } start() { if (this.state === 'playing') return; this.state = 'playing'; this.startTime = Date.now(); this.spawnCollectibles(); // Game loop - update every 16ms (~60 FPS) this.gameLoop = setInterval(() => { this.update(); }, 16); } spawnCollectibles() { this.collectibles = []; for (let i = 0; i < 10; i++) { this.collectibles.push({ id: i, x: Math.random() * 800, y: Math.random() * 600, type: Math.random() > 0.7 ? 'bonus' : 'normal', value: Math.random() > 0.7 ? 10 : 5 }); } } update() { // Check for collectible collisions this.players.forEach((player, playerId) => { this.collectibles = this.collectibles.filter(collectible => { const distance = Math.sqrt( Math.pow(player.x - collectible.x, 2) + Math.pow(player.y - collectible.y, 2) ); if (distance < 30) { // Player collected item const currentScore = this.scores.get(playerId) || 0; this.scores.set(playerId, currentScore + collectible.value); player.score = this.scores.get(playerId); return false; // Remove collectible } return true; }); }); // Spawn new collectibles if needed if (this.collectibles.length < 5) { this.collectibles.push({ id: Date.now(), x: Math.random() * 800, y: Math.random() * 600, type: Math.random() > 0.7 ? 'bonus' : 'normal', value: Math.random() > 0.7 ? 10 : 5 }); } // Check game end condition (2 minutes) const elapsed = Date.now() - this.startTime; if (elapsed > 120000) { this.finish(); } } updatePlayerPosition(playerId, x, y) { const player = this.players.get(playerId); if (player) { player.x = Math.max(0, Math.min(800, x)); player.y = Math.max(0, Math.min(600, y)); } } finish() { this.state = 'finished'; if (this.gameLoop) { clearInterval(this.gameLoop); this.gameLoop = null; } // Determine winner let winner = null; let maxScore = 0; this.scores.forEach((score, playerId) => { if (score > maxScore) { maxScore = score; winner = this.players.get(playerId); } }); return { winner: winner ? winner.name : 'No winner', scores: Array.from(this.scores.entries()).map(([id, score]) => ({ player: this.players.get(id).name, score })) }; } stop() { if (this.gameLoop) { clearInterval(this.gameLoop); } games.delete(this.roomId); } getState() { return { roomId: this.roomId, state: this.state, players: Array.from(this.players.values()), collectibles: this.collectibles, scores: Array.from(this.scores.entries()).map(([id, score]) => ({ playerId: id, playerName: this.players.get(id)?.name, score })) }; } } // Socket.io connection handling io.on('connection', (socket) => { console.log('Player connected:', socket.id); // Create or join game room socket.on('game:join', (data) => { const { roomId, playerName } = data; let game = games.get(roomId); if (!game) { game = new Game(roomId); games.set(roomId, game); } const result = game.addPlayer(socket.id, { name: playerName }); if (result.success) { socket.join(roomId); players.set(socket.id, roomId); socket.emit('game:joined', { success: true, playerId: socket.id, gameState: game.getState() }); // Notify all players in room io.to(roomId).emit('game:playerJoined', { playerId: socket.id, playerName, gameState: game.getState() }); } else { socket.emit('game:joined', { success: false, error: result.error }); } }); // Player ready socket.on('game:ready', () => { const roomId = players.get(socket.id); const game = games.get(roomId); if (game) { game.setPlayerReady(socket.id); io.to(roomId).emit('game:stateUpdate', game.getState()); if (game.state === 'playing') { io.to(roomId).emit('game:started'); } } }); // Player movement socket.on('game:move', (data) => { const roomId = players.get(socket.id); const game = games.get(roomId); if (game && game.state === 'playing') { game.updatePlayerPosition(socket.id, data.x, data.y); // Broadcast to other players socket.to(roomId).emit('game:playerMoved', { playerId: socket.id, x: data.x, y: data.y }); } }); // Game state updates setInterval(() => { const roomId = players.get(socket.id); const game = games.get(roomId); if (game && game.state === 'playing') { io.to(roomId).emit('game:stateUpdate', game.getState()); } }, 100); // Update 10 times per second // Disconnect socket.on('disconnect', () => { const roomId = players.get(socket.id); const game = games.get(roomId); if (game) { game.removePlayer(socket.id); io.to(roomId).emit('game:playerLeft', { playerId: socket.id, gameState: game.getState() }); } players.delete(socket.id); console.log('Player disconnected:', socket.id); }); }); server.listen(3000, () => { console.log('Game server running on port 3000'); });

Client Game Interface

Create the client-side game interface with HTML5 Canvas:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Multiplayer Game</title> <style> body { margin: 0; padding: 20px; background: #1a1a2e; color: #fff; font-family: Arial, sans-serif; } #gameCanvas { border: 3px solid #4ECDC4; background: #16213e; display: block; margin: 20px auto; } #gameInfo { text-align: center; margin-bottom: 20px; } .score-board { display: flex; justify-content: center; gap: 20px; margin-top: 20px; } .player-score { background: #0f3460; padding: 10px 20px; border-radius: 8px; } button { background: #4ECDC4; color: #fff; border: none; padding: 10px 20px; font-size: 16px; cursor: pointer; border-radius: 5px; } button:hover { background: #45B7D1; } </style> </head> <body> <div id="gameInfo"> <h1>Multiplayer Collection Game</h1> <div id="status">Connecting...</div> <button id="readyBtn" style="display:none;">Ready to Play</button> </div> <canvas id="gameCanvas" width="800" height="600"></canvas> <div class="score-board" id="scoreBoard"></div> <script src="/socket.io/socket.io.js"></script> <script> const socket = io('http://localhost:3000'); const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const statusDiv = document.getElementById('status'); const readyBtn = document.getElementById('readyBtn'); const scoreBoard = document.getElementById('scoreBoard'); let myPlayerId = null; let gameState = null; let keys = {}; // Get player name const playerName = prompt('Enter your name:') || 'Player'; const roomId = prompt('Enter room ID:') || 'room1'; // Join game socket.emit('game:join', { roomId, playerName }); // Event handlers socket.on('game:joined', (data) => { if (data.success) { myPlayerId = data.playerId; gameState = data.gameState; statusDiv.textContent = 'Waiting for players...'; readyBtn.style.display = 'block'; } else { statusDiv.textContent = 'Error: ' + data.error; } }); socket.on('game:playerJoined', (data) => { gameState = data.gameState; updateScoreBoard(); }); socket.on('game:started', () => { statusDiv.textContent = 'Game Started! Collect items to score!'; readyBtn.style.display = 'none'; }); socket.on('game:stateUpdate', (state) => { gameState = state; updateScoreBoard(); if (state.state === 'finished') { const results = state.scores.sort((a, b) => b.score - a.score); statusDiv.textContent = \`Game Over! Winner: ${results[0]?.playerName || 'Nobody'}\`; } }); socket.on('game:playerMoved', (data) => { if (gameState) { const player = gameState.players.find(p => p.id === data.playerId); if (player) { player.x = data.x; player.y = data.y; } } }); readyBtn.addEventListener('click', () => { socket.emit('game:ready'); readyBtn.disabled = true; readyBtn.textContent = 'Waiting for others...'; }); // Keyboard controls document.addEventListener('keydown', (e) => { keys[e.key] = true; }); document.addEventListener('keyup', (e) => { keys[e.key] = false; }); // Game loop function gameLoop() { // Clear canvas ctx.fillStyle = '#16213e'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (!gameState) { requestAnimationFrame(gameLoop); return; } // Update player position based on keys if (gameState.state === 'playing' && myPlayerId) { const myPlayer = gameState.players.find(p => p.id === myPlayerId); if (myPlayer) { const speed = 5; let moved = false; if (keys['ArrowUp'] || keys['w']) { myPlayer.y -= speed; moved = true; } if (keys['ArrowDown'] || keys['s']) { myPlayer.y += speed; moved = true; } if (keys['ArrowLeft'] || keys['a']) { myPlayer.x -= speed; moved = true; } if (keys['ArrowRight'] || keys['d']) { myPlayer.x += speed; moved = true; } if (moved) { socket.emit('game:move', { x: myPlayer.x, y: myPlayer.y }); } } } // Draw collectibles gameState.collectibles.forEach(item => { ctx.fillStyle = item.type === 'bonus' ? '#FFD700' : '#4ECDC4'; ctx.beginPath(); ctx.arc(item.x, item.y, 15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(item.value, item.x, item.y + 4); }); // Draw players gameState.players.forEach(player => { ctx.fillStyle = player.color; ctx.beginPath(); ctx.arc(player.x, player.y, 20, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; ctx.fillText(player.name, player.x, player.y - 30); ctx.fillText(player.score, player.x, player.y + 5); }); requestAnimationFrame(gameLoop); } function updateScoreBoard() { if (!gameState) return; scoreBoard.innerHTML = gameState.scores .sort((a, b) => b.score - a.score) .map(s => \` <div class="player-score"> <strong>${s.playerName}</strong> <div>${s.score} points</div> </div> \`) .join(''); } gameLoop(); </script> </body> </html>
Tip: For production games, implement client-side prediction and server reconciliation to handle network latency smoothly. This prevents the game from feeling laggy.

Game Optimization Techniques

Optimize your multiplayer game for better performance:

// Server-side: Reduce update frequency for non-critical data const updateIntervals = { positions: 50, // 20 updates/sec scores: 100, // 10 updates/sec gameState: 200 // 5 updates/sec }; // Client-side: Interpolation for smooth movement class PlayerInterpolator { constructor() { this.positions = []; } addPosition(x, y, timestamp) { this.positions.push({ x, y, timestamp }); if (this.positions.length > 10) { this.positions.shift(); } } interpolate(currentTime) { if (this.positions.length < 2) return this.positions[0]; const renderDelay = 100; // 100ms behind server const targetTime = currentTime - renderDelay; // Find two positions to interpolate between let before = this.positions[0]; let after = this.positions[1]; for (let i = 0; i < this.positions.length - 1; i++) { if (this.positions[i].timestamp <= targetTime && this.positions[i + 1].timestamp >= targetTime) { before = this.positions[i]; after = this.positions[i + 1]; break; } } const t = (targetTime - before.timestamp) / (after.timestamp - before.timestamp); return { x: before.x + (after.x - before.x) * t, y: before.y + (after.y - before.y) * t }; } }
Warning: Always validate player actions on the server. Never trust client-side position updates without server-side validation to prevent cheating.
Exercise: Extend the multiplayer game with additional features: (1) Add power-ups that give temporary speed boosts, (2) Implement a lobby system where players can see available rooms, (3) Add spectator mode for players who join full rooms, (4) Create a replay system that records and plays back matches, (5) Implement anti-cheat validation to prevent players from teleporting or moving too fast.