HOMEBLOG

Optimizing Online Game Leaderboards

Adam C. Clifton
18 May 2022

Online leaderboards are are great feature that gives your more competitive players a lot of replayability, allowing them to continue replaying your content to keep pushing each other to a new higher score. It can also be a source of excess database load if you let things get out of hand.

I'm going to use the simple example of a high score leaderboard to walk through how to initially set things up, and how to optimise. With the example leaderboard, we'll sort things so higher score is better, but the same process will work for any sortranking like lap times where smaller is better.

Setup

We'll start with a description of the entries in the database. I'll use a pseudo SQL style here, but note that this will work with any kind of database such as MySQL, PostgreSQL or MongoDB.

CREATE TABLE tblEntry (
    nUserId INT,
    nStageId INT,
    nScore INT///
);

Where:
nUserId is some sort of unique identifier for the player.
nStageId is a unique identifier for each stage, eg: each level or track in your game.
nScore is the thing we'll be ranking players on, here it's the score, but could be lap time in a racing game.

We'll also create two indexes:

CREATE INDEX idxStageScore ON tblEntry(nStage, nScore);
CREATE UNIQUE INDEX idxUserIdStage ON tblEntry(nUserId, nStage);

idxStageScore will allow us to quickly split the entries by stage and sort them by score.
idxUserIdStage this unique index will make sure the player only has one entry per stage to keep things simple.

Exact Rank Calculation

With this setup, to calculate the rank of a score nPlayerScore, you have to count every row above it in the leaderboard.

SELECT COUNT(*) FROM tblEntry WHERE nStageId=1 AND nScore < $nPlayerScore;

And now we know the exact rank of the player (ignoring other players with the exact same score). This should be a pretty efficient query, since it should be able to be calculated by just scanning the index data.

You could cache the value on the entry and update it occasionaly in the background. But that's extra code, and in the end will make the database work even more than directly calculating the rank when needed.

Showing Leaderboard Neighbours

When showing the player their leaderboard position, we also want to show the players around them. This is simple enough to approximate by getting a set of scores above and below what the player submitted. This is imperfect, as there may be multiple players with the same score, but it's very much good enough.

SELECT * FROM tblEntry WHERE nStage=1 AND nScore > $nPlayerScore ORDER BY nSCORE ASC LIMIT 10;

SELECT * FROM tblEntry WHERE nStage=1 AND nScore < $nPlayerScore ORDER BY nSCORE DESC LIMIT 10;

This will give 10 scores above and below what the player submitted. Perfect for building a leaderboard. Note that the results above the plater are sorted in the wrong order, so you'll need to manually fix that in code.

Since we know the players score, we can use that to guesstimate what each opponents rank is be how far away they are from them.

Caching

It turns out nobody really cares (or will never know) if they aren't perfectly ranked in the dark depths of the leaderboard. Usualy any increase in points in the middle of the leaderboard will lead to a big jump in rank as well, so a player will move so far they cannot even see their old position anymore.

At the top of the leaderboard it is very important to have correct rankings, so for the top 100 we'll use exact rank calculation. And for the rest of the leaderboard we can estimate the players rank.

We can estimate with just a few simple numbers, firstly what the top 100 score is, and how many players are in the leaderboard.

Here is the table layout we'll use to cache that data:

CREATE TABLE tblRankingCache (
    nRankingCacheId PRIMARY KEY,
    nStageId INT,
    nTop100Score INT,
    nNumEntry INT
    );

The plan here will be to always perfectly rank the top 100, that's usually where it really matters to players. And since the difference in scores between 1st and 2nd is usually a lot different than between 345341st and 345342nd, estimations will be way off at the top.

So we cache the score of the 100th ranked entry, and how many entries are behind it in the leaderboard. From here we can do a simple linear approximation for any score and where it would rank on the leaderboard.

Firstly we need to work out how many players are in the leaderboard:

SELECT COUNT(*) FROM tblEntry WHERE nStageId = 1;

We set this value on nNumEntry in the cache table. If this number is less than 100, then we set nTop100Score to 0. As we don't have enough entries (yet).

If we do have enough entries, then we need to work out the top 100 score:

SELECT nScore FROM tblEntry WHERE nStageId = 1 ORDER BY nScore OFFSET 100; 

And we set this value in the nTop100Score field.

Rank Approximation

Now that we have this cache data it's easy to approximate a rank for a given score nPlayerScore:

fRankPerScore = (nMaxScore - nMinScore) / nNumPlayer  
nRank = (nScore - nMinScore) * fRankPerScore;

This way, by using a simple cache table that is occasionaly updated, you can quickly generate the rank for any score.

When showing a player their local leaderboard, with all the players you can select a sorted chunk of players from above and below

Final Thoughts

With our table setup, we are only allowing one entry per user per stage in this leaderboard. It is possible to store multiple per user if your game needs it, perhaps for showing a full history, or wanting to rank all time best vs best of the week. These things are possible, but you'll need to add extra columns to the index, and keep it updated whenever the player submits a new score.

Previous: Hashing player passwords on your game server
Next: Static Site Hosting On Amazon S3 and Cloudfront
© Numbat Logic Pty Ltd 2014 - 2022