Creating a new game

Now that we have defined the data structure of our game, let's start with implementing the logic to create and persist a new game document in the database, all the while following Test-Driven Development practices.

In order to create a new game, we need to accept a POST to /create with your name in the POST body:

{ name: 'player1' }

There are a few things we should think about:

  • We need to return the board information to the user, and whether or not game creation was successful
  • We need to ensure the player can access the game they just created, so we must send them the boardId
  • In order for the player to identify themselves, we also need to ensure that we send them the p1Key, which will be needed for all future moves that Player One wishes to play to this board

Since we're building the game, we have the power to bend the rules of the game. So let's allow player 1 to optionally configure the size of the playing board! We should have a minimum size of 6x7, though.

So let's start with the tests for creating a game and fetch the game information:

var expect = require('chai').expect,
    request = require('supertest'),

var app = require('../src/lib/app'),
describe('Create new game | ', function() {
  var boardId;

  it('should return a game object with key for player 1', function(done) {
    request(app).post('/create')
      .send({name: 'express'})
      .expect(200)
      .end(function(err, res) {
        var b = res.body;
        expect(b.boardId).to.be.a('string'),
        expect(b.p1Key).to.be.a('string'),
        expect(b.p1Name).to.be.a('string').and.equal('express'),
        expect(b.turn).to.be.a('number').and.equal(1);
        expect(b.rows).to.be.a('number'),
        expect(b.columns).to.be.a('number'),

        // Make sure the board is a 2D array
        expect(b.board).to.be.an('array'),
        for(var i = 0; i < b.board.length; i++){
          expect(b.board[i]).to.be.an('array'),
        }

        // Store the boardId for reference
        boardId = b.boardId;
        done();
      });
  });
})

Note

Throughout this chapter, we will use the expect assertion library. The only difference with should is the syntax, and the way it handles undefined more gracefully. The should library patches the object prototype, which means that if the object is undefined, it will throw a TypeError: Cannot read property should of undefined.

The test will use supertest to simulate POSTing data to the /create endpoint, and we describe everything that we expect from the response.

  1. Now let's create a POST route in src/routes/games.js to create a game in the database, and make the first test pass:
    var Utils = require('../lib/utils'),
    var connect4 = require('../lib/connect4'),
    var Game = require('../models/game'),
    
    app.post('/create', function(req, res) {
        if(!req.body.name) {
          res.status(400).json({
            "Error": "Must provide name field!"
          });
        }
    
        var newGame = {
          p1Key: Utils.randomValueHex(25),
          p2Key: Utils.randomValueHex(25),
          boardId: Utils.randomValueHex(6),
          p1Name: req.body.name,
          board: connect4.initializeBoard(req.body.rows, req.body.columns),
          rows: req.body.rows || app.get('config').MIN_ROWS,
          columns: req.body.columns || app.get('config').MIN_COLUMNS,
          turn: 1,
          status: 'Game in progress'
        };
    
        Game.create(newGame, function(err, game) {
          if (err) {
            return res.status(400).json(err);
          }
          game.p2Key = undefined;
          res.status(201).json(game);
        });
      });

    Note

    Note that an API should always take care of all possible inputs, and make sure it return 400 error if it does not pass the input validation; more on this as follows.

  2. The Utils.randomValueHex() method will return a random string, which we use to generate a token as well as boardId. Instead of defining it in the preceding file, let's package it up nicely in src/lib/utils.js:
    var crypto = require('crypto'),
    
    module.exports = {
      randomValueHex: function(len) {
        return crypto.randomBytes(Math.ceil(len/2))
            .toString('hex')
            .slice(0,len);
      }
    }

    All the game logic of Connect4 is in src/lib/connect4.js, which you can find in the Appendix. We'll use that library to initialize the board.

  3. Also notice that rows and columns are optional arguments. We don't want to be hardcoding the default values in the code, so we have the following config.js file in the root folder:
    module.exports = {
      MIN_ROWS: 6,
      MIN_COLUMNS: 7
    };
  4. As we initiate the app in src/lib/app.js, we can attach this config object onto the app object, so we have app-wide access to the config:
    var express = require('express'),
        app = express(),
        config = require('../../config'),
        db = require('./db'),
    
    app.set('config', config);
    db.connectMongoDB();
    require('./parser')(app);
    require('../routes/games')(app);
    
    module.exports = app;

    By now, your first pass should pass—congratulations! We can now be rest assured that the POST endpoint is working, and will keep working as expected. It's a great feeling because if we ever break something in the future, the test will fail. Now you don't have to worry about it anymore and focus on your next task.

  5. You do have to be diligent about getting as much code coverage as possible. For instance, we allow the client to customize the size of the board, but we have not written tests to test this feature yet, so let's get right to it:
     it('should allow you to customize the size of the board', function(done) {
        request(app).post('/create')
          .send({
            name: 'express',
            columns: 8,
            rows: 16
          })
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.columns).to.equal(8);
            expect(b.rows).to.equal(16);
            expect(b.board).to.have.length(16);
            expect(b.board[0]).to.have.length(8);
            done();
          });
      });
  6. We should also enforce a minimum size of the board; otherwise, the game can't be played. Remember how we defined MIN_ROWS and MIN_COLUMNS in the config.js file? We can reuse that in our tests as well, without having to resort to hardcoding the tests. Now if we want to be changing the minimum size of the game, we can do it one place! As given in the following:
      it('should not accept sizes < ' + MIN_COLUMNS + ' for columns', function(done) {
        request(app).post('/create')
          .send({
            name: 'express',
            columns: 5,
            rows: 16
          })
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Number of columns has to be >= ' + MIN_COLUMNS);
            done();
          });
      });
    
      it('should not accept sizes < ' + MIN_ROWS + ' rows', function(done) {
        request(app).post('/create')
          .send({
            name: 'express',
            columns: 8,
            rows: -6
          })
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Number of rows has to be >= ' + MIN_ROWS);
            done();
          });
      });

