WebSockets & Real-Time Apps
Building a Multiplayer Game Project
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.