Building online multiplayers games with Meteor [codes + tutorials] - 5

This is the 5th post of a blog series. Please refer to the introductory post for the series introduction.

Source Code

Source code at the end of the previous part:

Source code at the end of the this part:

Encapsulating game logics (ORM in Meteor)

If you have followed through our previous sections, you might have started to think that the code is getting messy. Some game logics remain in the Games Collection while some logics existed on the UI. The objective of this section is to create a generic game model to encapsulate those. The end result is that we will have a Meteor independent game class which can be unit test easily. We will also see how to bind this game model with Meteor Collection to utilize data synchronization. You will also have some ideas on how ORM (Object Relationship Mapping) work in Meteor, although some people are against this.

Game model

States is a very common pattern in game modelling. It dictates the lifecycle of a game and control what actions are permitted at any particular time. We will start with four states (statuses):

export const GameStatuses = {  
  WAITING: 'WAITING',  // waiting player to join
  STARTED: 'STARTED',  // all spots are filled; can start playing
  FINISHED: 'FINISHED', // game is finished
  ABANDONED: 'ABANDONED' // all players left; game is abandoned
}

and the Game model will be realized in a standard javascript Class, which has the following skeleton:

export class Game {  
  constructor() {
    this.status = GameStatuses.WAITING;
    this.board = [[null, null, null], [null, null, null], [null, null, null]];
    this.players = [];
  }

/**
   * Handle join game action
   *
   * @param {User} user Meteor.user object
   */
  userJoin(user) {
    // TODO
  }

/**
   * Handle leave game action
   *
   * @param {User} user Meteor.user object
   */
  userLeave(user) {
    // TODO
  }

/**
   * Handle user action. i.e. putting marker on the game board
   *
   * @param {User} user
   * @param {Number} row Row index of the board
   * @param {Number} col Col index of the board
   */
  userMark(user, row, col) {
    // TODO
  }
}

In the constructor, we will initiate the object properties: status, board and players. status can be one of the 4 values declared in GameStatuses. The game class contains 3 methods userJoin, userLeave and userMark, which are self-explanatory.

Let's fill the userJoin method as an example:

  userJoin(user) {
    // pre-action validation
    if (this.status !== GameStatuses.WAITING) {
      throw "cannot join at current state";
    }
    if (this.userIndex(user) !== null) {
      throw "user already in game";
    }

    // actual action
    this.players.push({
      userId: user._id,
      username: user.username
    });

    // post-action
    if (this.players.length === 2) {
      this.status = GameStatuses.STARTED;
    }
  }

  userIndex(user) {
    for (let i = 0; i < this.players.length; i++) {
      if (this.players[i].userId === user._id) {
        return i;
      }
    }
    return null;
  }

If you look carefully, the method can be divided into three parts. First, we do validation - check to make sure the game is not started and the user is not currently in the game. Second, we do the actual work by pushing the user info in the players list. Finally, there is an "after-action", which update the status to STARTED if all 2 spots are filled.

userLeave and userMark are filled with similar concepts:

  userLeave(user) {
    if (this.status !== GameStatuses.WAITING) {
      throw "cannot leave at current state";
    }
    if (this.userIndex(user) === null) {
      throw "user not in game";
    }
    this.players = _.reject(this.players, (player) => {
      return player.userId === user._id;
    });

    // game is considered abandoned when all players left
    if (this.players.length === 0) {
      this.status = GameStatuses.ABANDONED;
    }
  }

  userMark(user, row, col) {
    let playerIndex = this.userIndex(user);
    let currentPlayerIndex = this.currentPlayerIndex();
    if (currentPlayerIndex !== playerIndex) {
      throw "user cannot make move at current state";
    }
    if (row < 0 || row >= this.board.length || col < 0 || col >= this.board[row].length) {
      throw "invalid row|col input";
    }
    if (this.board[row][col] !== null) {
      throw "spot is filled";
    }
    this.board[row][col] = playerIndex;

    let winner = this.winner();
    if (winner !== null) {
      this.status = GameStatuses.FINISHED;
    }
    if (this._filledCount() === 9) {
      this.status = GameStatuses.FINISHED;
    } 
  }

  /**
   * @return {Number} currentPlayerIndex 0 or 1
   */
  currentPlayerIndex() {
    if (this.status !== GameStatuses.STARTED) {
      return null;
    }

    // determine the current player by counting the filled cells
    // if even, then it's first player, otherwise it's second player
    let filledCount = this._filledCount();
    return (filledCount % 2 === 0? 0: 1);
  }

 /**
   * Determine the winner of the game
   *
   * @return {Number} playerIndex of the winner (0 or 1). null if not finished
   */
  winner() {
    let board = this.board;
    for (let playerIndex = 0; playerIndex < 2; playerIndex++) {
      // check rows
      for (let r = 0; r < 3; r++) {
        let allMarked = true;
        for (let c = 0; c < 3; c++) {
          if (board[r][c] !== playerIndex) allMarked = false;
        }
        if (allMarked) return playerIndex;
      }

      // check cols
      for (let c = 0; c < 3; c++) {
        let allMarked = true;
        for (let r = 0; r < 3; r++) {
          if (board[r][c] !== playerIndex) allMarked = false;
        }
        if (allMarked) return playerIndex;
      }

      // check diagonals
      if (board[0][0] === playerIndex && board[1][1] === playerIndex && board[2][2] === playerIndex) {
        return playerIndex;
      }
      if (board[0][2] === playerIndex && board[1][1] === playerIndex && board[2][0] === playerIndex) {
        return playerIndex;
      }
    }
    return null;
  }

 /**
   * Helper method to retrieve the player index of a user
   *
   * @param {User} user Meteor.user object
   * @return {Number} index 0-based index, or null if not found
   */
  userIndex(user) {
    for (let i = 0; i < this.players.length; i++) {
      if (this.players[i].userId === user._id) {
        return i;
      }
    }
    return null;
  }

  _filledCount() {
    let filledCount = 0;
    for (let r = 0; r < 3; r++) {
      for (let c = 0; c < 3; c++) {
        if (this.board[r][c] !== null) filledCount++;
      }
    }
    return filledCount;
  }
}

