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
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));
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));
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);
}
};
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;
}
};
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;
}
};
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';
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'
};
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;
}
...
...
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;
};
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;
}
};
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;
};
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)