As described in the preceding test cases, we should make sure that if the player is customizing the size of the board, that the size is not less than the minimum size. There are many more validation checks that we'll be doing, so let's start to get a bit more organized.

Input validation

We should always check that the inputs we receive from a POST request are indeed what we expect, and return a 400 input error otherwise. This requires thinking about as many edge-case scenarios as possible. When an API is used by thousands of users, it is guaranteed that some users will abuse or misuse it, be it either intentional or unintentional. However, it is your responsibility to make the API as user-friendly as possible.

The only input validation that we covered in the preceding /create route is to make sure that there is a name in the POST body. Now we can just add two more if blocks to cover the board-size cases to make the tests pass.

In true TDD philosophy, you should write the least amount of code to make the tests pass first. They call it red-green-refactor. First, write tests that fail (red), make them pass as quickly as possible (green), and refactor after.

We urge you to try the preceding first. The following is the result after refactoring.

  1. A lot of the input validation checks would be useful across multiple routes, so let's package it nicely as a collection of middleware in src/lib/validators.js:
    // A collection of validation middleware
    
    module.exports = function(app) {
      var MIN_COLUMNS = app.get('config').MIN_COLUMNS,
          MIN_ROWS = app.get('config').MIN_ROWS;
    
      // Helper to return 400 error with a custom message
      var _return400Error = function(res, message) {
        return res.status(400).json({
          error: message
        });
      };
    
      return {
        name: function(req, res, next) {
          if(!req.body.name) {
            return _return400Error(res, 'Must provide name field!'),
          }
          next();
        },
        columns: function(req, res, next) {
          if(req.body.columns && req.body.columns < MIN_COLUMNS) {
            return _return400Error(res, 'Number of columns has to be >= ' + MIN_COLUMNS);
          }
          next();
        },
        rows: function(req, res, next) {
          if(req.body.rows && req.body.rows < MIN_ROWS) {
            return _return400Error(res, 'Number of rows has to be >= ' + MIN_ROWS);
          }
          next();
        }
      }
    }

    The preceding packages three validation checkers in a reusable fashion. It returns an object with three middleware. Note how we DRYed up the code using a private _return400Error helper, to make it even cleaner.

  2. Now we can refactor the /create route as follows:
    module.exports = function(app) {
      // Initialize Validation middleware with app to use config.js
      var Validate = require('../lib/validators')(app);
    
      app.post('/create', [Validate.name, Validate.columns, Validate.rows], function(req, res) {
    
        var newGame = {
          p1Key: Utils.randomValueHex(25),
          p2Key: Utils.randomValueHex(25),
          boardId: Utils.randomValueHex(6),
          p1Name: req.body.name,
          board: connect4.initializeBoard(req.body.rows, req.body.columns),
          rows: req.body.rows || app.get('config').MIN_ROWS,
          columns: req.body.columns || app.get('config').MIN_COLUMNS,
          turn: 1,
          status: 'Game in progress'
        };
        Game.create(newGame, function(err, game) {
          if (err) return res.status(400).json(err);
    
          game.p2Key = undefined;
          return res.status(201).json(game);
        });
      });
    }

