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

This is the 4th 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:

Implementing account system (players login)

In this post, we will add an account system on top of the Tic-Tac-Toe game we built earlier.

Install package

Meteor is shipped with a full-fledged account system. To enable Meteor accounts, add the accounts-password package by executing:

$ meteor add accounts-password
Login Form

Our primary focus is to identifier users and control their permissions to make game move, we will try to skip the standard registration/login/forget password process as much as possible. Please check out Meteor doc - accounts section for details in setting up a full-fledged accounts system.

In this section, we will make a simple login page, which accept a single username. Once login, user will be redirected to the game list view.

To do this, we will create a new React component <LoginForm>, which has the following skeleton:

import { Random } from 'meteor/random'  
import React, { Component } from 'react';

export default class LoginForm extends Component {

render() {  
  return (
    <form name="login-form">  
      <h1>Login</h1>
      <input type="text" placeholder="Enter your name"/>
      <input type="submit" value="Login"/>
    </form>
  )
}

Just a simple form with a text input and a submit button, and this component will contain state property username:

  constructor(props) {
    super(props);
    this.state = {
      username: '',
    }
  }

and this username property will bind to the current text input content through the onChange callback, i.e.

  handleUsernameChange(e) {
    this.setState({username: e.target.value});
  }
<input type="text" onChange={this.handleUsernameChange.bind(this)} placeholder="Enter your name"/>  

Next, we will override the form default submit action, and execute our handleSubmit function instead. i.e.

<form name="login-form" onSubmit={this.handleSubmit.bind(this)}>  
.....
</form>  
handleSubmit(e) {  
  e.preventDefault();

  let username = this.state.username.trim();  
  if (username === '') return;
  Accounts.createUser({
    username: username,
    password: Random.secret()
  });
}

Accounts.createUser() is provided in account package we installed earlier. This function will:

  1. create a user in database, and
  2. automatically login as the current user.

Altogether, we should create a new file ./imports/ui/LoginForm.jsx with the following content:

import { Random } from 'meteor/random'  
import React, { Component } from 'react';

export default class LoginForm extends Component {  
  constructor(props) {
    super(props);
    this.state = {
      username: '',
    }
  }

  handleUsernameChange(e) {
    this.setState({username: e.target.value});
  }

  handleSubmit(e) {
    e.preventDefault();

  let username = this.state.username.trim();
    if (username === '') return;
    Accounts.createUser({
      username: username,
      password: Random.secret()
    });
  }