Do take some time to digest the code, but everything is standard javascript, independent of Meteor or React.

Actually, a even better way to build the game class is to use State Machine, where join, leave, and mark would be actions. Actions are allowed in certain states, and actions will trigger state transitions. If you are interested to go further, check out this Meteor package - https://github.com/nate-strauser/meteor-statemachine - which is a wrapper of a popular javascript state machine library. But for simplicity, we will not go into details of state machines in this tutorial.

Data synchronization and persistence

Next comes the interesting part. Now that we have a generic game class (to instantiate game instances), how do we connect it with Meteor Collections to utilize the seamless synchronization and data storage provided out of the box? The simple answer is ORM (Object Relationship Mapping).

The basic idea is that: We always create game instances through the Game class constructor, which allow us to play with all the declared properties and methods. When we save them into the database, we should convert the game instances into pure JSON document for MongoDB. The saved documents (backed by Meteor Collections) are then synchronized across clients and server automatically. Later, when we retrieve the documents (no matter on clients or server), they should get converted back to game instances. In the other words, in code level, we should be always working with the game instances instantiated through Game class.

OK. let's make that happens. First, we will update the Game constructor to allow two ways of instantiation:

  constructor(gameDoc) {
    if (gameDoc) {
      _.extend(this, gameDoc);
    } else {
      this.status = GameStatuses.WAITING;
      this.board = [[null, null, null], [null, null, null], [null, null, null]];
      this.players = [];
    }
  }

The constructor will accept an optional parameter gameDoc. Without it, game instance will be created with default properties. On the contrary, if it is given, we assign the values to the game instance accordingly. In this particular case, gameDoc is supposed to contains three properties status, board, and players.

To explicitly tell the outside world these three fields should persist for recovery, we will add an extra helper method:

persistentFields() {  
  return ['status', 'board', 'players'];
}

You will see how they are being used shortly. But for the moment, let's put everything together and create a ./imports/api/models/game.js file with the following content:

/**
 * GameStatus constants
 */
export const GameStatuses = {  
  WAITING: 'WAITING',  // waiting player to join
  STARTED: 'STARTED',  // all spots are filled; can start playing
  FINISHED: 'FINISHED', // game is finished
  ABANDONED: 'ABANDONED' // all players left; game is abandoned
}

/**
 * Game model, encapsulating game-related logics
 * It is data store independent
 */