This will create a nice separation of concerns, where each of the routes that we will define will accept an array of (reusable!) validation middleware that it has to go through, before it reaches the controller logic of the route.

Tip

Make sure your tests still pass before you proceed with the next endpoint.

Getting the game state

Both players need a way to check on the state of a game that they are interested in. To do this, we can send a GET request to /board/{boardId}. This will return the current state of the game, allowing players to see the state of the board, as well as whose turn is next.

We will create another endpoint to fetch a board, so let's first write the test for that:

  it('should be able to fetch the board', function(done) {
    request(app).get("/board/" + boardId)
      .expect(200)
      .end(function(err, res) {
        var b = res.body;
        expect(b.boardId).to.be.a('string').and.equal(boardId);
        expect(b.turn).to.be.a('number').and.equal(1);
        expect(b.rows).to.be.a('number'),
        expect(b.columns).to.be.a('number'),
        expect(b.board).to.be.an('array'),
        done();
      });
  });

Note that we want to make sure that we don't accidentally leak the player tokens. The response should be basically identical to the one received by the player that most recently made a move as given in the following:

 app.get('/board/:id', function(req, res) {
    Game.findOne({boardId: req.params.id}, function(err, game) {
      if (err) return res.status(400).json(err);

      res.status(200).json(_sanitizeReturn(game));
    });
  });

Here, _sanitizeReturn(game) is a simple helper that just copies the game object, except for the player tokens.

// Given a game object, return the game object without tokens
function _sanitizeReturn(game) {
  return {
    boardId: game.boardId,
    board: game.board,
    rows: game.rows,
    columns: game.columns,
    turn: game.turn,
    status: game.status,
    winner: game.winner,
    p1Name: game.p1Name,
    p2Name: game.p2Name
  };
}

Joining a game

