DEV Community

Cover image for Game In A Month: Serverless Leaderboards
Mike Talbot ⭐
Mike Talbot ⭐

Posted on

Game In A Month: Serverless Leaderboards

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.

Game Demo

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

We finally package up the response and send it to Unity as a JSON packet.

    redis.disconnect();
    response.send({rank, time: Date.now()});
});
Enter fullscreen mode Exit fullscreen mode

 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;
Enter fullscreen mode Exit fullscreen mode

Next we request the top 100 scores including both the score and the id:

    const topScores = await redis.zrange(HIGHSCORES, 0, 99, "withscores");
Enter fullscreen mode Exit fullscreen mode

The we need to turn ids into names.

    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]});
        }
    }
Enter fullscreen mode Exit fullscreen mode

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()});
});
Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
Enter fullscreen mode Exit fullscreen mode

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");
            });
        }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (3)

Collapse
 
grahamthedev profile image
GrahamTheDev • Edited

Impressive game for only a month ❤🦄!

Really fun game, the only thing I would say is that because of the animation time for each frame you end up spam clicking in a direction and occasionally that means it performs a "kick" that you don't want it to (the amount of times I ended up with a cabbage against a wall and needed a restart! hehe).

This might not be an issue on the actual app as I only played the unity version at the moment, but I will download the full game when I get chance.

Super impressive, I hope the game does well!

Collapse
 
miketalbot profile image
Mike Talbot ⭐

The blue circle instantly shows where the rabbit will go next - best I could do as if it moves faster you don't really get the animation working well. User feedback from before did the "queueing up moves" - it felt that the buttons were missed otherwise. Unfortunate "feature" of the playing system.

Collapse
 
grahamthedev profile image
GrahamTheDev

It is fair enough, easy enough to get used to and was the only feedback I had.

I look forward to playing the full game! ❤