A simple multi-player online game using node.js - Part IV

valyouw

Yuval

Posted on February 26, 2021

A simple multi-player online game using node.js - Part IV

Intro

In this section we are going to explore the server code, the main parts are:

  1. server.js - The entry point for the server, responsible for serving static files and accepting WebSockets
  2. lobby.js - Responsible for pairing players into matches
  3. 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);
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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());
    }
};
Enter fullscreen mode Exit fullscreen mode

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) {}
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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;
    }
};
Enter fullscreen mode Exit fullscreen mode

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 = [];
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 !!

💖 💪 🙅 🚩
valyouw
Yuval

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