export class Game {  
  /**
   * Constructor accepting a single param gameDoc.
   * gameDoc should contain the permanent fields of the game instance.
   * Normally, the fields are saved into data store, and later get retrieved
   *
   * If gameDoc is not given, then we will instantiate a new object with default fields
   *
   * @param {Object} [gameDoc] Optional doc retrieved from Games collection
   */
  constructor(gameDoc) {
    if (gameDoc) {
      _.extend(this, gameDoc);
    } else {
      this.status = GameStatuses.WAITING;
      this.board = [[null, null, null], [null, null, null], [null, null, null]];
      this.players = [];
    }
  }

/**
   * Return a list of fields that are required for permanent storage
   *
   * @return {[]String] List of fields required persistent storage
   */
  persistentFields() {
    return ['status', 'board', 'players'];
  }

/**
   * Handle join game action
   *
   * @param {User} user Meteor.user object
   */
  userJoin(user) {
    if (this.status !== GameStatuses.WAITING) {
      throw "cannot join at current state";
    }
    if (this.userIndex(user) !== null) {
      throw "user already in game";
    }

this.players.push({  
      userId: user._id,
      username: user.username
    });

// game automatically start with 2 players
    if (this.players.length === 2) {
      this.status = GameStatuses.STARTED;
    }
  }

/**
   * Handle leave game action
   *
   * @param {User} user Meteor.user object
   */
  userLeave(user) {
    if (this.status !== GameStatuses.WAITING) {
      throw "cannot leave at current state";
    }
    if (this.userIndex(user) === null) {
      throw "user not in game";
    }
    this.players = _.reject(this.players, (player) => {
      return player.userId === user._id;
    });

// game is considered abandoned when all players left
    if (this.players.length === 0) {
      this.status = GameStatuses.ABANDONED;
    }
  }

/**
   * Handle user action. i.e. putting marker on the game board
   *
   * @param {User} user
   * @param {Number} row Row index of the board
   * @param {Number} col Col index of the board
   */
  userMark(user, row, col) {
    let playerIndex = this.userIndex(user);
    let currentPlayerIndex = this.currentPlayerIndex();
    if (currentPlayerIndex !== playerIndex) {
      throw "user cannot make move at current state";
    }
    if (row < 0 || row >= this.board.length || col < 0 || col >= this.board[row].length) {
      throw "invalid row|col input";
    }
    if (this.board[row][col] !== null) {
      throw "spot is filled";
    }
    this.board[row][col] = playerIndex;

    let winner = this.winner();
    if (winner !== null) {
      this.status = GameStatuses.FINISHED;
    }
    if (this._filledCount() === 9) {
      this.status = GameStatuses.FINISHED;
    }
  }

 /**
   * @return {Number} currentPlayerIndex 0 or 1
   */
  currentPlayerIndex() {
    if (this.status !== GameStatuses.STARTED) {
      return null;
    }

// determine the current player by counting the filled cells
    // if even, then it's first player, otherwise it's second player
    let filledCount = this._filledCount();
    return (filledCount % 2 === 0? 0: 1);
  }

/**
   * Determine the winner of the game
   *
   * @return {Number} playerIndex of the winner (0 or 1). null if not finished
   */
  winner() {
    let board = this.board;
    for (let playerIndex = 0; playerIndex < 2; playerIndex++) {
      // check rows
      for (let r = 0; r < 3; r++) {
        let allMarked = true;
        for (let c = 0; c < 3; c++) {
          if (board[r][c] !== playerIndex) allMarked = false;
        }
        if (allMarked) return playerIndex;
      }

// check cols
      for (let c = 0; c < 3; c++) {
        let allMarked = true;
        for (let r = 0; r < 3; r++) {
          if (board[r][c] !== playerIndex) allMarked = false;
        }
        if (allMarked) return playerIndex;
      }

// check diagonals
      if (board[0][0] === playerIndex && board[1][1] === playerIndex && board[2][2] === playerIndex) {
        return playerIndex;
      }
      if (board[0][2] === playerIndex && board[1][1] === playerIndex && board[2][0] === playerIndex) {
        return playerIndex;
      }
    }
    return null;
  }

/**
   * Helper method to retrieve the player index of a user
   *
   * @param {User} user Meteor.user object
   * @return {Number} index 0-based index, or null if not found
   */
  userIndex(user) {
    for (let i = 0; i < this.players.length; i++) {
      if (this.players[i].userId === user._id) {
        return i;
      }
    }
    return null;
  }

  _filledCount() {
    let filledCount = 0;
    for (let r = 0; r < 3; r++) {
      for (let c = 0; c < 3; c++) {
        if (this.board[r][c] !== null) filledCount++;
      }
    }
    return filledCount;
  }
}
Updating Games Collection

Now, to connect the Game class with the Games Collections, update ./imports/api/collections/games.js by replacing:

export default Games = new Mongo.Collection('games');  

with

export default Games = new Mongo.Collection('games', {  
  transform(doc) {
    return new Game(doc);
  }
});

This is another magic code from Meteor. Whenever we retrieved documents from Games Collection (as in findOne(), or find().fetch()), new Game(doc) will be returned instead of the pure JSON documents.