This game would be no fun if played alone, so we need to allow a second player to join the game.

  1. In order to join a game, we need to accept POST to /join with the name of player2 in the POST body:
    { name: 'player2' }

    Note

    For this to work, we need to implement a rudimentary match-making system. An easy one is to simply have a queue of games in a joinable state, and popping one off when the /join API is hit. We chose to use Redis as our Queue implementation to keep track of the joinable games.

    Once a game is joined, we will send boardId and p2Key back to the player, so that they can play on this board with player 1. This will intrinsically avoid a game to be joined multiple times.

  2. All we need to do is add this line to push boardId onto the queue, once the game is created and stored in the DB:
          client.lpush('games', game.boardId);
  3. We glanced over database connections when we showed app.js. The way to set up a MongoDB connection was covered in Chapter 2, A Robust Movie API. The following is how we'll connect to a redis database in src/lib/db.js:
    var redis = require('redis'),
    var url = require('url'),
    
    exports.connectRedis = function() {
      var urlRedisToGo = process.env.REDISTOGO_URL;
      var client = {};
    
      if (urlRedisToGo) {
        console.log('using redistogo'),
        rtg = url.parse(urlRedisToGo);
        client = redis.createClient(rtg.port, rtg.hostname);
        client.auth(rtg.auth.split(':')[1]);
      } else {
        console.log('using local redis'),
        // This would use the default redis config: { port 6347, host: 'localhost' }
        client = redis.createClient();
      }
    
      return client;
    };

    Note

    Note that in production, we'll be connecting to Redis To Go (you can start with a 2MB instance for free). For local development, all you need to do is redis.createClient().

  4. Now we can write the tests to join a game, TDD style:
    var expect = require('chai').expect,
        request = require('supertest'),
        redis = require('redis'),
        client = redis.createClient();
    
    var app = require('../src/lib/app'),
    
    describe('Create and join new game | ', function() {
      before(function(done){
        client.flushall(function(err, res){
          if (err) return done(err);
          done();
        });
      });
  5. Note that we flush the redis queue each time we run this test suite, just to make sure that the stack is empty. In general, it is a good idea to write atomic tests that can run on their own, without reliance on outside state.
      it('should not be able to join a game without a name', function(done) {
        request(app).post('/join')
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal("Must provide name field!");
            done();
          });
      });
    
      it('should not be able to join a game if none exists', function(done) {
        request(app).post('/join')
          .send({name: 'koa'})
          .expect(418)
          .end(function(err, res) {
            expect(res.body.error).to.equal("No games to join!");
            done();
          });
      });
  6. Always remember to cover input the edge-cases! In the preceding test, we make sure that we cover the case that we have no games left to join. If not, we might crash the server or return the 500 error (which we should attempt to eradicate because that means it's your fault, not the user!). Now let's write the following code:
      it('should create a game and add it to the queue', function(done) {
        request(app).post('/create')
          .send({name: 'express'})
          .expect(200)
          .end(function(err, res) {
            done();
          });
      });
    
      it('should join the game on the queue', function(done) {
        request(app).post('/join')
          .send({name: 'koa'})
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.boardId).to.be.a('string'),
            expect(b.p1Key).to.be.undefined;
            expect(b.p1Name).to.be.a('string').and.equal('express'),
            expect(b.p2Key).to.be.a('string'),
            expect(b.p2Name).to.be.a('string').and.equal('koa'),
            expect(b.turn).to.be.a('number').and.equal(1);
            expect(b.rows).to.be.a('number'),
            expect(b.columns).to.be.a('number'),
            done();
          });
      });
    });
  7. These tests describe the core logic of creating a game and joining it. Enough tests to describe this endpoint. Let's now write the accompanying code:
    app.post('/join', Validate.name, function(req, res) {
        client.rpop('games', function(err, boardId) {
          if (err) return res.status(418).json(err);
    
          if (!boardId) {
            return res.status(418).json({
              error: 'No games to join!'
            });
          }
    
          Game.findOne({ boardId: boardId }, function (err, game){
            if (err) return res.status(400).json(err);
    
            game.p2Name = req.body.name;
            game.save(function(err, game) {
              if (err) return res.status(500).json(err);
              game.p1Key = undefined;
              res.status(200).json(game);
            });
          });
        });
      });

We reuse the Validate.name middleware here to make sure that we have a name for player 2. If so, we will look for the next joinable game in the queue. When there are no joinable games, we will return an appropriate 418 error.

If we successfully retrieve the next joinable boardId, we will fetch the board from the database, and store the name of player 2 in it. We also have to make sure that we do not return player 1's token along with the game object.

Now that both players have fetched their respective tokens, let the games begin!

Playing the game

The game state is stored in the database and can be retrieved with a GET request to /board/{boardId}. The essence of making a move is a change to the game state. In familiar CRUD terms, we would be updating the document. Following REST conventions whenever possible, a PUT request to /board/{boardId} would be the logical choice to make a move.

To make a valid move, a player needs to include an X-Player-Token in their request header matching that of the corresponding player, as well as a request body identifying which column to make a move in:

{ column: 2 }

However, not all moves are legal, for example, we need to ensure that players only play moves when it is their turn. There are a few more things that need to be checked for every move:

  • Is the move valid? Does the column parameter specify an actual column?
  • Does the column still have space?
  • Is the X-Player-Token a valid token for the current game?
  • Is it your turn?
  • Did the move create a victory condition? Did this player win with this move?
  • Did the move fill up the board and cause a draw game?

