A simple multi-player online game using node.js - Part IV
Yuval
Posted on February 26, 2021
Intro
In this section we are going to explore the server code, the main parts are:
-
server.js
- The entry point for the server, responsible for serving static files and accepting WebSockets -
lobby.js
- Responsible for pairing players into matches -
game/
- All the snake game logic sits under this folder
Server
As stated above, server.js
is responsible for accepting connections and serving static files, I am not using any framework here but I do use the ws module for handling WebSockets connections.
Requests handlers
In the code below we create a new http server and pass a request listener callback to handle the request, quite a straight forward code:
var http = require('http');
var server = http.createServer(function(req, res) {
// This is a simple server, support only GET methods
if (req.method !== 'GET') {
res.writeHead(404);
res.end();
return;
}
// Handle the favicon (we don't have any)
if (req.url === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
}
// This request is for a file
var file = path.join(DEPLOY_DIR, req.url);
serveStatic(res, file);
});
Static files handler
Whenever we receive a GET request (which is not the favicon) we assume it is for a file, the serveStatic
method will look for the file and stream it back to the client.
In the code I use 2 constant variables that helps with finding the files, the first is DEPLOY_DIR
which is actually the root folder where the static files are, and the second is DEFAULT_FILE
which is the name of the file that should be served if the request url points to a folder.
var DEPLOY_DIR = path.resolve(__dirname, '../client/deploy');
var DEFAULT_FILE = 'index.html';
So assume we deployed the project under /var/www/SnakeMatch
, then DEPLOY_DIR
is /var/www/SnakeMatch/client/deploy
, and a request to /all.js
will serve /var/www/SnakeMatch/client/deploy/all.js
.
Here is the code of the serveStatic
method, where fs
is Node's fs module:
/**
* Serves a static file
* @param {object} res - The response object
* @param {string} file - The requested file path
*/
function serveStatic(res, file) {
// Get the file statistics
fs.lstat(file, function(err, stat) {
// If err probably file does not exist
if (err) {
res.writeHead(404);
res.end();
return;
}
// If this is a directory we will try to serve the default file
if (stat.isDirectory()) {
var defaultFile = path.join(file, DEFAULT_FILE);
serveStatic(res, defaultFile);
} else {
// Pipe the file over to the response
fs.createReadStream(file).pipe(res);
}
});
}
Accepting connections
After creating http server we need to bind on a port, we are using the PORT
environment variable (to be used in Heroku), defaults to 3000, for WebSockets we use ws
, whenever we get a WebSocket connection we just send it to the lobby
var WebSocketServer = require('ws').Server;
var port = process.env.PORT || 3000;
server.listen(port, function () {
console.log('Server listening on port:', port);
});
// Create the WebSocket server (it will handle "upgrade" requests)
var wss = new WebSocketServer({server: server});
wss.on('connection', function(ws) {
lobby.add(ws);
});
Lobby
The Lobby is responsible for accepting new players, and pairing players into matches.
Whenever a new socket is added to the lobby it first creates a Player
object (wrapper around the socket, more on this later) and listen to its disconnect
event, then it tries to pair it with another player into a Match
, if there are no available players it puts the player in the pendingPlayers
dictionary, if it succeeded to pair this player with another player the Match object is put in the activeMatches
dictionary and it registers to the Match's GameOver
event.
Lobby.add = function (socket) {
// Create a new Player, add it to the pending players dictionary and register to its disconnect event
var player = new Player(socket);
pendingPlayers[player.id] = player;
player.on(Player.Events.Disconnect, Lobby.onPlayerDisconnect);
// Try to pair this player with other pending players, if success we get a "match"
var match = this.matchPlayers(player);
if (match) {
// Register the Match GameOver event and store the match in the active matches dictionary
match.on(Match.Events.GameOver, Lobby.onGameOver);
activeMatches[match.id] = match;
// Remove the players in the match from the pending players
delete pendingPlayers[match.player1.id];
delete pendingPlayers[match.player2.id];
// Start the match
match.start();
} else {
// No match found for this player, let him know he is Pending
player.send(protocol.buildPending());
}
};
The rest of the code in the Lobby is not that interesting, matchPlayers
just loops over the pendingPlayers
dictionary and returns a new Match
object if it found another pending player (which is not the current player). When a match is over (GameOver
event) we just disconnect the two players (which will close their sockets), and delete the match from the activeMatches
dictionary.
The Game
Now we will go over the code under the server/game
folder, it contains the Player
, Match
and SnakeEngine
classes.
Player class
The Player is just a wrapper around the socket class, whenever new data arrives on the socket it raises a message
event, if the socket gets closed it raises a disconnect
event, and it exposes a send
method which is used to write data over the socket. Below is the ctor and send methods:
var Emitter = require('events').EventEmitter,
util = require('util'),
uuid = require('node-uuid');
function Player(socket) {
// Make sure we got a socket
if (typeof socket !== 'object' || socket === null) {
throw new Error('socket is mandatory');
}
Emitter.call(this);
this.id = uuid.v1();
this.index = 0; // The player index within the game (will be set by the Match class)
this.online = true;
this.socket = socket;
// Register to the socket events
socket.on('close', this.onDisconnect.bind(this));
socket.on('error', this.onDisconnect.bind(this));
socket.on('message', this.onMessage.bind(this));
}
util.inherits(Player, Emitter);
Player.prototype.send = function(msg) {
if (!msg || !this.online) {
return;
}
try {
this.socket.send(msg);
} catch (ignore) {}
};
Match class
This class is responsible for all the game logistics, it updates the snake-engine every 100 msec, it sends updates to the clients, it read messages from the client etc.
NOTE: the Match class doesn't know how to "play" snake, that's why we have the snake-engine for.
Although we described it on the first post lets go over the course of a snake match: start by sending a Ready
message to the clients with all the game info (board size, snakes initial position etc), then there are 3 Steady
messages (every 1 second), then there is a go
message signaling to the clients that the game has started, then a series of Update
messages are being sent every 100 milliseconds, and finally there is a GameOver
message.
The match is over if when one of the players has failed or 60 seconds has passed, if after 60 seconds the score is tied there is an overtime of 10 seconds until one player wins.
Now lets see how the Match class is doing all this, first we define some constants:
var MATCH_TIME = 60000; // In milliseconds
var MATCH_EXTENSION_TIME = 10000; // In milliseconds
var UPD_FREQ = 100;
var STEADY_WAIT = 3; // number of steady messages to send
var BOARD_SIZE = {
WIDTH: 500,
HEIGHT: 500,
BOX: 10
};
In the ctor we initialize the game, note that each player is assigned to an index (player1 / player2).
function Match(player1, player2) {
Emitter.call(this);
this.id = uuid.v1();
this.gameTimer = null;
this.matchTime = MATCH_TIME; // The match timer (each match is for MATCH_TIME milliseconds)
// Set the players indexes
this.player1 = player1;
this.player1.index = 1;
this.player2 = player2;
this.player2.index = 2;
// Register to the players events
this.player1.on(Player.Events.Disconnect, this.onPlayerDisconnect.bind(this));
this.player2.on(Player.Events.Disconnect, this.onPlayerDisconnect.bind(this));
this.player1.on(Player.Events.Message, this.onPlayerMessage.bind(this));
this.player2.on(Player.Events.Message, this.onPlayerMessage.bind(this));
// Create the snake game
this.snakeEngine = new SnakeEngine(BOARD_SIZE.WIDTH, BOARD_SIZE.HEIGHT, BOARD_SIZE.BOX);
}
Ready-Steady-Go
The ready-steady-go flow happens in the start
and steady
methods:
Match.prototype.start = function() {
// Build the ready message for each player
var msg = protocol.buildReady(this.player1.index, this.snakeEngine.board, this.snakeEngine.snake1, this.snakeEngine.snake2);
this.player1.send(msg);
msg = protocol.buildReady(this.player2.index, this.snakeEngine.board, this.snakeEngine.snake1, this.snakeEngine.snake2);
this.player2.send(msg);
// Start the steady count down
this.steady(STEADY_WAIT);
};
/**
* Handles the steady count down
* @param {number} steadyLeft - The number of steady events left
*/
Match.prototype.steady = function(steadyLeft) {
var msg;
// Check if steady count down finished
if (steadyLeft === 0) {
// Send the players a "Go" message
msg = protocol.buildGo();
this.player1.send(msg);
this.player2.send(msg);
// Starts the update events (this is the actual game)
this.gameTimer = setTimeout(this.update.bind(this), UPD_FREQ);
return;
}
// Sends the players another steady message and call this method again in 1 sec
msg = protocol.buildSteady(steadyLeft);
this.player1.send(msg);
this.player2.send(msg);
--steadyLeft;
this.gameTimer = setTimeout(this.steady.bind(this, steadyLeft), 1000);
};
Update cycle
The update
method is being called every 100 milliseconds, the method is quite self-explanatory but do note that snakeEngine.update()
returns a result object with info about the game state, more specifically, it tells us whether one snake has lost (by colliding into itself/border) and if there was a change to the pellets (removed/added).
Match.prototype.update = function() {
// Update the match time, this is not super precise as the "setTimeout" time is not guaranteed,
// but ok for our purposes...
this.matchTime -= UPD_FREQ;
// Update the game
var res = this.snakeEngine.update();
// If no snake lost on this update and there is more time we just reload the update timer
if (res.loosingSnake < 0 && this.matchTime > 0) {
this.gameTimer = setTimeout(this.update.bind(this), UPD_FREQ);
this.sendUpdateMessage(res);
return;
}
var msg;
// If no snake lost it means time's up, lets see who won.
if (res.loosingSnake < 0) {
// Check if there is a tie
if (this.snakeEngine.snake1.parts.length === this.snakeEngine.snake2.parts.length) {
// We don't like ties, lets add more time to the game
this.matchTime += MATCH_EXTENSION_TIME;
this.gameTimer = setTimeout(this.update.bind(this), UPD_FREQ);
this.sendUpdateMessage(res);
return;
}
// No tie, build a GameOver message (the client will find which player won)
msg = protocol.buildGameOver(protocol.GameOverReason.End, null, this.snakeEngine.snake1, this.snakeEngine.snake2);
} else {
// Ok, some snake had a collision and lost, since we have only 2 players we can easily find the winning snake
var winningPlayer = (res.loosingSnake + 2) % 2 + 1;
msg = protocol.buildGameOver(protocol.GameOverReason.Collision, winningPlayer);
}
// Send the message to the players and raise the GameOver event
this.player1.send(msg);
this.player2.send(msg);
this.emit(Match.Events.GameOver, this);
};
Handling clients messages
Whenever the client sends a message it first get parsed using the Protocol object, then if it is a ChangeDirection
request we pass it to the snake-engine for processing, note that we put the player index on the message so that snake-engine would know what player to update.
Match.prototype.onPlayerMessage = function(player, msg) {
// Parse the message
var message = protocol.parseMessage(msg);
if (!message) {
return;
}
switch (message.type) {
case protocol.Messages.ChangeDirection:
message.playerIndex = player.index;
this.snakeEngine.handleDirChangeMessage(message);
break;
}
};
That's it for the Match class, the rest of the code is not that interesting.
Snake Engine
The snake-engine is responsible for "playing" the snake game, on every update
it checks whether a snake had collided with itself, went out-of-bounds, ate a pellet etc.
In the ctor we create the 2 snake objects, both snakes are created at the first row of the board, one is created on the left side and the other is created on the right side.
Remember that the Board is divided into boxes, and that Board.toScreen()
gets a box index and returns the screen x/y.
function SnakeEngine(width, height, boxSize) {
this.board = new Board(width, height, boxSize);
// The first snake is created on the left side and is heading right (very top row, y index = 0)
var snakeLoc = this.board.toScreen(INITIAL_SNAKE_SIZE - 1);
this.snake1 = new Snake(snakeLoc.x, snakeLoc.y, boxSize, INITIAL_SNAKE_SIZE, protocol.Direction.Right);
// The second snake is created on the right side and is heading left (very top row, y index = 0)
snakeLoc = this.board.toScreen(this.board.horizontalBoxes - INITIAL_SNAKE_SIZE);
this.snake2 = new Snake(snakeLoc.x, snakeLoc.y, boxSize, INITIAL_SNAKE_SIZE, protocol.Direction.Left);
/** @type {Pellet[]} */
this.pellets = [];
}
The interesting methods are update
, checkCollision
and addPellet
.
In the update method we do the following for each snake: call the snake update method (tell it to move to its next location), check for collisions, check if it ate a pellet. If there was a collision we stop immediately as the game is over, if there was no collision we try to add a new pellet to the game.
SnakeEngine.prototype.update = function() {
var res = new GameUpdateData();
// Update snake1
this.snake1.update();
// Check if the snake collides with itself or out-of-bounds
var collision = this.checkCollision(this.snake1);
if (collision) {
res.loosingSnake = 1;
return res;
}
// Check if the snake eats a pellet
res.pelletsUpdate = this.eatPellet(this.snake1);
// Update snake2
this.snake2.update();
// Check if the snake collides with itself or out-of-bounds
collision = this.checkCollision(this.snake2);
if (collision) {
res.loosingSnake = 2;
return res;
}
// Check if the snake eats a pellet
res.pelletsUpdate = this.eatPellet(this.snake2) || res.pelletsUpdate;
// Finally add new pellet
res.pelletsUpdate = this.addPellet() || res.pelletsUpdate;
// No one lost (yet...).
return res;
};
In checkCollision
we first check if the snake went out-of-bounds, we do this by comparing the snake's head to the board dimensions. Remember that the snake head is a rectangle, where the upper-left corner is denoted by x/y, so when we want to check if the snake crossed the top/left border we use x/y, but when we want to check whether the snake crossed the bottom/right border we use the bottom-right corner of the snake head.
Checking whether the snake had collided with itself is quite simple, just loop thru all the snake parts (excluding the head), and check whether they are equal to the head (equals just check x/y).
SnakeEngine.prototype.checkCollision = function(snake) {
// Check if the head is out-of-bounds
if (snake.parts[0].location.x < 0 ||
snake.parts[0].location.y < 0 ||
snake.parts[0].location.x + snake.parts[0].size > this.board.rectangle.width ||
snake.parts[0].location.y + snake.parts[0].size > this.board.rectangle.height) {
return true;
}
// Check if the snake head collides with its body
for (var i = 1; i < snake.parts.length; ++i) {
if (snake.parts[0].location.equals(snake.parts[i].location)) {
return true;
}
}
return false;
};
Adding pellets
When we come to add a new pellet to the game we first check that we have not exceeded the maximum number of allowed pellets, then we select a random box on the board and check that the box is vacant.
Since addPellet
is getting called quite frequently (every update cycle) we have to do some filtering as we want the pellets to be added on a random timing, so at the very beginning of the method we check if Math.random() > 0.2
, if yes we immediately return without adding anything, so on average we would drop 8 of 10 calls.
SnakeEngine.prototype.addPellet = function() {
// Check if we should add pellets
if (this.pellets.length >= MAX_PELLETS || Math.random() > 0.2) {
return false;
}
// Keep loop until we found a spot for a pellet (theoretically this can turn into an infinite loop, so a solution could
// be to stop the random search after X times and look for a spot on the board).
var keepSearch = true;
while (keepSearch) {
keepSearch = false;
// Take a random spot on the board
var boxIndex = Math.floor(Math.random() * this.board.horizontalBoxes * this.board.horizontalBoxes);
var loc = this.board.toScreen(boxIndex);
// check that this spot is not on snake1
for (var i = 0; i < this.snake1.parts.length; ++i) {
if (this.snake1.parts[i].location.equals(loc)) {
keepSearch = true;
break;
}
}
if (!keepSearch) {
// check that this spot is not on snake2
for (i = 0; i < this.snake2.parts.length; ++i) {
if (this.snake2.parts[i].location.equals(loc)) {
keepSearch = true;
break;
}
}
}
if (!keepSearch) {
// check that this spot is not on existing pellet
for (i = 0; i < this.pellets.length; ++i) {
if (this.pellets[i].location.equals(loc)) {
keepSearch = true;
break;
}
}
}
if (!keepSearch) {
// Hooray we can add the pellet
this.pellets.push(new Pellet(loc));
}
}
return true;
};
THE END
Pshew... if you have made it all the way to here, well done and thank you!
I hope this series was in some of interest to you, to me it was fun programming this game, feel free to explore the code and even make it better !!
Posted on February 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 27, 2024
October 21, 2024