How to Build a Multiplayer (.io) Web Game, Part 2
A look into the backend server behind an .io game.
| UPDATED
This is Part 2 of my “How to Build a Multiplayer (.io) Web Game” series - make sure you read Part 1 first.
In this post, we’ll take a look at the Node.js backend powering our example .io game:
As a reminder, here’s what we went over in Part 1 of the series:
- Project Overview / Structure: A high level view of the project.
- Builds / Project Setup: Development tooling, configuration, and setup.
- Client Entrypoints:
index.html
andindex.js
. - Client Networking: Communicating with the server.
- Client Rendering: Downloading image assets + Rendering the game.
- Client Input: Letting users actually play the game.
- Client State: Processing game updates from the server.
Table of Contents
We’ll cover the following topics in this post:
- Server Entrypoint: Setting up Express and socket.io.
- The Server Game: Managing server-side game state.
- Server Game Objects: Implementing Players and Bullets.
- Collision Detection: Finding Bullets that hit Players.
1. Server Entrypoint
We’ll be using Express, a popular web framework for Node.js, to power our web server. Our server entrypoint file, src/server/server.js
, takes care of setting that up:
server.js, Part 1
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackConfig = require('../../webpack.dev.js');
// Setup an Express server
const app = express();
app.use(express.static('public'));
if (process.env.NODE_ENV === 'development') {
// Setup Webpack for development
const compiler = webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
} else {
// Static serve the dist/ folder in production
app.use(express.static('dist'));
}
// Listen on port
const port = process.env.PORT || 3000;
const server = app.listen(port);
console.log(`Server listening on port ${port}`);
Remember discussing Webpack in Part 1 of this series? This is where we put our Webpack configurations to use. We either
- Use webpack-dev-middleware to automatically rebuild our development bundles, or
- Static serve the
dist/
folder, which is where Webpack will write our files after a production build.
The other primary job server.js
has is to setup our socket.io server, which actually just attaches to our Express server:
server.js, Part 2
const socketio = require('socket.io');
const Constants = require('../shared/constants');
// Setup Express
// ...
const server = app.listen(port);console.log(`Server listening on port ${port}`);
// Setup socket.io
const io = socketio(server);
// Listen for socket.io connections
io.on('connection', socket => {
console.log('Player connected!', socket.id);
socket.on(Constants.MSG_TYPES.JOIN_GAME, joinGame);
socket.on(Constants.MSG_TYPES.INPUT, handleInput);
socket.on('disconnect', onDisconnect);
});
Whenever a socket.io connection to the server is successfully established, we setup event handlers for the new socket. The event handlers process messages received from clients by delegating to the singleton game
object:
server.js, Part 3
const Game = require('./game');
// ...
// Setup the Game
const game = new Game();
function joinGame(username) {
game.addPlayer(this, username);
}
function handleInput(dir) {
game.handleInput(this, dir);
}
function onDisconnect() {
game.removePlayer(this);
}
This is an .io game, so we only need one Game
instance (“the Game”) - all players play in the same arena! We’ll see how this Game
class works in the next section.
2. The Server Game
The Game
class contains the most important server-side logic. It has two primary jobs: managing players and simulating the game.
Let’s start with the first of those: managing players.
game.js, Part 1
const Constants = require('../shared/constants');
const Player = require('./player');
class Game {
constructor() {
this.sockets = {};
this.players = {};
this.bullets = [];
this.lastUpdateTime = Date.now();
this.shouldSendUpdate = false;
setInterval(this.update.bind(this), 1000 / 60);
}
addPlayer(socket, username) {
this.sockets[socket.id] = socket;
// Generate a position to start this player at.
const x = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
const y = Constants.MAP_SIZE * (0.25 + Math.random() * 0.5);
this.players[socket.id] = new Player(socket.id, username, x, y);
}
removePlayer(socket) {
delete this.sockets[socket.id];
delete this.players[socket.id];
}
handleInput(socket, dir) {
if (this.players[socket.id]) {
this.players[socket.id].setDirection(dir);
}
}
// ...
}
Our convention for this game will be to identify players by the id
field of their socket.io socket (refer back to server.js
if you’re confused). Socket.io takes care of assigning each socket a unique id
for us, so we don’t have to worry about it. I’ll refer to this as a player ID.
With that in mind, let’s go over the instance variables in the Game
class:
sockets
is an object that maps a player ID to the socket associated with that player. This lets us access sockets by their player’s ID in constant time.players
is an object that maps a player ID to thePlayer
object associated with that player. This lets us quickly access player objects by their player’s ID.bullets
is an array ofBullet
objects in no particular order.lastUpdateTime
is the timestamp when the last game update occurred. We’ll see this used in a bit.shouldSendUpdate
is a helper variable. We’ll also see this used in a bit.
addPlayer()
, removePlayer()
, and handleInput()
are pretty self-explanatory methods that are used in server.js
. Scroll back up to review it if you need a reminder!
The last line of constructor()
starts the update loop (at 60 updates / second) for the game:
game.js, Part 2
const Constants = require('../shared/constants');
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// Calculate time elapsed
const now = Date.now();
const dt = (now - this.lastUpdateTime) / 1000;
this.lastUpdateTime = now;
// Update each bullet
const bulletsToRemove = [];
this.bullets.forEach(bullet => {
if (bullet.update(dt)) {
// Destroy this bullet
bulletsToRemove.push(bullet);
}
});
this.bullets = this.bullets.filter(
bullet => !bulletsToRemove.includes(bullet),
);
// Update each player
Object.keys(this.sockets).forEach(playerID => {
const player = this.players[playerID];
const newBullet = player.update(dt);
if (newBullet) {
this.bullets.push(newBullet);
}
});
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions(
Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// Check if any players are dead
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
if (player.hp <= 0) {
socket.emit(Constants.MSG_TYPES.GAME_OVER);
this.removePlayer(socket);
}
});
// Send a game update to each player every other time
if (this.shouldSendUpdate) {
const leaderboard = this.getLeaderboard();
Object.keys(this.sockets).forEach(playerID => {
const socket = this.sockets[playerID];
const player = this.players[playerID];
socket.emit(
Constants.MSG_TYPES.GAME_UPDATE,
this.createUpdate(player, leaderboard),
);
});
this.shouldSendUpdate = false;
} else {
this.shouldSendUpdate = true;
}
}
// ...
}
The update()
method contains arguably the most important server-side logic. Let’s walk through what it does, in order:
- Calculate how much time
dt
has passed since the lastupdate()
. - Update each bullet and destroy if needed. We’ll see this implementation later - for now, we just need to know that
bullet.update()
returnstrue
if the bullet should be destroyed (because it’s out of bounds). - Update each player and create a bullet if needed. We’ll also see this implementation later -
player.update()
may return aBullet
object. - Check for collisions between bullets and players using
applyCollisions()
, which returns an array of bullets that hit players. For each returned bullet, we increase the score of the player who fired it (viaplayer.onDealtDamage()
) and then remove the bullet from ourbullets
array. - Notify and remove any dead players.
- Send a game update to all players every other time
update()
is called. TheshouldSendUpdate
helper variable mentioned earlier helps us track this. Sinceupdate()
is called 60 times / second, we send game updates 30 times / second. Thus, our server’s tick rate is 30 ticks / second (we discussed tick rate in Part 1).
Why only send game updates every other time? To save bandwidth. 30 game updates per second is plenty!
Why not just call
update()
30 times / second then? To improve the quality of the game simulation. The more timesupdate()
is called, the more precise the game simulation will be. We don’t want to go too crazy withupdate()
calls, though, because that’d be computationally expensive - 60 per second is good.
The remainder of our Game
class consists of helper methods used in update()
:
game.js, Part 3
class Game {
// ...
getLeaderboard() {
return Object.values(this.players)
.sort((p1, p2) => p2.score - p1.score)
.slice(0, 5)
.map(p => ({ username: p.username, score: Math.round(p.score) }));
}
createUpdate(player, leaderboard) {
const nearbyPlayers = Object.values(this.players).filter(
p => p !== player && p.distanceTo(player) <= Constants.MAP_SIZE / 2,
);
const nearbyBullets = this.bullets.filter(
b => b.distanceTo(player) <= Constants.MAP_SIZE / 2,
);
return {
t: Date.now(),
me: player.serializeForUpdate(),
others: nearbyPlayers.map(p => p.serializeForUpdate()),
bullets: nearbyBullets.map(b => b.serializeForUpdate()),
leaderboard,
};
}
}
getLeaderboard()
is pretty simple - it sorts the players by score, takes the top 5, and returns the username and score for each.
createUpdate()
is used in update()
to create game updates to send to players. It primarily operates by invoking the serializeForUpdate()
methods implemented for the Player
and Bullet
classes. Notice also that it only sends data to any given player about nearby players and bullets - there’s no need to include info about game objects far away from the player!
3. Server Game Objects
In our game, Players and Bullets are actually quite similar: both are ephemeral, circular, moving game objects. To take advantage of this similarity when implementing Players and Bullets, we’ll start out with a base Object
class:
object.js
class Object {
constructor(id, x, y, dir, speed) {
this.id = id;
this.x = x;
this.y = y;
this.direction = dir;
this.speed = speed;
}
update(dt) {
this.x += dt * this.speed * Math.sin(this.direction);
this.y -= dt * this.speed * Math.cos(this.direction);
}
distanceTo(object) {
const dx = this.x - object.x;
const dy = this.y - object.y;
return Math.sqrt(dx * dx + dy * dy);
}
setDirection(dir) {
this.direction = dir;
}
serializeForUpdate() {
return {
id: this.id,
x: this.x,
y: this.y,
};
}
}
Nothing fancy here. This gives us a good starting point that can be extended. Let’s see how the Bullet
class uses Object
:
bullet.js
const shortid = require('shortid');
const ObjectClass = require('./object');
const Constants = require('../shared/constants');
class Bullet extends ObjectClass {
constructor(parentID, x, y, dir) {
super(shortid(), x, y, dir, Constants.BULLET_SPEED);
this.parentID = parentID;
}
// Returns true if the bullet should be destroyed
update(dt) {
super.update(dt);
return this.x < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
}
}
Bullet
’s implementation is so short! The only extensions we add to Object
are:
- Using the shortid package to randomly generate an
id
for our bullet. - Adding a
parentID
field so we can track which player created this bullet. - Adding a return value to
update()
that’strue
if the bullet is out of bounds (remember talking about this in the previous section?).
Onwards to Player
:
player.js
const ObjectClass = require('./object');
const Bullet = require('./bullet');
const Constants = require('../shared/constants');
class Player extends ObjectClass {
constructor(id, username, x, y) {
super(id, x, y, Math.random() * 2 * Math.PI, Constants.PLAYER_SPEED);
this.username = username;
this.hp = Constants.PLAYER_MAX_HP;
this.fireCooldown = 0;
this.score = 0;
}
// Returns a newly created bullet, or null.
update(dt) {
super.update(dt);
// Update score
this.score += dt * Constants.SCORE_PER_SECOND;
// Make sure the player stays in bounds
this.x = Math.max(0, Math.min(Constants.MAP_SIZE, this.x));
this.y = Math.max(0, Math.min(Constants.MAP_SIZE, this.y));
// Fire a bullet, if needed
this.fireCooldown -= dt;
if (this.fireCooldown <= 0) {
this.fireCooldown += Constants.PLAYER_FIRE_COOLDOWN;
return new Bullet(this.id, this.x, this.y, this.direction);
}
return null;
}
takeBulletDamage() {
this.hp -= Constants.BULLET_DAMAGE;
}
onDealtDamage() {
this.score += Constants.SCORE_BULLET_HIT;
}
serializeForUpdate() {
return {
...(super.serializeForUpdate()),
direction: this.direction,
hp: this.hp,
};
}
}
Players are more complex than bullets, so this class needs to store a couple extra fields. Its update()
method does a few extra things, notably returning a newly fired bullet if there is no fireCooldown
left (remember talking about this in the previous section?). It also extends the serializeForUpdate()
method, since we need to include extra fields for a player in a game update.
Having a base Object
class is key for preventing code repetition. For example, without the Object
class, every game object would have the exact same implementation of distanceTo()
, and it’d be a nightmare to keep all of those copy-pasted implementations in sync across different files. This becomes especially important for larger projects, as the number of classes extending Object
grows.
4. Collision Detection
The only thing left to do is detect when bullets hit players! Recall this bit of code from the update()
method in the Game
class:
game.js
const applyCollisions = require('./collisions');
class Game {
// ...
update() {
// ...
// Apply collisions, give players score for hitting bullets
const destroyedBullets = applyCollisions( Object.values(this.players),
this.bullets,
);
destroyedBullets.forEach(b => {
if (this.players[b.parentID]) {
this.players[b.parentID].onDealtDamage();
}
});
this.bullets = this.bullets.filter(
bullet => !destroyedBullets.includes(bullet),
);
// ...
}
}
We need to implement an applyCollisions()
method that returns all bullets that hit players. Luckily, this isn’t too hard because
- All of our collidable objects are circles, which is the easiest shape to implement collision detection for.
- We already have a
distanceTo()
method that we implement in theObject
class in the previous section.
Here’s what our collision detection implementation looks like:
collisions.js
const Constants = require('../shared/constants');
// Returns an array of bullets to be destroyed.
function applyCollisions(players, bullets) {
const destroyedBullets = [];
for (let i = 0; i < bullets.length; i++) {
// Look for a player (who didn't create the bullet) to collide each bullet with.
// As soon as we find one, break out of the loop to prevent double counting a bullet.
for (let j = 0; j < players.length; j++) {
const bullet = bullets[i];
const player = players[j];
if (
bullet.parentID !== player.id &&
player.distanceTo(bullet) <= Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
) {
destroyedBullets.push(bullet);
player.takeBulletDamage();
break;
}
}
}
return destroyedBullets;
}
The math behind this simple collision detection is the fact that two circles only “collide” if the distance between their centers is ≤ the sum of their radii. Here’s the case when the distance between two circle centers is exactly the sum of their radii:
There’s a couple other things we have to be careful about here:
- Making sure a bullet cannot hit the player who created it. We achieve this by checking
bullet.parentID
againstplayer.id
. - Making sure a bullet only “hits” once in the edge case when it collides with multiple players at the same time. We take care of this with the
break
statement: once a player that collides with the bullet is found, we stop looking and go on to the next bullet.
The End
That’s it! We’ve gone through everything you need to know to build an .io web game. What now? Build your own .io game!
All of the code for our example .io game is open-source on Github. Have questions or concerns? Leave a comment below or tweet at me.
Happy Hacking!