DEV Community

Yuval
Yuval

Posted on • Originally published at thecodingnotebook.com

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

Intro

In part 1 we had an overview of the architecture, in this part we are going to dive into the code, obviously I wouldn't go over all the code but rather try to explain the concept, the code is well commented (I believe) so exploring it should be easy (GitHub)

Folder Structure

This is the entire project folders and files.

SnakeMatch
+-- common (client/Server shared files)
|   +-- game-objects (classes that represents the different game objects)
|   |   |-- board.js
|   |   |-- pellet.js
|   |   |-- snake-part.js (represent a single part of a snake)
|   |   |-- snake-head.js (represnt the snake head, inherits from snake-part)
|   |   |-- snake.js (represent the snake, a collection of snake-part and head)
|   |-- protocol.js (protocol functions for encoding / decoding messages)
|   |-- rectangle.js
+-- client (client code)
|     +-- deploy (holds the files for deploy)
|     +-- js
|       +-- lib
|       |   |-- graphics.js (functions for drawing on the canvas)
|       |   |-- util.js (Polyfill for necessary node.js util functions in the client)
|       |-- index.js (common client functions/enums, also declare our namespace on window)
|       |-- connector.js (responsible for sever communication)
|       |-- snake-engine.js (manages the game on the client)
|       |-- game-state.js (object to hold the current game state)
+-- server (server code)
|   +-- game
|   |   |-- snake-engine.js (manages the snake game on the server)
|   |   |-- match.js (manages a snake match between 2 players)
|   |   |-- player.js (represnts a single player, basically sending/receiving messages)
|   |-- server.js (starts the web server, our main file)
|   |-- lobby.js (manages client connections and pair players to matches)
|-- Gruntfile.js (grunt build tasks)
|-- .jshintrc (some jshint rules)
|-- package.json
Enter fullscreen mode Exit fullscreen mode

Common objects (Client + Server)

As described in the previous post, the snake game is running on both the client and the server, hence there are parts of the code that can be shared (especially what we call "game objects").

Using JavaScript on the client and the server makes it very easy to share common classes, there are only few simple tweaks needed in order to make client code run in Node.js and vice versa.

In order to not make a mess on the client we will use a namespace for our app, will call it VYW, it is declared on index.js as follow: window.VYW = window.VYW || {};.

The main difference between the browser and node.js is that node.js uses the module.exports object in order to export functions, while the browser uses the window object, so all we need is to make sure that our code knows on which object to export its functions.

We will make a use of Immediately-Invoked Function Expressions (IIFE) in order to create a closure and pass the correct "parent" object (note that we can inject any dependency we need into the module, see util in the example below).

(function(parent, util) {
    function SomeClass(input) {
        var isArr = util.isArray(input);
    }
    ...
    ...

    // Export SomeClass on parent (this is either the module.exports object (server) or VYW object (client)
    parent.SomeClass = SomeClass;

// Pass the correct dependencies into the module, if window is undefined assume it is node.js, otherwise it's the browser
}(typeof window === 'undefined' ? module.exports : window.VYW,
  typeof window === 'undefined' ? require('util') : window.VYW.Util));
Enter fullscreen mode Exit fullscreen mode

That's it, now this code can be used in the client as well as be required in node.js (of course that on the client we would have to create a util class that resembles the node.js util class).

Game Objects

Snake is relatively a simple game, there are no many objects involved, we have the game Board, the Snake and the Pellets, under the game-objects folder we created a class to represent each of those.

Board

The board class gives us methods to interact with the board (dah), as described in the first post, the board is divided into cells (boxes) with fixed size, so the Board class exposes methods that converts from box index to screen pixel and vice versa.
I include here the full file just to show how the IIFE looks like:

(function(parent, Rectangle) {
    /**
     * Creates a new game board instance
     * @param {number} w - The board width
     * @param {number} h - The board height
     * @param {number} boxSize - The box size of each box on the board
     * @param {string} color - The board color
     * @param {string} borderColor - The board border color
     * @constructor
     */
    function Board(w, h, boxSize, color, borderColor) {
        this.rectangle = new Rectangle(0, 0, w, h);
        this.boxSize = boxSize;
        this.color = color;
        this.borderColor = borderColor || '#000000';

        // Hold the number of boxes we can have on the board on X/Y axis
        this.horizontalBoxes = Math.floor(this.rectangle.width / this.boxSize);
        this.verticalBoxes = Math.floor(this.rectangle.height / this.boxSize);
    }

    /**
     * Convert a box index to screen location
     * @param {number} boxIndex - A box index
     * @returns {Rectangle} The screen location on the box
     */
    Board.prototype.toScreen = function(boxIndex) {
        var y = Math.floor(boxIndex / this.horizontalBoxes) * this.boxSize;
        var x = (boxIndex % this.horizontalBoxes) * this.boxSize;
        return new Rectangle(x, y, this.boxSize, this.boxSize);
    };

    /**
     * Gets the box index of an x/y location
     * @param {number} x - The box x
     * @param {number} y - The box y
     * @returns {number} The box index on the board (box index run from 0 to the TotalNumberOfBoxes-1)
     */
    Board.prototype.toBoxIndex = function(x, y) {
        return Math.floor(y / this.boxSize) * this.horizontalBoxes + Math.floor(x / this.boxSize);
    };

    /**
     * Draws the board
     * @param {Graphics} graphics - The game graphics
     */
    Board.prototype.draw = function(graphics) {
        graphics.fillRectangle(this.rectangle,  this.color);
        graphics.drawRectangle(this.rectangle, this.borderColor);
    };

    parent.Board = Board;

// This file is shared between the client and the server, in case "window" is defined we assume it is the client
}(typeof window === 'undefined' ? module.exports : window.VYW,
  typeof window === 'undefined' ? require('../rectangle.js').Rectangle : window.VYW.Rectangle));
Enter fullscreen mode Exit fullscreen mode

Pellet

The pellet has no special logic what-so-ever, all it knows to do is to draw itself.

Snake

The snake is our main object, we need the snake to know how to move, grow, change direction etc.

How the snake moves? The snake keep moving in a certain direction until it is changed, on each step (update interval) it moves to the next box on the board, while the rest of the body just follows the head. In order for that to happen, we keep all the snake parts in a linked list, where each part has a reference to the part it is following, when a part update method is called, it saves his current location in a prevLoaction variable, and update its current location to the prevLocation of the part it is following.

Below are the interesting parts of the Snake class, note how in the constructor the initial snake is built as a linked-list.

/**
 * Creates a new snake
 * @param {number} startX - The snake head X
 * @param {number} startY - The snake head Y
 * @param {number} partSize - The size of a single snake part
 * @param {number} length - The total number of parts of the snake
 * @param {Direction} direction - The direction of the snake
 * @param color
 * @constructor
 */
function Snake(startX, startY, partSize, length, direction, color) {
    /* @type {SnakePart[]} */
    this.parts = [];

    // Create the head
    var part = new SnakeHead(startX, startY, partSize, color);
    this.parts.push(part);
    ...
    ...
    // Create the rest of the snake body
    for (var i = 0; i < length - 1; ++i) {
        // Create the snake part, the last arg is the part it should follow
        part = new SnakePart(startX, startY, partSize, color, this.parts[this.parts.length-1]);
        this.parts.push(part);
    }
}

/**
 * Adds a new tail to the snake
 */
Snake.prototype.addTail = function() {
    var currTail = this.parts[this.parts.length-1];
    var newSnakeTail = new SnakePart(currTail.prevLocation.x, currTail.prevLocation.y, currTail.size, currTail.color, currTail);
    this.parts.push(newSnakeTail);
};

/**
 * Changes the snake direcion
 * @param {Protocol.Direction} newDir
 */
Snake.prototype.changeDirection = function(newDir) {
    if (newDir === this.direction) {
        return;
    }

    // Make sure we can do the change (can't do 180 degrees turns)
    if (newDir === protocol.Direction.Right && this.direction !== protocol.Direction.Left) {
        this.direction = newDir;
    } else if (newDir === protocol.Direction.Left && this.direction !== protocol.Direction.Right) {
        this.direction = newDir;
    } else if (newDir === protocol.Direction.Up && this.direction !== protocol.Direction.Down) {
        this.direction = newDir;
    } else if (newDir === protocol.Direction.Down && this.direction !== protocol.Direction.Up) {
        this.direction = newDir;
    }
};

/**
 * Updates the snake
 * @param {number} [newSize] - The new snake size
 */
Snake.prototype.update = function(newSize) {
    // Check if the snake grew
    if (newSize && newSize > this.parts.length) {
        this.addTail();
    }

    // Update the head first
    this.parts[0].update(this.direction);

    // Update the rest of the snake
    for (var i = 1; i < this.parts.length; ++i) {
        this.parts[i].update();
    }
};

/**
 * Draw the snake
 * @param {Graphics} graphics - The Graphics object
 */
