1. Introduction
Last Updated: 2023-01-27
What does it take to build a leaderboard?
At their core, leaderboards are just tables of scores with one complicating factor: reading a rank for any given score requires knowledge of all the other scores in some kind of order. Also, if your game takes off, your leaderboards will grow large and be read from and written to frequently. To build a successful leaderboard, it needs to be able to handle this ranking operation quickly.
What you'll build
In this codelab, you will implement various different leaderboards, each suitable for a different scenario.
What you'll learn
You'll learn how to implement four different leaderboards:
- A naive implementation using simple record-counting to determine rank
- A cheap, periodically-updating leaderboard
- A real-time leaderboard with some tree nonsense
- A stochastic (probabilistic) leaderboard for approximate ranking of very large player bases
What you'll need
- A recent version of Chrome (107 or later)
- Node.js 16 or higher (run
nvm --versionto see your version number if you're using nvm) - A paid Firebase Blaze plan (optional)
- The Firebase CLI v11.16.0 or higher
To install the CLI, you can runnpm install -g firebase-toolsor refer to the CLI documentation for more installation options. - Knowledge of JavaScript, Cloud Firestore, Cloud Functions, and Chrome DevTools
2. Getting set up
Get the code
We've put everything you need for this project into a Git repo. To get started, you'll need to grab the code and open it in your favorite dev environment. For this codelab, we used VS Code, but any text editor will do.
and unpack the downloaded zip file.
Or, clone into your directory of choice:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
What's our starting point?
Our project is currently a blank slate with some empty functions:
index.htmlcontains some glue scripts that let us invoke functions from the dev console and see their outputs. We'll use this to interface with our backend and see the results of our function invocations. In a real-world scenario, you'd make these backend calls from your game directly—we're not using a game in this codelab because it would take too long to play a game every time you want to add a score to the leaderboard.functions/index.jscontains all of our Cloud Functions. You'll see some utility functions, likeaddScoresanddeleteScores, as well as the functions we'll implement in this codelab, which call out to helper functions in another file.functions/functions-helpers.jscontains the empty functions we'll implement. For each leaderboard, we'll implement read, create, and update functions, and you'll see how our choice of implementation affects both the complexity of our implementation and its scaling performance.functions/utils.jscontains more utility functions. We won't touch this file in this codelab.
Create and set up a Firebase project
Create a new Firebase project
- Sign into the Firebase console using your Google Account.
- Click the button to create a new project, and then enter a project name (for example,
Leaderboards Codelab).
- Click Continue.
- If prompted, review and accept the Firebase terms, and then click Continue.
- (Optional) Enable AI assistance in the Firebase console (called "Gemini in Firebase").
- For this codelab, you do not need Google Analytics, so toggle off the Google Analytics option.
- Click Create project, wait for your project to provision, and then click Continue.
Set up Firebase products
- From the Build menu, click Functions, and if prompted, upgrade your project to use the Blaze pricing plan.
- From the Build menu, click Firestore database.
- In the Create database dialog that appears, select Start in test mode, then click Next.
- Choose a region from the Cloud Firestore location drop-down, then click Enable.
Configure and run your leaderboard
- In a terminal, navigate to the project root and run
firebase use --add. Pick the Firebase project you just created. - In the root of the project, run
firebase emulators:start --only hosting. - In your browser, navigate to
localhost:5000. - Open Chrome DevTools's JavaScript console and import
leaderboard.js:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js"); - Run
leaderboard.codelab();in console. If you see a welcome message, you're all set! If not, shut down the emulator and re-run steps 2-4.
Let's jump into the first leaderboard implementation.
3. Implement a simple leaderboard
By the end of this section, we'll be able to add a score to the leaderboard and have it tell us our rank.
Before we jump in, let's explain how this leaderboard implementation works: All players are stored in a single collection, and fetching a player's rank is done by retrieving the collection and counting how many players are ahead of them. This makes inserting and updating a score easy. To insert a new score, we just append it to the collection, and to update it, we filter for our current user and then update the resulting document. Let's see what that looks like in code.
In functions/functions-helper.js, implement the createScore function, which is about as straightforward as it gets:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
For updating scores, we just need to add an error check to make sure the score being updated already exists:
async function updateScore(playerID, newScore, firestore) {
const playerSnapshot = await firestore.collection("scores")
.where("user", "==", playerID).get();
if (playerSnapshot.size !== 1) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
const doc = firestore.doc(player.id);
return doc.update({
score: newScore,
});
}
And finally, our simple but less-scalable rank function:
async function readRank(playerID, firestore) {
const scores = await firestore.collection("scores")
.orderBy("score", "desc").get();
const player = `${playerID}`;
let rank = 1;
for (const doc of scores.docs) {
const user = `${doc.get("user")}`;
if (user === player) {
return {
user: player,
rank: rank,
score: doc.get("score"),
};
}
rank++;
}
// No user found
throw Error(`User not found in leaderboard: ${playerID}`);
}
Let's put it to the test! Deploy your functions by running the following in terminal:
firebase deploy --only functions
And then, in Chrome's JS console, add some other scores so we can see our ranking among other players.
leaderboard.addScores(); // Results may take some time to appear.
Now we can add our own score to the mix:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
When the write completes, you should see a response in the console saying "Score created." Seeing an error instead? Open up the Functions logs via Firebase console to see what went wrong.
And, finally, we can fetch and update our score.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
However, this implementation gives us undesirable linear time and memory requirements for fetching a given score's rank. Since function execution time and memory are both limited, not only will this mean our fetches become increasingly slow, but after enough scores are added to the leaderboard, our functions will time out or crash before they can return a result. Clearly, we'll need something better if we're going to scale beyond a handful of players.
If you're a Firestore aficionado, you may be aware of COUNT aggregation queries, which would make this leaderboard much more performant. And you'd be right! With COUNT queries, this scales nicely below a million or so users, though its performance is still linear.
But wait, you may be thinking to yourself, if we're going to enumerate all of the documents in the collection anyway, we can assign every document a rank and then when we need to fetch it, our fetches will be O(1) time and memory! This leads us to our next approach, the periodically-updating leaderboard.
4. Implement a periodically-updating leaderboard
The key to this approach is to store the rank in the document itself, so fetching it gives us the rank with no added work. To achieve this, we'll need a new kind of function.
In index.js, add the following:
// Also add this to the top of your file
const admin = require("firebase-admin");
exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
// Schedule this when most of your users are offline to avoid
// database spikiness.
.timeZone("America/Los_Angeles")
.onRun((context) => {
const scores = admin.firestore().collection("scores");
scores.orderBy("score", "desc").get().then((snapshot) => {
let rank = 1;
const writes = [];
for (const docSnapshot of snapshot.docs) {
const docReference = scores.doc(docSnapshot.id);
writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
rank++;
}
Promise.all(writes).then((result) => {
console.log(`Writes completed with results: ${result}`);
});
});
return null;
});
Now our read, update, and write operations are all nice and simple. Write and update are both unchanged, but read becomes (in functions-helpers.js):
async function readRank(playerID, firestore) {
const scores = firestore.collection("scores");
const playerSnapshot = await scores
.where("user", "==", playerID).get();
if (playerSnapshot.size === 0) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
if (player.get("rank") === undefined) {
// This score was added before our scheduled function could run,
// but this shouldn't be treated as an error
return {
user: playerID,
rank: null,
score: player.get("score"),
};
}
return {
user: playerID,
rank: player.get("rank"),
score: player.get("score"),
};
}
Unfortunately, you won't be able to deploy and test this without adding a billing account to your project. If you do have a billing account, shorten the interval on the scheduled function and watch your function magically assign ranks to your leaderboard scores.
If not, delete the scheduled function and skip ahead to the next implementation.
Go ahead and delete the scores in your Firestore database by clicking on the 3 dots next to the scores collection to prepare for the next section.