Building a multi-player board game with Firebase Firestore & Functions

Philippe Martin
9 min readDec 1, 2017

We will discover in this article how we can easily build a multi-player board game with the precious help of Firebase, and more especially the Cloud Firestore and Functions features.

The steps in this article will be to:

  • create a new Firebase project from the Firebase console,
  • Activate Cloud Firestore,
  • Activate Functions and create a local project in your development machine,
  • Define the user stories and documents structure in Cloud Firestore,
  • Create your first trigger, using Typescript.

You can access the sources of the project at: https://github.com/feloy/ludoio/

Creating a new Firebase project

To create a new Firebase project, you just need to go to http://firebase.google.com, connect with your Google account, go to the console and then Add Project.

Activating Cloud Firestore

Once in the console for your newly created project, you can select the menu entry Develop » Database, then select Cloud Firestore and Try Firestore Beta.

You have the choice to select between Start in lock mode and Start in test mode. For this tutorial, we will choose the lock mode.

At this point, you would be ready to create documents in your Cloud Firestore instance of your project.

Activating Functions and creating a local project

In the console for your project, select Develop » Functions and follow the instructions given by the console on your local machine:

  • install the firebase-tools node package by running the following command:
$ npm install -g firebase-tools
  • create a new directory for your project and initiate it:
$ mkdir ludo && cd $_ && firebase init
  • The firebase init command is interactive and will prompt you for several choices,
  • select Firestore and Functions as features to install,
  • select the project your previously created from the console,
  • continue by selecting the default options for Firestore Rules and Firestore Indexes configuration files,
  • Select Typescript to write Cloud Functions and choose to use TSLint.

The different files created by the firebase init command are:

  • firebase.json: the configuration of your project, describing the files to deploy,
  • firestore.rules: the rules protecting your documents in Firestore,
  • firestore.indexes.json: the indexes to be created, essential to access your documents quickly,
  • functions/: the directory containing your Functions project,
  • functions/package.json: the configuration file for your Functions project,
  • functions/index.js: the main source file for your Functions.

If you compare the firestore.rules created by the previous command and the Firestore rules visible in the Console (in Develop » Database » Rules), you can see that the firebase init command selected for you the test mode. You can edit the firestore.rules file so it contains the same rules visible in the Console:

service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}

You can now experiment the deployment of your project. You should get this output:

$ firebase deploy 
=== Deploying to 'ludo-123456'...
i deploying firestore, functions
i firestore: checking firestore.rules for compilation errors...
✔ firestore: rules file firestore.rules compiled successfully
i functions: ensuring necessary APIs are enabled...
i runtimeconfig: ensuring necessary APIs are enabled...
✔ functions: all necessary APIs are enabled
✔ runtimeconfig: all necessary APIs are enabled
i firestore: uploading rules firestore.rules...
i functions: preparing functions directory for uploading...
✔ firestore: released rules firestore.rules to cloud.firestore
✔ Deploy complete!

Defining user stories and documents structure in Firestore

We will create a Ludo game. This game can be played from two to four players.

When a player wants to play, he indicates his name and how many players he wants to play with. If some other players are waiting to play with the same number of players, he will join these players.

For this, we will create two collections: players and rooms. When a player wants to play, he will create a player document in the players collection, writing in this document his name and the number of players he wants to play with: { name: “Johnny”, players: 2 }.

Thanks to the Functions feature, we will be able to create a trigger when a new player document is added. This trigger’s responsability will be to add this new player to a room with a corresponding size. If such a room does not exist, it will first have to create one. It will then add a reference to the room in the player document so the player can know in which room and with which other players he will play.

After adding the player, if the room is full, the trigger will also be responsible to start the game. We will see that later.

Creating a trigger

We have to create a trigger that will be called when a document is created in the players collection.

The Firebase documentation explains how to create such a trigger: https://firebase.google.com/docs/functions/firestore-events

In JavaScript, we would write:

const functions = require('firebase-functions');exports.playerCreated = functions.firestore
.document('players/{playerId}')
.onCreate(event => {
// trigger content...
});

Using TypeScript

In this article, we will use the TypeScript language instead of JavaScript. First install the necessary packages:

$ npm install --save-dev typescript ts-loader webpack webpack-node-externals

Create a tsconfig.json file in the functions/ directory:

{
"compilerOptions": {
"lib": [
"es6",
"es2015.promise"
],
"module": "commonjs",
"noImplicitAny": false,
"outDir": "",
"sourceMap": true,
"target": "es5",
"typeRoots": [
"node_modules/@types"
]
},
"include": [
"src/**/*.ts",
"spec/**/*.ts"
]
}

And a webpack.config.js file, also in the functions/ directory:

'use strict';
var nodeExternals = require('webpack-node-externals');
module.exports = {
entry: './src/index.ts',
output: {
filename: 'index.js',
libraryTarget: 'this'
},
target: 'node',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
externals: [nodeExternals()]
};

We can now move the index.js file in the src/ directory and change its extension to .ts:

$ mkdir src && mv index.js src/index.ts

And finally add a build command to your package.json file:

{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase serve --only functions",
"shell": "firebase experimental:functions:shell",
"start": "npm run shell",
"build": "webpack",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
[...]
}

You can now build your JavaScript file and deploy it with the following commands, from the functions/ directory:

$ npm run build && npm run deploy

Now adapt your src/index.ts file for the TypeScript language:

import * as functions from 'firebase-functions';export const playerCreated = functions.firestore
.document('players/{playerId}')
.onCreate((event: functions.Event<functions.firestore.DeltaDocumentSnapshot>) => {
// trigger content...
});

Writing the trigger content

In the trigger, we first have to search for a room with a specific number of players. If such a room does not exist, we have to create one. Finally, we have to add the newly created player to the room and update the player document to add the room id.

Because we are in an asynchronous environment, we have to make atomic this series of operations. Otherwise, two triggers started at the same time would find the same room with only one place left and add the two users to this same room.

Hopefully, the Cloud Firestore API provides Transactions for this case. On such a transaction, all read operations must be performed before all write operations and the transaction is replayed if data read at the beginning is written by another transaction before the end of this transaction.

Let’s go. First, we create interfaces describing the structure of our player and room documents, on a top-level directory of our project, so they will be accessible to Functions but also to other programs (ui, admin tools, etc).

// structs/player.ts
export interface Player {
name: string;
players: number;
}
// structs/room.ts
export interface Room {
full: boolean;
size: number;
players: string[];
}

And here is the code for our first trigger:

// src/index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin'
import { Player } from '../../structs/player';
import { Room } from '../../structs/room';
admin.initializeApp(functions.config().firebase);export const playerCreated = functions.firestore
.document('players/{playerId}')
.onCreate((event: functions.Event<functions.firestore.DeltaDocumentSnapshot>) => {
const playerId = event.data.id;
const player: Player = event.data.data();
const db = admin.firestore();
return db.runTransaction((trs: FirebaseFirestore.Transaction) => {
return Rooms.findFreeRoom(db, trs, player.players)
.then((roomResult: FirebaseFirestore.QuerySnapshot) => {
var roomId;
if (roomResult.size == 1) {
// a room was found, add the player to it
const roomSnapshot: FirebaseFirestore.DocumentSnapshot = roomResult.docs[0];
const room: Room = <Room> roomSnapshot.data();
const players = [...room.players, playerId];
const full = players.length == room.size;
const newRoomData: Room = { full, size: room.size, players };
trs.set(roomSnapshot.ref, newRoomData);
roomId = roomSnapshot.id;
} else {
// no room was found, create a new room with the player
const players = [playerId];
const roomRef: FirebaseFirestore.DocumentReference = db.collection('rooms').doc();
trs.set(roomRef, { full: false, size: player.players, players });
roomId = roomRef.id;
}
// then add a reference to the room in the player document
trs.update(db.collection('players').doc(playerId), { roomId });
});
});
});
class Rooms {
/**
* Search a non full room of a specific size
*
* @param db The database connection
* @param trs The transaction in which to execute the request
* @param size The number of players in the room
*/
static findFreeRoom(
db: FirebaseFirestore.Firestore,
trs: FirebaseFirestore.Transaction,
size: number): Promise<FirebaseFirestore.QuerySnapshot> {
return trs.get(db.collection('rooms')
.where('full', '==', false)
.where('size', '==', size)
.limit(1));
}
}

Testing our trigger from the Firebase console

Back to the Firebase > Database console, you begin with no collection nor document.

Let’s begin by adding a new players collection:

The console automatically propose you to add a new player document. Let’s add a first nephew wanting to play with three players:

We can see that the player is added and that a roomId field appeared:

And we can also see that a new rooms collection and a new room document was created (you will probably have to reload the page to see them):

After adding the three nephews, all wanting to play with three players, we can see that all three players were added to the same room, which is now full.

Testing the trigger from a script

We previously tested our trigger from the console, but it is not sufficient for testing that the transactions work correctly. Let’s write a script that add several players very quickly.

For this, we will create a new directory scripts at the top of our project, and populate it with NPM, TypeScript and WebPack configuration files.You can find all the details at: https://github.com/feloy/ludoio/

We will use the Admin SDK and a ‘service account’ to access the Firestore documents.

We first have to get credentials for accessing our data as an admin user. From the Firebase console, go to the ‘Project Settings’ (accessible from the icon next to ‘Project Overview’ then choose the ‘Service Accounts’ tab. The Node.js code visible in the tab will give you information to adapt the code below. You’ll have to ‘Generate a New Private Key’ and save it as serviceAccountKey.json on the root directory of your poject.

Finally, here is the code that creates five players, three of them wanting to play with three players and two of them wanting to play with two players:

// scripts/src/test-create-players.tsimport * as admin from 'firebase-admin'
const serviceAccount = require("../../serviceAccountKey.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: "https://ludoio-f31b4.firebaseio.com"
});
function addPlayer(name: string, players: number) {
return admin.firestore().collection('players').doc().create({ name, players });
}
addPlayer('Huey', 3).then(() => console.log('added Huey'));
addPlayer('Foo', 2).then(() => console.log('added Foo'));
addPlayer('Dewey', 3).then(() => console.log('added Dewey'));
addPlayer('Bar', 2).then(() => console.log('added Bar'));
addPlayer('Louie', 3).then(() => console.log('added Louie'));

Let’s build and run it:

$ cd scripts && npm run build && node ./test-create-players.js
[...]
added Louie
added Foo
added Bar
added Huey
added Dewey

You can see from the output of the script that the different players are added in a random order, because all are added very quickly.

Now let’s go and examine your Firestore data from the Firebase console. You should see only two rooms, all full, with the correct players in each.

--

--