  render() {
    return (
      <form name="login-form" onSubmit={this.handleSubmit.bind(this)}>
        <h1>Login</h1>
        <input type="text" onChange={this.handleUsernameChange.bind(this)} placeholder="Enter your name"/>
        <input type="submit" value="Login"/>
      </form>
    )
  }
}
Attaching the Login View to App

With the form ready, we will now update the <App> component to show the form for non-logged-in users.

First, Inside createContainer, we will pass in an extra property user:

export default createContainer(() => {  
  return {
    user: Meteor.user(),
    games: Games.find().fetch()
  };
}, App);

Note: Meteor.user() will return the current user, or null if not logged in.

Then, inside render() method, we simply add another condition: showing the <LoginForm> for non-logged-in users:

render() {  
  if (!this.props.user) {
    return (
      <div>
        <LoginForm/>
      </div>
    )
  }

We also need to also pass in the user into the sub-components.

<GameList  
          games={this.props.games}
          enterGameHandler={this.handleEnterGame.bind(this)}
          user={this.props.user}/>
<GameBoard  
          game={this.selectedGame()}
          backToGameListHandler={this.handleBackToGameList.bind(this)}
          user={this.props.user}/>

Everything else should stay the same, so at the end, the ./imports/ui/App.jsx file should have the following content:

import React, { Component } from 'react';  
import { createContainer } from 'meteor/react-meteor-data';  
import Games from '../api/collections/games.js';  
import GameList from './GameList.jsx';  
import GameBoard from './GameBoard.jsx';  
import LoginForm from './LoginForm.jsx';

class App extends Component {  
  constructor(props) {
    super(props);
    this.state = {
      selectedGameId: null,
    }
  }

handleEnterGame(gameId) {  
    this.setState({selectedGameId: gameId});
  }

handleBackToGameList() {  
    this.setState({selectedGameId: null});
  }

selectedGame() {  
    let selectedGame = _.find(this.props.games, (game) => {
      return game._id === this.state.selectedGameId;
    });
    return selectedGame;
  }

render() {  
    if (!this.props.user) {
      return (
        <div>
          <LoginForm/>
        </div>
      )
    }

if (this.state.selectedGameId === null) {  
      return (
        <GameList
          games={this.props.games}
          enterGameHandler={this.handleEnterGame.bind(this)}
          user={this.props.user}/>
      )
    } else {
      return (
        <GameBoard
          game={this.selectedGame()}
          backToGameListHandler={this.handleBackToGameList.bind(this)}
          user={this.props.user}/>
      )
    }
  }
}

export default createContainer(() => {  
  return {
    user: Meteor.user(),
    games: Games.find().fetch()
  };
}, App);

Assigning users to games

The login process is now ready. In the next step, we want to assign users to games, meaning that each games should have two participating players, and only they can make moves on the game board.

Adding players info in game document

Previously, our game document contains a single board property. We will now add a second one named players which is a list of two containing the information of the two participating players. So when we create a new game document, we do:

  newGame() {
    let gameDoc = {
      board: [[null, null, null], [null, null, null], [null, null, null]],
      players: []
    };
    let gameId = Games.insert(gameDoc);
    return gameId;
  }

when the game is joined by a user, we will push the user information into the players list, i.e.

  joinGame(gameId, user) {
    let game = Games.findOne(gameId);
    game.players.push({
      userId: user._id,
      username: user.username
    });
    Games.update(game._id, {
      $set: {players: game.players}
    });
  },

Similar, when a user leave a game, he/she get removed from the players list, i.e.

  leaveGame(gameId, user) {
    let game = Games.findOne(gameId);
    game.players = _.reject(game.players, (player) => {
      return player.userId === user._id;
    });
    Games.update(game._id, {
      $set: {players: game.players}
    });
  }
});

For the moment, we will extend the Games Collection with the above three methods. So update the ./imports/api/collections/games.js file with the following content:

import { Mongo } from 'meteor/mongo';

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

_.extend(Games, {  
  newGame() {
    let gameDoc = {
      board: [[null, null, null], [null, null, null], [null, null, null]],
      players: []
    };
    let gameId = Games.insert(gameDoc); // insert a new game document into the collection
    return gameId;
  },

  joinGame(gameId, user) {
    let game = Games.findOne(gameId);
    if (game.players.length === 2) {
      throw "game is full";
    }
    game.players.push({
      userId: user._id,
      username: user.username
    });
    Games.update(game._id, {
      $set: {players: game.players}
    });
  },

  leaveGame(gameId, user) {
    let game = Games.findOne(gameId);
    game.players = _.reject(game.players, (player) => {
      return player.userId === user._id;
    });
    Games.update(game._id, {
      $set: {players: game.players}
    });
  }
});
Updating GameList

We will see the result soon, but keep going and follow through the updates on the UI.

First, we would like to also show the players information on the <GameList>. We will add a renderPlayers() helper which return a simple string wrapped in <span>.

  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>
    )   
  }

and render it just after the game number:

...
<span>Game {index+1}</span>  
{this.renderPlayers(game)}
...

We will also extend each entries of the game list to include 2 buttons "Join" and "Leave". These buttons will be shown under certain conditions:

{this.props.games.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.players.length < 2? (
      <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.players.length < 2? (
      <button onClick={this.handleJoinGame.bind(this, game._id)}>Join</button>
    ): null}

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

Notes:
1. The game is considered started when it contains 2 players.
2. "Join" button is shown only when the game is not started, and the user is not currently in any game.
3. "Leave" button is shown only when the game is not started and the user has joined the game.
4. "Enter" button is shown only when the game is started

As you can see, we have make use of a helper method myCurrentGameId which retrieve the current game where the user is in:

myCurrentGameId() {  
  let game = _.find(this.props.games, (game) => {
    return _.find(game.players, (player) => {
      return player.userId === this.props.user._id;
      }) !== undefined;
    });

  if (game === undefined) return null;
  return game._id;
}

The "join" and "leave" handlers will now call the newly created methods we declared in the Games Collection previously:

  handleLeaveGame(gameId) {
    Games.leaveGame(gameId, this.props.user);
  }

  handleJoinGame(gameId) {
    Games.joinGame(gameId, this.props.user);
  }

Finally, we will also make a little tweak on the "New Game" button:

  1. It will be shown only when the user is not currently in any games
  2. the handleNewGame is refactored to make use of the newGame method declared in Games Collection.
{/* 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}
handleNewGame() {  
  Games.newGame(this.props.user);
}

There are quite a lot of changes. Please check and see if your final ./imports/ui/GameList.jsx looks like:

import React, { Component } from 'react';  
import Games from '../api/collections/games.js';

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

handleLeaveGame(gameId) {  
    Games.leaveGame(gameId, this.props.user);
  }

handleJoinGame(gameId) {  
    Games.joinGame(gameId, this.props.user);
  }

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

myCurrentGameId() {  
    // find game where the user is currently in
    let game = _.find(this.props.games, (game) => {
      return _.find(game.players, (player) => {
        return player.userId === this.props.user._id;
      }) !== undefined;
    });
    if (game === undefined) return null;
    return 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.props.games.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.players.length < 2? (
                <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.players.length < 2? (
                <button onClick={this.handleJoinGame.bind(this, game._id)}>Join</button>
              ): null}

{/* can enter only if the game is started */}
              {game.players.length === 2? (
                <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>
    )
  }
}
Updating GameBoard

There are relatively less changes required in <GameBoard>. The only thing we want to do is to control the players move - only the current user can place mark on the board.

To do this, we just need to add a single line

if (game.players[currentPlayer].userId !== this.props.user._id) return;  

in the handleCellClick() method:

handleCellClick(row, col) {  
  let currentPlayer = this.currentPlayer();
  let game = this.props.game;

  if (game.players[currentPlayer].userId !== this.props.user._id) return;  // NEW line - checking current user

  game.board[row][col] = currentPlayer;
  Games.update(game._id, {
    $set: {board: game.board}
  });
}

It checks whether the user id of the current player matched the logged in user id. That’s it. Now we have games that only the participating users can make moves.

Checking the result

We can now check the result. However, because we have updated the game document structure with an extra players property. The existing documents in database will not work, and will actually crash the frontend. So we need to clear the games collections first. If you don't remember how to do that, checkout the previous post.

If you open the page at http://localhost:3000 now, we will see the Login view. Since we don't have a logout function yet, if you want to logout and test things out, you can open the browser console and execute Meteor.logout(). Another thing to note is that if you login with the same username used before, it will not work because Meteor account package will validate the uniqueness of usernames. We will add error message for that later.

Conclusion

To summarize, we have implemented a simple account system to control game matching and playing.

However, as you can see, our code started to get a bit messy, due to the numerous game states (whether it’s started, or who’s the current player). You probably have also noticed that our games won’t "finish", and therefore players are forbidden to join other games.

In the next post, we will to so some refactoring on the code, trying to encapsulate the game logics (including states and actions). We will create a Meteor-Independent game model, allowing easy unit testing. This step is particular important if we are to create more complicated games. I'll also share some insights on how ORM (Object Relationship Mapping) work in Meteor.

Next post:

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