Building a Simple Game – Part 2

So after a short introduction to the project, let’s dive into some commits.

Our story starts with this commit, where we set the basis for drawing the board.

A lot of the files there are boiler plate (webpack config, package.json). The interesting parts are the index.js and drawing.js files:

function initCanvas(canvasEl)
{
let ret = canvasEl && (new fabric.Canvas(canvasEl));
if (ret)
{
ret.line = (x1,y1,x2,y2,c) => drawLineOn(ret,x1,y1,x2,y2,c);
}
return ret;
}
function drawBoard(cnvs)
{
let CELL_SIZE = 50;
let CELL_COUNT = 14;
let TOP_LEFT = { x : 50, y : 50};
let playerCellCount = CELL_COUNT/2-1;
let boardWidthInCells = playerCellCount+2 //for the side cells (player homes)
let boardHeightInCells = 3;
//frame
horizLine(TOP_LEFT.x,TOP_LEFT.y,boardWidthInCells * CELL_SIZE); //top left to top right
verticalLine(TOP_LEFT.x,TOP_LEFT.y,CELL_SIZE*boardHeightInCells); //top left to bottom left
horizLine(TOP_LEFT.x,TOP_LEFT.y + CELL_SIZE*boardHeightInCells,boardWidthInCells * CELL_SIZE); // bottom left to bottom right
verticalLine(TOP_LEFT.x + boardWidthInCells * CELL_SIZE,TOP_LEFT.y,CELL_SIZE * boardHeightInCells); //top right to bottom right
//home cells
verticalLine(TOP_LEFT.x + CELL_SIZE,TOP_LEFT.y,CELL_SIZE*boardHeightInCells)
verticalLine(TOP_LEFT.x + CELL_SIZE*(boardWidthInCells-1),TOP_LEFT.y,CELL_SIZE*boardHeightInCells)
//cell horizontal lines
let upperCellY = TOP_LEFT.y + CELL_SIZE;
let lowerCellY = TOP_LEFT.y + CELL_SIZE*2;
let lineLen = (boardWidthInCells-2)*CELL_SIZE;
horizLine(TOP_LEFT.x + CELL_SIZE,upperCellY,lineLen)
horizLine(TOP_LEFT.x + CELL_SIZE,lowerCellY,lineLen)
//cell borders
range(2,CELL_COUNT/2).map(cellNum => {
verticalLine(TOP_LEFT.x + cellNum*CELL_SIZE,TOP_LEFT.y,CELL_SIZE)
verticalLine(TOP_LEFT.x + cellNum*CELL_SIZE,TOP_LEFT.y+CELL_SIZE*(boardHeightInCells-1),CELL_SIZE)
} )
function verticalLine(x,y,len) { cnvs.line(x,y,x,y+len); }
function horizLine(x,y,len) { cnvs.line(x,y,x+len,y); }
view raw drawing.js hosted with ❤ by GitHub
function initGame(cnvsELID)
{
drawBoard(initCanvas(cnvsELID));
}
view raw index.js hosted with ❤ by GitHub
index.js and drawing.js – the interesting parts.

The index.js is the start of the MancalaGame I mentioned previously. Similarly, the drawing.js is the start of the BoardUI module mentioned as well. Both are written as node modules that export the necessary API as functions to be used by other modules.

At this point, not a lot is going on. We expose a single function from index.js which basically only asks the drawing module to initialize the canvas and draw the board.

Initializing the canvas is simply initializing a fabric.js object with our given HTML canvas element. We’re adding there another function for easier writing (and reading) later, but not much more. We’re doing this so we can leverage the fabric.js API for drawing on the canvas. We’ll be using this object from now on to manipulate the canvas.

Drawing the board itself is simply a matter of calculating line lengths and locations, nothing more. Note how the cell size, number and location of the board are all hard-coded into the function. We’re avoiding magic numbers, which is good. But this is clearly not very extendible or configurable code.

At the end of this step we can launch the index.html file which simply calls the game initGame function we mentioned above when the page loads.


Our next commit is a simple refactoring – renaming the index.js file to game.js. This is a simple enough refactoring, but I wouldn’t dismiss it so easily. It’s important to maintain a mental model of what each module does and how it’s connected to other modules in the code. I described that model in the previous post, and this is where it is manifested. It starts with the file names; designing the API and how it’s used is easier when we have some idea of where different responsibilities lie. It’s easier to track this when we know what module we’re working on by simply looking at the file name1.

Drawing Board State

So far, we only drew the board itself, but no stones in it. If we only do minimal changes, we’d probably only be drawing circles and numbers at this point (to represent how much stones are in each cell). But there’s a more fundamental idea here. We’re in fact drawing the state of the game onto the board. At the start of the game there’s obviously an initial state of the board setup. But the more prominent idea here is that of drawing the state of the board onto the canvas, in correct places. In other words, we need some representation of the board state.

Enter board.js:

class Board
{
constructor(_cellCount)
{
this.cellCount = _cellCount;
this.board = [];
range(1,this.cellCount).forEach(_ => this.board.push(0))
this.forAllPlayer1Cells(cellInd => this.setCellStoneCount(cellInd,INITIAL_STONE_COUNT))
this.forAllPlayer2Cells(cellInd => this.setCellStoneCount(cellInd,INITIAL_STONE_COUNT))
dbg("Board initialized")
}
forAllPlayer2Cells(f) { … }
forAllPlayer1Cells(f) { … }
setCellStoneCount(boardCell,cnt) { this.board[boardCell] = cnt }
stonesIn(boardCell) { return this.board[boardCell]}
forAllCells(f) { … }
isPlayer1Cell(boardCell) { return boardCell >= 1 && boardCell <= this.cellCount/2-1; }
isPlayer2Cell(boardCell) { return boardCell >= this.cellCount/2+1 && boardCell <= this.cellCount-1; }
isPlayer1Home(boardCell) { return boardCell == 0; }
isPlayer2Home(boardCell) { return boardCell == this.cellCount/2; }
totalCellCount() { return this.cellCount; }
}
view raw board.js hosted with ❤ by GitHub
Board.js, initial version

This module exports a single class – Board, which maintains the data structure for maintaining the board state.

The state itself is maintained as a simple array of numbers – one number per board cell + the board’s cell count (the game’s “size”). But the array itself is not directly exposed. All API of the class does some query/manipulation over that array.

Note that there’s only one mutating method – setCellCount, which is the only method (for now) that changes the state. All other methods are simply querying the board’s state, or iterating over it.

One other noteworthy choice at this point: the API encodes the fact that there are only 2 possible players, e.g. by defining methods such as isPlayer1Cell, isPlayer2Cell. This is a deliberate choice, working under the assumption that the game, and its accompanying state, would only have 2 players playing. Accounting for a possibility of more players doesn’t seem useful, and more importantly worth the complication it could cause in the code. We’re “hard-coding” this choice (the number of players), but benefit by having a simpler and explicit API to the game’s state. This will also translate into easier expression of the game rules as we’ll see later.

Drawing the board state requires then a new function – drawBoardState in the drawing module:

function drawBoardState(cnvs,board)
{
board.forAllCells(boardCell => {
let stonesInCell = board.stonesIn(boardCell);
switch (true)
{
case board.isPlayer1Home(boardCell) : drawPlayer1Home(stonesInCell); break;
case board.isPlayer2Home(boardCell) : drawPlayer2Home(stonesInCell); break;
case board.isPlayer1Cell(boardCell) || board.isPlayer2Cell(boardCell): drawCell(boardCell,stonesInCell); break;
default : ERR ("Invalid board cell when drawing state: " + boardCell); break;
}
})
function drawPlayer1Home(stoneCount)
{
drawText(stoneCount,TOP_LEFT.x + CELL_SIZE / 2 – 10,TOP_LEFT.y + CELL_SIZE * 1.5 – 10)
}
function drawPlayer2Home(stoneCount)
{
drawText(stoneCount,TOP_LEFT.x + boardWidthInCells(board.totalCellCount()) * CELL_SIZE – CELL_SIZE/2 – 10,TOP_LEFT.y + CELL_SIZE*1.5-10)
}
function drawText(txt,left,top)
{
cnvs.add(new fabric.Text(txt+'',{fontSize : 20, left : left, top : top}))
}
}
view raw drawing.js hosted with ❤ by GitHub
Querying and drawing the board state

This function is now exposed outside, as another API function from drawing.js. There’s not a lot going on here except calculating positions and sizes for text elements. We can already see the meaning of choosing to be explicit in our API about player 1 and 2 – the function simply dispatches the correct calculation based on the characterization of the cell.

Tying It Together

To make sure all this drawing actually takes place, we make sure to tie it together. This happens by calling the new function from our controller module, `game.js`:

const CELL_COUNT = 14;
let board = new Board(CELL_COUNT)
function initGame(cnvsELID)
{
drawBoard(initCanvas(cnvsELID));
let cnvs = initCanvas(cnvsELID)
drawBoard(cnvs,CELL_COUNT);
drawBoardState(cnvs,board);
}
view raw game.js hosted with ❤ by GitHub
Making sure we actually draw the state

At this point, we’re only adding the call to draw the state from the game initialization function. Note how at this point we already need to extract the CELL_COUNT constant. It was previously in the drawBoard state, in drawing.js; now it’s extracted to the game module, and used both to initialize the board data structure itself, as well as the drawing the board (lines 3,10 in the above snippet).

Drawing the Cell

Our next commit takes care of drawing the actual cell count in the cells:

function drawCell(boardCell,stoneCount)
{
var left = 0;
var top = 0;
switch (true)
{
case board.isPlayer1Cell(boardCell) :
top = CELL_SIZE /2 – FONT_SIZE/2;
left = boardCell * CELL_SIZE + CELL_SIZE/2 – FONT_SIZE/2;
break;
case board.isPlayer2Cell(boardCell) :
top = CELL_SIZE * 2.5 – FONT_SIZE/2;
left = (board.totalCellCount() – boardCell) * CELL_SIZE + CELL_SIZE/2 – FONT_SIZE/2;
break;
default : ERR("Invalid board cell: must be either player 1 or player 2 cell");
}
drawText(stoneCount,TOP_LEFT.x + left,TOP_LEFT.y + top);
}
view raw drawing.js hosted with ❤ by GitHub
Drawing the cell

The function itself is simple enough, simply calculating the position of the drawn elements based on whether we’re drawing for player 1 (top row) or 2 (bottom row). The text itself is the stone count.

Note also how we eliminate a magic number – the font number.

But a Cell Can Be Empty…

An acute reader should note that at this point we’re only drawing the text figures in cells. But updating the state of the board will require us to also remove the text, in case no stones are actually in the cell. This is a design choice – not showing a circle with a text (number of stones) in an empty cell; but it’s one we’re making for the sake of a nicer UI. We’d like an empty cell to be really empty, and not show ‘0’.

The next commit takes care of that. We add the code to essentially remove an existing drawing object. This leads us to the need to remember which UI objects belong to which cell. This is done by maintaining an array of canvas elements2 that correspond to each cell, when drawn:

let stoneUIElement = [];
function initDrawingElements(cellCount)
{
range(1,cellCount).forEach( _ => stoneUIElement.push(None));
}
function rememberUIObj(boardCell,el) { stoneUIElement[boardCell] = maybe(el); }
function forgetUIObj(boardCell) { stoneUIElement[boardCell] = None; }
function uiObjAt(boardCell) { return stoneUIElement[boardCell]; }
view raw drawing.js hosted with ❤ by GitHub
Maintaining an array of UI canvas objects

Each element in the cell is an Option object 3 allowing to write more easily read code.

All that’s left to do is to keep track (“remember” and “forget”) the drawn elements, whenever we draw them. Of course, when the stone count is 0, we remove the canvas object, and “forget” it:

function drawBoardState(cnvs,board)
{
board.forAllCells(boardCell => {
let stonesInCell = board.stonesIn(boardCell);
switch (true)
{
case board.isPlayer1Home(boardCell) : drawOrRemove(boardCell,stonesInCell,(_,stoneCount) => { drawPlayer1Home(stoneCount); }); break;
case board.isPlayer2Home(boardCell) : drawOrRemove(boardCell,stonesInCell,(_,stoneCount) => { drawPlayer2Home(stoneCount); }); break;
case board.isPlayer1Cell(boardCell) || board.isPlayer2Cell(boardCell):
drawOrRemove(boardCell,stonesInCell,(boardCell,stoneCount) => { drawCell(boardCell,stoneCount); });
break;
default : ERR ("Invalid board cell when drawing state: " + boardCell); break;
}
})
function drawPlayer1Home(stoneCount)
{
rememberUIObj(board.player1Home(),
drawStones(stoneCount,
TOP_LEFT.x + CELL_SIZE / 2 – FONT_SIZE/2-MARGIN,
TOP_LEFT.y + CELL_SIZE * 1.5 – FONT_SIZE/2-MARGIN));
}
function drawPlayer2Home(stoneCount)
{
rememberUIObj(board.player2Home(),
drawStones(stoneCount,
/* left = */TOP_LEFT.x + boardWidthInCells(board.totalCellCount()) * CELL_SIZE – CELL_SIZE/2 – FONT_SIZE/2-MARGIN,
/* top = */TOP_LEFT.y + CELL_SIZE*1.5-FONT_SIZE/2-MARGIN));
}
function drawCell(boardCell,stoneCount)
{
switch (true)
{
case board.isPlayer1Cell(boardCell) :
… //calculating top,left
break;
case board.isPlayer2Cell(boardCell) :
… //calculating top,left
break;
default : ERR("Invalid board cell: must be either player 1 or player 2 cell");
}
rememberUIObj(boardCell,drawStones(stoneCount,TOP_LEFT.x + left,TOP_LEFT.y + top));
}
function drawOrRemove(boardCell,stoneCount,drawFunc)
{
if (stoneCount > 0)
{
drawFunc(boardCell,stoneCount);
uiObjAt(boardCell).ifPresent(uiObj => {uiObj.on('mousedown', _ => {dbg('Clicked cell: ' + boardCell + '!')})})
}
else removeDrawingAt(boardCell);
}
function removeDrawingAt(boardCell)
{
uiObjAt(boardCell).ifPresent(uiObj => {
cnvs.remove(uiObj);
forgetUIObj(boardCell);
});
}
function drawStones(stoneCount,left,top)
{
… //create canvas objects and add them to the canvas
//return the created canvas object
}
view raw drawing.js hosted with ❤ by GitHub
Drawing and keeping track of canvas objects

Note that the drawStones function does the actual “drawing” – creating the canvas objects and adding it to the canvas, but it also returns the created object (a Group). This allows calling code to “remember” the object. I intentionally did not add the code to remember the objects in this function, since it would’ve made the function’s code more complicated – mixing separate concerns 4. This way, there’s place in the code that does the drawing, and a place that does the accounting of the objects, each place operates at a different level of abstraction – the game board vs. the canvas objects.

Another thing to note is that regardless of the cell being drawn, the pattern of either drawing it or removing it is the same. The difference lies in how we calculate the coordinates of where to put it. We therefore create a single function – drawOrRemove – that encodes this repeating pattern. The function accepts another function – the drawFunc parameter – that does the actual drawing (calls drawCell or drawPlayer1Home, drawPlayer2Home). We could’ve chosen to abstract it differently – pass functions that simply calculate the coordinates. I eventually leaned towards this solution – a slightly higher abstraction (a function that draws the cell and not a function that calculates coordinates) thinking that I might want to alter the look and feel of different cells in the future.

One last thing to note in this part: when drawing the cell canvas objects, we’re already adding the mouse listener to respond to events (line 54 in the above snippet), at this point doing nothing except issuing a log message. We do this here since it’s the low level operation of attaching an event listener to the object. I’d like to maintain the rest of the code oblivious as much as possible to the details of the drawing objects.


Next, we’re going to look how the drawing will start responding to events, and what does that entail.

Notes

  1. that’s simple “developer user experience” for you.
  2. Note that the board cell is always simply an index to an array. Another advantage of using a simple array as the underlying data structure.
  3. a simple implementation, nothing fancy; see here
  4. or operating at different levels of abstraction if you will

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.