So how about saving? We will make a saveGame() helper method, which takes a Game instance and store the persistence fields into database:

  saveGame(game) {
    let gameDoc = {};
      _.each(game.persistentFields(), (field) => {
        gameDoc[field] = game[field];
      });

    if (game._id) {
      Games.update(game._id, {
        $set: gameDoc
      });
    } else {
      Games.insert(gameDoc);
    }
  }

Basically, there are two scenarios. If the game object contains an _id field, meaning it's already existed in database, we will do update, otherwise we insert it as a new document. With this helper method, we will later on calling Games.saveGame(game). Putting them together, the final ./imports/api/collections/games.js file should look like:

import { Mongo } from 'meteor/mongo';  
import { Game } from '../models/game.js';

export default Games = new Mongo.Collection('games', {  
  transform(doc) {
    return new Game(doc);
  }
});

_.extend(Games, {  
  saveGame(game) {

    let gameDoc = {};
    _.each(game.persistentFields(), (field) => {
      gameDoc[field] = game[field];
    });

    if (game._id) {
      Games.update(game._id, {
        $set: gameDoc
      });
    } else {
      Games.insert(gameDoc);
    }
  }
});
Games Controller

We would also like to have another layer of abstraction for modifying Games. Let's create one more file ./imports/api/controllers/gamesController.js with the following content:

import {Game} from "../models/game.js";  
import Games from "../collections/games.js";

export let GamesController = {  
  newGame(user) {
    let game = new Game();
    game.userJoin(user);
    Games.saveGame(game);
  },

  userJoinGame(gameId, user) {
    let game = Games.findOne(gameId);
    game.userJoin(user);
    Games.saveGame(game);
  },

  userLeaveGame(gameId, user) {
    let game = Games.findOne(gameId);
    game.userLeave(user);
    Games.saveGame(game);
  },

  userMarkGame(gameId, user, row, col) {
    let game = Games.findOne(gameId);
    game.userMark(user, row, col);
    Games.saveGame(game);
  }
}

We can also put these methods directly on Games Collection, but an extra layer to wrap around "retrieve", "update" and "save" sounds more appropriate.

Updating the UI

The modelling part is all ready. Now we just need to make some tweaks on the UI to make use of the new APIs. The updates are pretty straight forward, so we will go through them quickly.

In ./imports/ui/GameBoard.jsx, simplify the handleCellClick to:

  handleCellClick(row, col) {
    let game = this.props.game;
    if (game.currentPlayerIndex() !== game.userIndex(this.props.user)) return;
    GamesController.userMarkGame(game._id, this.props.user, row, col);
 }

and currentPlayerIndex() method is no longer necessary.

We will also add a code snippet to render a simple text showing the current game status:

  renderStatus() {
    let game = this.props.game;
    let status = "";
    if (game.status === GameStatuses.STARTED) {
      let playerIndex = game.currentPlayerIndex();
      status = `In Progress: current player: ${game.players[playerIndex].username}`;
    } else if (game.status === GameStatuses.FINISHED) {
      let playerIndex = game.winner();
      if (playerIndex === null) {
        status = "Finished: tie";
      } else {
        status = `Finished: winner: ${game.players[playerIndex].username}`;
      }
    }

return (  
      <div>{status}</div>
    )
  }

and render this just above the board <table>

......
{this.renderStatus()}
<table className="game-board">  
......

At the end, the content should look like:

import React, { Component } from 'react';  
import {GamesController} from '../api/controllers/gamesController.js';  
import {Game, GameStatuses} from '../api/models/game.js';

export default class GameBoard extends Component {  
  handleCellClick(row, col) {
    let game = this.props.game;
    if (game.currentPlayerIndex() !== game.userIndex(this.props.user)) return;
    GamesController.userMarkGame(game._id, this.props.user, row, col);
  }

handleBackToGameList() {  
    this.props.backToGameListHandler();
  }

renderCell(row, col) {  
    let value = this.props.game.board[row][col];
    if (value === 0) return (<td>O</td>);
    if (value === 1) return (<td>X</td>);
    if (value === null) return (
      <td onClick={this.handleCellClick.bind(this, row, col)}></td>
    );
  }

renderStatus() {  
    let game = this.props.game;
    let status = "";
    if (game.status === GameStatuses.STARTED) {
      let playerIndex = game.currentPlayerIndex();
      status = `In Progress: current player: ${game.players[playerIndex].username}`;
    } else if (game.status === GameStatuses.FINISHED) {
      let playerIndex = game.winner();
      if (playerIndex === null) {
        status = "Finished: tie";
      } else {
        status = `Finished: winner: ${game.players[playerIndex].username}`;
      }
    }

return (  
      <div>{status}</div>
    )
  }

render() {  
    return (
      <div>
        <button onClick={this.handleBackToGameList.bind(this)}>Back</button>
        {this.renderStatus()}
        <table className="game-board">
          <tbody>
            <tr>
              {this.renderCell(0, 0)}
              {this.renderCell(0, 1)}
              {this.renderCell(0, 2)}
            </tr>
            <tr>
              {this.renderCell(1, 0)}
              {this.renderCell(1, 1)}
              {this.renderCell(1, 2)}
            </tr>
            <tr>
              {this.renderCell(2, 0)}
              {this.renderCell(2, 1)}
              {this.renderCell(2, 2)}
            </tr>
          </tbody>
        </table>
      </div>
    )
  }
}