Snake.prototype.draw = function(graphics) {
    for (var i = 0; i < this.parts.length; ++i) {
        this.parts[i].draw(graphics);
    }
};
Enter fullscreen mode Exit fullscreen mode

And here is the update method of snake-part, note how it just follows the location of the part ahead of him.

/**
 * Updates the snake state
 */
SnakePart.prototype.update = function() {
    // Save the current location as previous
    this.prevLocation = this.location.clone();

    // We are just followers here...
    if (this.following !== null) {
        this.location = this.following.prevLocation;
    }
};
Enter fullscreen mode Exit fullscreen mode

Don't lose your head

The snake head is slightly different, it is not following anyone, it inherits from snake-part and overrides its update method.

function SnakeHead(x, y, size, color) {
    SnakePart.call(this, x, y, size, color);
    this.direction = null;
}
// Inherit from SnakePart
util.inherits(SnakeHead, SnakePart);

/**
 * Updates the snake head
 * @param {VYW.Direction} newDirection - A new direction for the snake
 */
SnakeHead.prototype.update = function(newDirection) {
    // Do the base update
    SnakePart.prototype.update.call(this);

    // Update location based on updated direction
    this.direction = newDirection;
    switch (this.direction) {
        case protocol.Direction.Right:
            this.location.x += this.size;
            break;
        case protocol.Direction.Left:
            this.location.x -= this.size;
            break;
        case protocol.Direction.Up:
            this.location.y -= this.size;
            break;
        case protocol.Direction.Down:
            this.location.y += this.size;
            break;
    }
};
Enter fullscreen mode Exit fullscreen mode

Protocol

The game is using a custom protocol (why? see the previous post) for messages, each message has a type (number), and some fields in a predefined order. A field can be either a primitive (number/bool etc), or an object.
Fields are separated by # where object properties are separated by , .
The general structure of a message is: MsgType#field1#field2#objFieldProp1,objFieldProp2#field3#...
For example this is how the update message is encoded:

var updMessage = {
  type: 5,                     // Message type
  timeToEnd: 53,               // Time to game end
  directions: [ '6', '4' ],    // The directions each snake is heading
  sizes: [ 6, 6 ],             // The snake sizes
  pellets: [ 34, 21, 67, 54 ], // The cell indices where we have pellets
  score: [ 6, 5 ]              // The players score
};

var encoded = '5#53#6,4#6,6#34,21,67,54#6,5';
Enter fullscreen mode Exit fullscreen mode

The Protocol module (protocol.js) is responsible for encoding/decoding messages, it starts with exposing some enums to be used by other modules:

// Private constants
var DATA_SEP = '#',
    OBJ_SEP = ',';

/**
 * Player direction enum
 */
Protocol.Direction = {
    Up: '8',
    Right: '6',
    Down: '2',
    Left: '4'
};

/**
 * Game over reason
 */
Protocol.GameOverReason = {
    PeerDisconnect: '1',
    Collision: '2',
    End: '3'
};

/**
 * Server messages enum
 */
Protocol.Messages = {
    Pending: '1',
    Ready: '2',
    Steady: '3',
    Go: '4',
    Update: '5',
    GameOver: '6',
    ChangeDirection: '7'
};
Enter fullscreen mode Exit fullscreen mode

Then we define a class for each message type with the relevant fields, all messages inherits from a base Message class (this is our data model).

/**
 * Creates a new message
 * @param {string} type - The message type
 * @constructor
 */
function Message(type) {
    this.type = type;
}

/**
 * @constructor
 * @extends {Message}
 */
function GetReadyMessage() {
    Message.call(this, Protocol.Messages.Ready);
    this.playerIndex = 0;
    this.board = { width: 0, height: 0, cellSize: 0 };
    this.snake1 = { x: 0, y: 0, size: 0, direction: 0 };
    this.snake2 = { x: 0, y: 0, size: 0, direction: 0 };
}

/**
 * @constructor
 * @extends {Message}
 */
function SteadyMessage() {
    Message.call(this, Protocol.Messages.Steady);
    this.timeToStart = 0;
}
...
...
Enter fullscreen mode Exit fullscreen mode

Then we have our encode methods, these methods get the data they need as arguments, and return a string result (which is the encoded message), for example this is the encoding of the update message:

Protocol.buildUpdate = function(tte, snake1, snake2, pellets, board) {
    // Update msg: 5#timeToEnd#playersDirection#snakesSize#pellets#score
    // playersDirection - player1Direction,player2Direction
    // snakeSizes - snake1Size,snake2Size
    // pellets - cellIndex,cellIndex,cellIndex...
    // score - player1Score,player2Score

    var msg = Protocol.Messages.Update + DATA_SEP + tte + DATA_SEP + snake1.direction + OBJ_SEP + snake2.direction + DATA_SEP;
    msg += snake1.parts.length + OBJ_SEP + snake2.parts.length + DATA_SEP;

    // Now add the pellets
    if (pellets) {
        var currPellet;
        var delim;
        for (var i = 0; i < pellets.length; ++i) {
            currPellet = pellets[i];
            delim = (i === pellets.length - 1) ? '' : OBJ_SEP; // Don't add separator for the last element
            msg += board.toBoxIndex(currPellet.location.x, currPellet.location.y) + delim;
        }
    }

    // Finally add the score
    msg += DATA_SEP + snake1.parts.length + OBJ_SEP + snake2.parts.length;

    return msg;
};
Enter fullscreen mode Exit fullscreen mode

Finally we need methods to decode the messages, we start by split the encoded message into the different fields, check the first field (which is the message type), and call to the appropriate decode method based on the message type:

/**
 * Parse a message
 * @param {string} msg - The message
 * @returns {Message}
 */
Protocol.parseMessage = function(msg) {
    // Message: "CODE#DATA"
    if (!msg) {return null;}

    var parts = msg.split(DATA_SEP);
    var code = parts.shift(); // This also removes the code from the parts array
    switch (code) {
        case Protocol.Messages.Pending:
            // No specific data for this message type
            return new Message(code);
        case Protocol.Messages.Ready:
            return Protocol.parseGetReadyMessage(parts);
        case Protocol.Messages.Steady:
            return Protocol.parseSteadyMessage(parts);
        case Protocol.Messages.Go:
            // No specific data for this message type
            return new Message(code);
        case Protocol.Messages.Update:
            return Protocol.parseUpdateMessage(parts);
        case Protocol.Messages.GameOver:
            // No specific data for this message type
            return Protocol.parseGameOverMessage(parts);
        case Protocol.Messages.ChangeDirection:
            return Protocol.parseChangeDirectionMessage(parts);
        default:
            return null;
    }
};
Enter fullscreen mode Exit fullscreen mode

Here is the decode method for the update message, note how the message is parsed field-by-field, and each field is being verified to have the expected structure and data types:

/**
 * Parse an update message
 * @param {string} data - The encoded message
 * @returns {UpdateMessage}
 */
Protocol.parseUpdateMessage = function(data) {
    // Update data: timeToEnd#playersDirection#snakesSize#pellets#score
    // playersDirection - player1Direction,player2Direction
    // snakeSizes - snake1Size,snake2Size
    // pellets - cellIndex,cellIndex,cellIndex...
    // score - player1Score,player2Score

    if (data.length < 5) {
        return null;
    }

    var res = new UpdateMessage();

    // Parse tte
    res.timeToEnd = parseInt(data[0]);
    if (isNaN(res.timeToEnd)) {
        return null;
    }

    // Parse players directions
    var dirs = data[1].split(OBJ_SEP);
    if (dirs.length < 2) {
        return null;
    }

    res.player1Direction = dirs[0];
    res.player2Direction = dirs[1];

    // Parse players sizes
    var sizes = data[2].split(OBJ_SEP);
    if (sizes.length < 2) {
        return null;
    }

    res.player1Size = parseInt(sizes[0]);
    res.player2Size = parseInt(sizes[1]);
    if (!res.player1Size || !res.player1Size) {
        return null;
    }

    // Parse pellets (if we have)
    if (data[3]) {
        res.pellets = [];
        var pellets = data[3].split(OBJ_SEP);
        for (var i = 0; i < pellets.length; ++i) {
            res.pellets.push(pellets[i]);
        }
    }

    // Parse players scores
    var scores = data[4].split(OBJ_SEP);
    if (scores.length < 2) {
        return null;
    }

    res.player1Score = parseInt(scores[0]);
    res.player2Score = parseInt(scores[1]);
    // The reason we check isNaN instead of (!player1Score) is that 0 is a valid value for this field
    if (isNaN(res.player1Score) || isNaN(res.player2Score)) {
        return null;
    }

    return res;
};
Enter fullscreen mode Exit fullscreen mode

End of part II

This is the end of the second post describing the common objects in the game, those modules are being used both in the client (browser) and the server (node.js).
In the next part we will check out the client-side code.

Top comments (0)