I found myself out of work for the last month and decided to use my time with my partner to build a game. We've participated in Game Jams before but never really got anything to a production state. This time we wanted it to be different.
We decided to build the game in Unity and used some really nice Synty assets for the world and Malbers animations for our key rabbit characters along side some custom assets and a whole lot of level design :)
We needed three key things that fall outside of the Unity stack:
- A website to host a free preview version of the game (https://wabbitsworld.com)
- A service on that site that can share photos to Facebook that are uploaded from the game, even if from a mobile app etc
- A highscore table that ran in seasons and could return the top 100 scores and the position in the total leaderboard of the current player.
Leaderboards
Leaderboards are a non-trivial problem - even if you have a server with a database you are having to do sorts on large numbers of records - albeit that indexes can help a lot with this, it's still quite a load. To find the relative position of a player in a million scores you need to traverse the sorted list. If you decide, like we did, that you don't want to go to the cost of running a server and opt for serverless (in our case Firebase) then your problem intensifies. It would be very expensive indeed to use one of the Firebase databases to try to run a leaderboard due to the pricing model and you can't benefit from in memory caching in Serverless architectures.
The ideal way to run a leaderboard is to use ZSets in Redis. Redis is fantastic at these kinds of operations and so I decided on implementing the following stack:
- Run the website as a Cloud Function in Firebase - this way I can implement an Express app to record scores and download the current top scores. I use Pug to create sharing pages for a user's images with the correct Open Graph tags so Facebook posts link through properly and show the image.
- Use Upstash as a serverless Redis implementation - it has a generous free tier and the price won't get out of hand even if the game is very successful
- Use my cloud based Express app to query Redis for scores and to record new ones.
- Create a React app for the site and host that in the same Express Cloud function
I also decided that I would do 14 day seasons so the leaderboard is for currently active players - not those who played months ago. This is easy with Redis - I just add the current date / 14 * 1000 * 60 * 60 * 24 rounded to an int to the key used for the highscores.
The Code
I'm going to start by showing you the entire code for the website (excluding the pug view). I'm doing this because I can't quite believe how tiny it is!
const functions = require("firebase-functions");
const express = require("express");
const path = require("path");
const bodyParser = require('body-parser');
const app = express();
app.use(require('compression')());
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
// Facebook share page
app.get("/shared", (req,res)=>{
res.render("shared", {image: req.query.image, token: req.query.token});
});
const season = Math.floor(Date.now()/ (1000 * 60 * 60 * 24 * 14) );
const HIGHSCORES = `highscores ${season}`;
const REDIS_PASSWORD="REDIS_PASSWORD_HERE";
const REDIS_HEADER= "Bearer MY BEARER TOKEN=";
const REDIS_BASEURL= "https://MY_SERVER.upstash.io/";
const Redis = require("ioredis");
function createRedisConnection(){
return new Redis("redis://UPSTASH ADDRESS AND PASSWORD");
}
// Heartbeat api
app.get('/info', (req,res)=>{
res.render("info");
});
//API to record a score
app.post("/addscorerecord", async ({body: {id, name, score}}, response)=>{
const redis = createRedisConnection();
await redis.zadd(HIGHSCORES, -score, id);
await redis.set(id, JSON.stringify({id, name, score}));
await redis.set(`${id}_name`, name);
let rank = await redis.zrank(HIGHSCORES, id);
if(rank === undefined || rank === null) rank = -1;
redis.disconnect();
response.send({rank, time: Date.now()});
});
function groupResults(results)
{
const output = []
for(let i = 0; i < results.length; i+=2)
{
output.push([results[i], results[i+1]]);
}
return output;
}
// API to get the Highscore table
app.post("/gethighscoretable", async ({body: {id}}, response) =>{
const redis = createRedisConnection();
let rank = await redis.zrank(HIGHSCORES, id);
if(rank === null || rank === undefined) rank = -1;
const topScores = await redis.zrange(HIGHSCORES, 0, 99, "withscores");
const scores = []
if(topScores && topScores.length) {
const pipe = redis.pipeline();
let groupedResults = groupResults(topScores)
for (const [id, score] of groupedResults) {
pipe.get(`${id}_name`);
}
const names = await pipe.exec();
for (let i = 0; i < groupedResults.length; i++) {
const [, score] = groupedResults[i];
scores.push({score: -score, name: names[i][1]});
}
}
redis.disconnect();
response.send({rank, scores, time: Date.now()});
});
// API to get the server time
app.get("/time", (req,res)=>{
res.send({time: Date.now()})
});
// This serves the Unity game
app.use(express.static(path.join(__dirname, "public")));
// Return all other paths to the index.html for React routing
app.use((req,res)=>{
res.sendFile(path.join(__dirname, "public", "index.html"), err=>{
res.status(500).send(err);
});
});
exports.app = functions.https.onRequest(app);
Recording a score
The process of recording a score is pretty simple. The game provides a score
, an id
for the player and the name
that they want displayed for their score.
The id
and the score
are placed in a ZSet with the score negated so that higher scores come first.
app.post("/addscorerecord", async ({body: {id, name, score}}, response)=>{
const redis = createRedisConnection();
await redis.zadd(HIGHSCORES, -score, id);
Next I record the name for the ID so we can look it up quickly and a whole record of the current score and name for the player - this latter is unnecessary in the current code, but I have a plan for it later.
await redis.set(id, JSON.stringify({id, name, score}));
await redis.set(`${id}_name`, name);
Finally we use Redis magic to quickly work out the current rank of the player.
let rank = await redis.zrank(HIGHSCORES, id);
if(rank === undefined || rank === null) rank = -1;
We finally package up the response and send it to Unity as a JSON packet.
redis.disconnect();
response.send({rank, time: Date.now()});
});
Getting the highscore table
It's not much harder to retrieve the highscore table - we get the top 100 scores and repeat the current player ranking operation. For this to work we just need the id
of the player.
app.post("/gethighscoretable", async ({body: {id}}, response) =>{
const redis = createRedisConnection();
let rank = await redis.zrank(HIGHSCORES, id);
if(rank === null || rank === undefined) rank = -1;
Next we request the top 100 scores including both the score
and the id
:
const topScores = await redis.zrange(HIGHSCORES, 0, 99, "withscores");
The we need to turn id
s into name
s.
const scores = []
if(topScores && topScores.length) {
const pipe = redis.pipeline();
let groupedResults = groupResults(topScores)
for (const [id, score] of groupedResults) {
pipe.get(`${id}_name`);
}
const names = await pipe.exec();
for (let i = 0; i < groupedResults.length; i++) {
const [, score] = groupedResults[i];
scores.push({score: -score, name: names[i][1]});
}
}
You can see I use a pipeline operation in Redis to make the call for 100 things all at once for performance reasons.
Next we just need to return the data:
redis.disconnect();
response.send({rank, scores, time: Date.now()});
});
Calling From Unity
Unity makes it pretty easy to call these functions and use the results. I implemented an HTTP helper first, this allows HTTP requests as Unity coroutines:
namespace Wabbit
{
public static class HttpHelp
{
public static IEnumerator GetJson<T>(string url, Action<T> response) where T: new()
{
var request = new UnityWebRequest(url, "GET");
yield return request.SendWebRequest();
while (!request.isDone)
{
yield return null;
}
if (request.result == UnityWebRequest.Result.Success)
{
var o = new T();
var item = JsonUtility.FromJson<T>(request.downloadHandler.text);
response(item);
}
}
public static IEnumerator PostJson(string url, object data, Action<string> response = null)
{
var request = new UnityWebRequest(url, "POST");
var body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(data));
request.uploadHandler = new UploadHandlerRaw(body);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
while (!request.isDone)
{
yield return null;
}
if (response != null && request.result == UnityWebRequest.Result.Success)
{
response(request.downloadHandler.text);
}
}
}
}
Recording a score and retrieving scores use this helper function, but we have to define classes that will be translated to and from JSON, so they come first:
[Serializable]
public class ScoreRecord
{
public string id;
public string name;
public int score;
}
[Serializable]
public class Ranking
{
public int rank;
}
[Serializable]
public class ScoreEntry
{
public string name;
public int score;
}
[Serializable]
public class HighScoreTable
{
public int time;
public int rank = -2;
public ScoreEntry[] scores;
}
Now recording a score is just a matter of using the helper with the correct class as a parameter:
private static IEnumerator SendScore()
{
yield return HttpHelp.PostJson("https://wabbitsworld.com/addscorerecord", new ScoreRecord
{
id = Controls.PlayerInfo.id, name = Controls.PlayerInfo.userName, score = Controls.PlayerInfo.highScore
}, result =>
{
var ranking = JsonUtility.FromJson<Ranking>(result);
currentRank = ranking.rank;
Events.Raise("GotRank");
});
}
Conclusion
I found it was pretty easy to setup a free tiered serverless environment that combines Firebase with Upstash to allow a simple leaderboard system to be developed. While this example doesn't cover some of the extensions you would add to avoid cheating, it shows a cheap and performant way to make simple highscore functionality.
You can download the iOS and Mac versions of Wabbits from the App Store. The Droid version is awaiting approval.