Now we will model all these scenarios.

  1. Let's play a full game with the following tests:
    var expect = require('chai').expect,
        request = require('supertest'),
        redis = require('redis'),
        client = redis.createClient();
    
    var app = require('../src/lib/app'),
        p1Key, p2Key, boardId;
    
    describe('Make moves | ', function() {
      before(function(done){
        client.flushall(function(err, res){
          if (err) return done(err);
          done();
        });
      });
    
      it('create a game', function(done) {
        request(app).post('/create')
          .send({name: 'express'})
          .expect(200)
          .end(function(err, res) {
            p1Key = res.body.p1Key;
            boardId = res.body.boardId;
            done();
          });
      });
    
      it('join a game', function(done) {
        request(app).post('/join')
          .send({name: 'koa'})
          .expect(200)
          .end(function(err, res) {
            p2Key = res.body.p2Key;
            done();
          });
      });

    The first test creates the game and the second test joins it. The next six tests are validation tests to make sure that the requests are valid.

  2. Make sure that the X-Player-Token is present:
      it('Cannot move without X-Player-Token', function(done) {
        request(app).put('/board/' + boardId)
          .send({column: 1})
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Missing X-Player-Token!'),
            done();
          });
      });
  3. Make sure that the X-Player-Token is the correct one:
      it('Cannot move with wrong X-Player-Token', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', 'wrong token!')
          .send({column: 1})
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Wrong X-Player-Token!'),
            done();
          });
      });
  4. Make sure that the board you move on exists:
      it('Cannot move on unknown board', function(done) {
        request(app).put('/board/3213')
          .set('X-Player-Token', p1Key)
          .send({column: 1})
          .expect(404)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Cannot find board!'),
            done();
          });
      });
  5. Make sure that a column parameter is sent when making a move:
      it('Cannot move without a column', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p2Key)
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Move where? Missing column!'),
            done();
          });
      });
  6. Make sure that the column is not off the board:
      it('Cannot move outside of the board', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p1Key)
          .send({column: 18})
          .expect(200)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Bad move.'),
            done();
          });
      });
  7. Make sure that the wrong player cannot move:
      it('Player 2 should not be able to move!', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p2Key)
          .send({column: 1})
          .expect(400)
          .end(function(err, res) {
            console.log(res.body);
            expect(res.body.error).to.equal('It is not your turn!'),
            done();
          });
      });
  8. Now that we have covered all the validation cases, let's test the entire game play:
    it('Player 1 can move', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p1Key)
          .send({column: 1})
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.p1Key).to.be.undefined;
            expect(b.p2Key).to.be.undefined;
            expect(b.turn).to.equal(2);
            expect(b.board[b.rows-1][0]).to.equal('x'),
            done();
          });
      });
  9. Just a quick check that player 1 cannot move again, before player 2 makes a move:
      it('Player 1 should not be able to move!', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p1Key)
          .send({column: 1})
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('It is not your turn!'),
            done();
          });
      });
    
      it('Player 2 can move', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p2Key)
          .send({column: 1})
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.p1Key).to.be.undefined;
            expect(b.p2Key).to.be.undefined;
            expect(b.turn).to.equal(3);
            expect(b.board[b.rows-2][0]).to.equal('o'),
            done();
          });
      });
  10. The remainder of this test suite plays out a full game. We won't show it all here, but you may refer to the source code. The last three tests are still interesting though because we cover the final game state and prevent any more moves.
      it('Player 1 can double-check victory', function(done) {
        request(app).get('/board/' + boardId)
          .set('X-Player-Token', p1Key)
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.winner).to.equal('express'),
            expect(b.status).to.equal('Game Over.'),
            done();
          });
      });
    
      it('Player 2 is a loser, to be sure', function(done) {
        request(app).get('/board/' + boardId)
          .set('X-Player-Token', p2Key)
          .expect(200)
          .end(function(err, res) {
            var b = res.body;
            expect(b.winner).to.equal('express'),
            expect(b.status).to.equal('Game Over.'),
            done();
          });
      });
    
      it('Player 1 cannot move anymore', function(done) {
        request(app).put('/board/' + boardId)
          .set('X-Player-Token', p1Key)
          .send({column: 3})
          .expect(400)
          .end(function(err, res) {
            expect(res.body.error).to.equal('Game Over. Cannot move anymore!'),
            done();
          });
      });
    });