Similarly, we will update ./imports/ui/GameList.jsx. The content is self-explanatory, so I’ll just put it down without explanation:

import React, { Component } from 'react';  
import {GamesController} from '../api/controllers/gamesController.js';  
import {Game, GameStatuses} from '../api/models/game.js';

export default class GameList extends Component {  
  handleNewGame() {
    GamesController.newGame(this.props.user);
  }

handleLeaveGame(gameId) {  
    GamesController.userLeaveGame(gameId, this.props.user);
  }

handleJoinGame(gameId) {  
    GamesController.userJoinGame(gameId, this.props.user);
  }

handleEnterGame(gameId) {  
    this.props.enterGameHandler(gameId);
  }

activeGames() {  
    return _.filter(this.props.games, (game) => {
      return game.status === GameStatuses.WAITING || game.status === GameStatuses.STARTED;
    });
  }

myCurrentGameId() {  
    let game = _.find(this.activeGames(), (game) => {
      return game.userIndex(this.props.user) !== null;
    });
    return game === undefined? null: game._id;
  }

renderPlayers(game) {  
    let player1 = game.players.length > 0? game.players[0].username: '';
    let player2 = game.players.length > 1? game.players[1].username: '';
    return (
      <span>[{player1}] vs [{player2}]</span>
    )
  }

render() {  
    return (
    <div>
      <div>
        <h1>List of games</h1>
        {this.activeGames().map((game, index) => {
          return (
            <div key={game._id}>
              <span>Game {index+1}</span>
              {this.renderPlayers(game)}

{/* can leave only if user is in the game, and the game is not started */}
              {this.myCurrentGameId() === game._id && game.status === GameStatuses.WAITING? (
                <button onClick={this.handleLeaveGame.bind(this, game._id)}>Leave</button>
              ): null}

{/* can join only if user is not in any game, and the game is not started */}
              {this.myCurrentGameId() === null && game.status === GameStatuses.WAITING? (
                <button onClick={this.handleJoinGame.bind(this, game._id)}>Join</button>
              ): null}

{/* can enter only if the game is started */}
              {game.status === GameStatuses.STARTED? (
                <button onClick={this.handleEnterGame.bind(this, game._id)}>Enter</button>
              ): null}
            </div>
          )
        })}
      </div>

{/* Only show new game button if player is not in any room */}
      {this.myCurrentGameId() === null? (
        <div>
          <button onClick={this.handleNewGame.bind(this)}>New Game</button>
        </div>
      ): null}
    </div>
    )
  }
}
Conclusion

We will end this lengthy section here. Do check out the updated version at http://localhost:3000 and spend some time to digest the code. Although we didn't add too much features in this section, our code now looks much cleaner and writing unit tests on Game class is extremely easy. I would not go into details of writing unit tests in Meteor, but if you are interested, do checkout https://guide.meteor.com/testing.html

Just a final word about Object Relationship Mapping in Meteor. I know some people are seriously against this in funational style of programming like javascript and NoSQL database, but I personally still see the values in it.

Also, what I presented here is just ONE way of doing ORM. In fact, I don't see any standard way so far, although numerous ORM Meteor packages existed. However, I can assure you that most of them are doing more or less the same thing. I guess the most important takeaway of this tutorial is to learn the concept behind rather than focusing on actual implementation.

What's next?

In the next section, we will address some of the security concerns in our current version. As you might have noticed, all the updates and validations are currently done on client side. If you understand what you are doing, you probably have realized that you can easily cheat by opening the browser console window, and execute javascript code directly like:

Games.update("GAME_ID", {$set: board: [[1, 1, 1], [0, 0, null], [0, null, null]]}); // update the game board to make yourself a winner  

or hack the system and remove everything:

Games.remove({});  // remove all the games  

Since all the updates on client sides get synchronized with server, it opens up unlimited possibilities of cheating and hacking. To solve this, we need to validate game actions on the server side, and that’s what we are going to do next!

Next post:

http://blog.hiukim.com/building-online-multiplayers-games-with-meteor-codes-tutorials-6