Now that we have described our expected behavior, let's begin with implementing the move endpoint.

  1. First, let's cover the validation pieces, making the first 8 tests pass.
    app.put('/board/:id', [Validate.move, Validate.token], function(req, res) {
    
        Game.findOne({boardId: req.params.id }, function(err, game) {
  2. We fetch the board that the move is sent to. If we cannot find the board, we should return a 400 error. This should make the test 'Cannot move on unknown board' pass.
          if (!game) {
            return res.status(400).json({
              error: 'Cannot find board!'
            });
          }
  3. If the game is over, you cannot make any moves.
          if(game.status !== 'Game in progress') {
            return res.status(400).json({
              error: 'Game Over. Cannot move anymore!'
            });
          }
  4. The following code will make sure that the token is either p1Key or p2Key. If not, return the 400 error with the according message:
          if(req.headers['x-player-token'] !== game.p1Key && req.headers['x-player-token'] !== game.p2Key) {
            return res.status(400).json({
              error: 'Wrong X-Player-Token!'
            });
          }

Now that we have verified that the token is indeed a valid one, we still need to check if it is your turn.

The game.turn() method will increment with each turn, so we have to take the modulo to check who's turn it is. Incrementing the turn, instead of toggling, will have the benefit of keeping a count on the number of turns that have been played, which will also be handy later, when we want to check whether the board is full, and end in a tie.

Now we know which key to compare the token with.

  1. If your token does not match, then it is not your turn:
          var currentPlayer = (game.turn % 2) === 0 ? 2 : 1;
          var currentPlayerKey = (currentPlayer === 1) ? game.p1Key : game.p2Key;
          if(currentPlayerKey !== req.headers['x-player-token']){
            return res.status(400).json({
              error: 'It is not your turn!'
            });
          }
  2. We added two more validation middleware for this route, move and token, which we can add to the validators library in src/lib/validators.js:
        move: function(req, res, next) {
          if (!req.body.column) {
            return _return400Error(res, 'Move where? Missing column!'),
          }
          next();
        },
        token: function(req, res, next) {
          if (!req.headers['x-player-token']) {
            return _return400Error(res, 'Missing X-Player-Token!'),
          }
          next();
        }
  3. Since we are sending the 400 error four times in the preceding code, let's dry it up and reuse the same helper we had in validators.js, by extracting that helper into src/lib/utils.js:
    var crypto = require('crypto'),
    
    module.exports = {
      randomValueHex: function(len) {
        return crypto.randomBytes(Math.ceil(len/2))
            .toString('hex')
            .slice(0,len);
      },
      // Helper to return 400 error with a custom message
      return400Error: function(res, message) {
        return res.status(400).json({
          error: message
        });
      }
    }
  4. Don't forget to update src/lib/validators.js to use this utils, by replacing the line with the following:
      var _return400Error = require('./utils').return400Error;
  5. Now we can refactor the move route to make a move as follows:
    app.put('/board/:id', [Validate.move, Validate.token], function(req, res) {
    
        Game.findOne({boardId: req.params.id }, function(err, game) {
          if (!game) {
            return _return400Error(res, 'Cannot find board!'),
          }
    
          if(game.status !== 'Game in progress') {
            return _return400Error(res, 'Game Over. Cannot move anymore!'),
          }
    
          if(req.headers['x-player-token'] !== game.p1Key && req.headers['x-player-token'] !== game.p2Key) {
            return _return400Error(res, 'Wrong X-Player-Token!'),
          }
    
          var currentPlayer = (game.turn % 2) === 0 ? 2 : 1;
          var currentPlayerKey = game['p' + currentPlayer + 'Key'];
          if(currentPlayerKey !== req.headers['x-player-token']){
            return _return400Error(res, 'It is not your turn!'),

Much cleaner, ain't it!

For the remainder of the controller logic, we will use the connect4.js library (see Appendix), which implements the makeMove() and checkForVictory() methods.

The makeMove() method will return a new board that results from the move, or return false if the move is invalid. Invalid here means that the column is already full, or the column is out of bounds. No turn validation is done here.

    // Make a move, which returns a new board; returns false if the move is invalid
      var newBoard = connect4.makeMove(currentPlayer, req.body.column, game.board);
      if(newBoard){
        game.board = newBoard;
        game.markModified('board'),
      } else {
        return _return400Error(res, 'Bad move.'),
      }

One really important thing to point out is the line game.markModified('board'). Since we are using a 2D array for board, Mongoose is unable to auto-detect any changes. It can only do so with the basic field types. So if we do not explicitly mark the board as modified, it will not persist any changes when we call game.save!

      // Check if you just won
      var win = connect4.checkForVictory(currentPlayer, req.body.column, newBoard);
      if(win) {
        game.winner = game['p'+ currentPlayer + 'Name'];
        game.status = 'Game Over.';
      } else if(game.turn >= game.columns*game.rows) {
        game.winner = 'Game ended in a tie!';
        game.status = 'Game Over.';
      }

The checkForVictory() method is a predicate that will check for victory based on the last move by the last player. We don't need to be checking the entire board each time. If the last move was a winning move, this method will return true; otherwise, it will return false.

      // Increment turns
      game.turn++;

      game.save(function(err, game){
        if (err) return res.status(500).json(err);
        return res.status(200).json(_sanitizeReturn(game));
      });
    });
  });

It is a good idea to keep the controller logic as thin as possible and defer as much of the business logic as possible to the libraries or models. This decoupling and separation of concerns improves maintainability and testability, as well as modularity and reusability. Given the current architecture, it would be very easy to reuse the core components of our application in another Express project.

Testing for a tie

The only thing we haven't covered yet in our test suite is a tie game. We could create another test suite that would fill the entire board manually using 42 individual moves, but that would be too tedious. So let's fill the board programmatically.

That may sound easy, but it can be a bit tricky with JavaScript's asynchronous control flow. What would happen if we were to simply wrap the move request in a for loop?

In short, it would be a mess. All requests would go out at the same time, and there will be no order. And how would you know that all moves are finished? You would need to maintain a global state counter that increments with each callback.

This is where the async library becomes indispensable from Github.

Async is a utility module, which provides straightforward, powerful functions to work with asynchronous JavaScript.

There is so much that you can do with async that would make your life easier; definitely a library that you should acquaint yourself with and add to your toolbox.

In our situation, we will use async.series, which allows us to send a flight of requests serially. Each request will wait until the previous request has returned.

Note

Run the functions in the tasks array in series, each one running once the previous function has completed. If any functions in the series pass an error to its callback, no more functions can be run, and callback is immediately called with the value of the error; otherwise, callback receives an array of results when tasks are completed.

So to prepare our moves to be passed to async.series, we will use the following helper to create a thunk:

function makeMoveThunk(player, column) {
  return function(done) {
    var token = player === 1 ? p1Key : p2Key;
    request(app).put('/board/' + boardId)
      .set('X-Player-Token', token)
      .send({column: column})
      .end(done);
  };
}

A thunk is simply a subroutine; in this case calling the API to make a move, that is wrapped in a function, to be executed later. In this case, we create a thunk that accepts a callback parameter (as required by async), which notifies async that we're done.

Now we can fill the board programmatically and check for the tie state:

it('Fill the board! Tie the game!', function(done) {
    var moves = [],
        turn = 1,
        nextMove = 1;

    for(var r = 0; r < rows; r++) {
      for(var c = 1; c <= columns; c++) {
        moves.push(makeMoveThunk(turn, nextMove));
        turn = turn === 1 ? 2 : 1;
        nextMove = ((nextMove + 2) % columns) + 1;
      }
    }

    async.series(moves, function(err, res) {
      var lastResponse = res[rows*columns-1].body;
      console.log(lastResponse);
      expect(lastResponse.winner).to.equal('Game ended in a tie!'),
      expect(lastResponse.status).to.equal('Game Over.'),
      done();
    });
  });
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.37.126