We’re sorry we missed you at re:Invent, but we can still meet!

Build on Momento: Tips and tricks for a lightning-fast game leaderboard

Learn how to build a game leaderboard four different ways with sorted sets from Momento Cache.

アレン・ヘルトン
著者

Share

I’m not a game developer, but I have been an application developer for over 10 years. And I’m competitive. Probably Definitely the most competitive person I know.

Everything I do has to be a competition. Whether we’re seeing who can run the fastest mile or whose code goes the longest without a reported defect, everything can have a winner. 

Competition bleeds into my code more often than I’d like to admit. I once built a fitness tracking mobile app that scored you on how many days in a row you worked out. It gave you bonus points if you did the “workout of the day” as opposed to your own thing. Curious how the workout of the day was determined? It was the workout I did that day (a perk of being the developer). It was a fun build, but there was one part of it that took longer than I’d like to admit—the leaderboard.

For someone like me who breathes competition, a leaderboard is a necessary part of any game. You have to know who is winning at all times. And if you’re not winning, how can you slice and dice the statistics to position yourself so that you are winning?

With something as common as a leaderboard, you’d think that it’s a quick 30-minute build, right? 

Wrong.

Leaderboards can be deceptively difficult to implement. You can easily paint yourself into a corner with inflexible design decisions that make you scrap everything when you need a change. 

Data also tends to be ephemeral, meaning it doesn’t stick around very long. You have leaderboards that could be valid for a single game, a series, a day, or a month. How do you invalidate the data?

As it turns out, with the recent release of sorted sets for Momento Cache, building a leaderboard has never been easier. All the logic to track scores, rank players, and invalidate data is built directly into our SDKs. Let’s take a look at the power available at our fingertips.

Anatomy of a leaderboard

Leaderboards generally show who is playing, who is winning, and what their score is. Let’s take an example leaderboard from Momento’s game, Acorn Hunt.

The main components of the above leaderboard are:

  • Rank: The player’s position in the game
  • Score: How many points a player has (this directly affects the rank)
  • Value: The name of the player

What you don’t see explicitly labeled in the leaderboard is the order. The order determines how you are ranked. With an ascending order, lower scores will rank higher. A descending order ranks higher scores at the top. The example above shows an example of a leaderboard in descending order. 

Wondering how easy it is to build something like that? I was hoping you’d ask.

Point-based leaderboards

The classic example of a leaderboard is descending and point-based. This means players with the highest scores win and those with the lowest scores lose. But we can design a flexible leaderboard to allow those competitive “slice and dicers” a way to view rankings any way they like.

Single stat scoring

The simplest leaderboard you can build tracks a single statistic. In Acorn Hunt, this ties directly to the number of acorns a player collects during the game. We can easily build a leaderboard that tracks player rankings by using only this number.

To add to the player score, consider this endpoint written with our Node.js SDK:

//
// POST /points
//
exports.handler = async (event) => {
  const input = JSON.parse(event.body);
  const { username } = event.requestContext.authorizer;  
  
  const momento = await getCacheClient();
  const currentGameResponse = await momento.dictionaryGetField('user', username, 'currentGameId');
  if(currentGameResponse instanceof CacheDictionaryGetField.Miss){
    return {
      statusCode: 409,
      body: JSON.stringify({message: 'You are not part of an active game'})
    };
  }
  const gameId = currentGameResponse.valueString();
  const newScore = await momento.sortedSetIncrementScore('leaderboard', gameId, username, input.score);

  return {
    statusCode: 200,
    body: JSON.stringify({ score: newScore.score() })
  };
}

This endpoint uses two caches to keep track and update the score:

user cache implicitly provides the current game identifier for the caller. The endpoint then uses the id to update the leaderboard cache of the appropriate game. You might notice we’re calling the sortedSetIncrementScore command in our SDK. This is similar to our increment API where it accepts a value to increase the player score. It does not set the new player score directly but rather passes in the change in score

To get the leaderboard for the game, we have another endpoint that allows a flexible return format of the data.

//
// GET /games/{gameId}/leaderboard
//
exports.handler = async (event) => {
  const { gameId } = event.pathParameters;
  const params = event.queryStringParameters;
  
  const order = params?.order?.toLowerCase() == 'asc' ? SortedSetOrder.Ascending : SortedSetOrder.Descending;
  const top = params?.top;

  const momento = await getCacheClient();
  const leaderboardResponse = await momento.sortedSetFetchByRank('leaderboard', gameId, {
    order: order,
    ...top && {
      startRank: 0,
      endRank: top
    }
  });

  if(leaderboardResponse instanceof CacheSortedSetFetch.Miss){
    return {
      statusCode: 404,
      body: JSON.stringify({ message: 'Game not found.' })
    };
  }

  const leaderboard = leaderboardResponse.valueArrayStringNumber().map((element, rank) => {
    return {
      rank: rank + 1,
      username: element.value,
      score: element.score
    }
  });
  return {
    statusCode: 200,
    body: JSON.stringify({ leaderboard })
  };
}

This allows the caller to pass in optional query string parameters to change the order of the rankings and limit the number of results. 

So if you wanted to get your top 10 or bottom 3 players on the leaderboard, you could call the endpoint with the following query parameters:

Top 10GET /games/{gameId}/leaderboard?top=10

Bottom 3GET /games/{gameId}/leaderboard?order=desc&top=3

That’s about the easiest implementation of a leaderboard I’ve ever seen!

Multiple stat scoring

An advanced use case for leaderboards ranks players by two stats. This means rank is determined first by stat one, then by stat two. As a reminder, Acorn Hunt provides players with the super-ability tree slam. This knocks a bunch of acorns out of a tree and provides a quick boost to the player’s score. Each player is only allowed to use the ability up to three times.

Since this is such a powerful move, we decided to reflect its usage in the leaderboard. Players who use it are ranked lower than players who don’t if they have the same score. This means that if two players have both collected 10 acorns, the player who used tree slam will rank lower than the player who didn’t.

To reflect this in the leaderboard, we separate the two numbers by a decimal point. The number on the left of the decimal refers to the score and the number on the right is the number of remaining tree slam uses. It ends up looking like this:

const score = Number(`${points}.${remainingSuperAbilities}`);

NOTE – This method works because the score of a sorted set is a float.You could not repeat this pattern for 3 or more stats.

As super abilities are used, the number is decremented, which lowers the score of the player. Tracking multiple stats like this results in no change to our existing POST /points endpoint, but it does require an update to how we track super-abilities. Every time one is used we need to decrement the player score on the leaderboard.

//
// DELETE /super-abilities
//
exports.handler = async (event) => {
  const { username } = event.requestContext.authorizer;  
  const currentGameResponse = await momento.dictionaryGetField('user', username, 'currentGameId');
  const gameId = currentGameResponse.valueString();
  
  const momento = await getCacheClient();
  const incrementResponse = await momento.increment('super-abilities', `${gameId}-${username}`, -1);
  if( incrementResponse.value < 0) {
    return {
      statusCode: 409,
      body: JSON.stringify({ message: 'Out of super-ability uses'})
    }
  } else {
    await momento.sortedSetIncrementScore('leaderboard', gameId, username, -.1);
    return {
      statusCode: 200,
      body: JSON.stringify({ remaining: incrementResponse.value})
    };
  }
};

Since the super-ability stat is on the right side of the decimal point in the score, we increment the score by -.1, to indicate a change to that stat.

The only other setup when tracking multiple statistics in this manner is initialization. Every player will need a default score when the game starts indicating the maximum number of super-abilities. Since we allow players to use tree slam three times, every player starts with a score of 0.3.

Time-based leaderboards

Another use case for a leaderboard is time-based rankings. Instead of tracking rank via a series of points and statistics, you can track them via time. There are a couple different ways to implement time-based leaderboards.

Point-in-time scoring

A great way to visualize the last X number of players to do something is via point-in-time scoring. Acorn Hunt tracks the last time each player collected an acorn. Players get bonus points at the end of a round if they were one of the last 5 people to gather an acorn.

With time-based leaderboards, the score is not based on an adjustable number of points, but rather a point in time when an action occurred. We can update our existing POST /points endpoint to save the last time the player scored a point (which means they collected an acorn). We only need to add 3 lines of code.

if(input.score > 0){
    await momento.sortedSetPutElement('leaderboard', `${gameId}-acorn-activity`, username, new Date().getTime());
  }

Note there are a couple things different with this sorted set. Instead of using the sortedSetIncrementScore call, we use sortedSetPutElement. This command will overwrite the existing value with a new value as opposed to adding (or subtracting) to the existing one. Since this is a time-based leaderboard, we simply want to overwrite the last time a player gathered an acorn with the most recent one.

As stated earlier, sorted sets use a floating point value for scoring. Since a date in the format of “2023-03-08T13:54:21Z” is not a number, we convert it to ticks, which is the number representation of the date. This allows us to get a sorted leaderboard by the newest or oldest interactions.

//
// GET /games/{gameId}/activity
//
exports.handler = async (event) => {
  const { gameId } = event.pathParameters;
  const momento = await getCacheClient();
  const recentScorerResponse = await momento.sortedSetFetchByRank('leaderboard', `${gameId}-acorn-activity`, {
      order: SortedSetOrder.Descending,
      startRank: 0,
      endRank: 5
    }
  );
  const recentScorers = recentScorerResponse.valueArrayStringNumber().map((element, rank) => {
    return {
      rank: rank + 1,
      username: element.value
    }
  });
  return {
    statusCode: 200,
    body: JSON.stringify({ recentScorers })
  };
}

This endpoint returns the 5 players who most recently scored a point in the game. Since the sorted set score is a numerical representation of a date and time, a descending ordered list will return the newest first.

Duration-based scoring

What if you wanted to score players on how fast they did something? A classic example of a duration-based leaderboard is racing. The drivers with the fastest time rank the highest. This means duration-based leaderboards typically rank players by ascending score.

In Acorn Hunt, we give bonus points to the player who finds the first acorn. We save the time the game started and calculate the elapsed time when the first point rolls in. To turn a duration into a valid score for a sorted set, we subtract the game start time from the time we record the point and store the difference in ticks. See below:

exports.handler = async (event) => {
  const { gameId } = event.pathParameters;
  const { username } = event.requestContext.authorizer;  
  const momento = getCacheClient();

  const startTime = getGameStartTime(gameId);
  const duration = new Date().getTime() - startTime.getTime();
  await momento.sortedSetPutElement('leaderboard', `${gameId}-first-acorn`, username, duration);

  
  const leaderboardResponse = await momento.sortedSetFetchByRank('leaderboard', `${gameId}-first-acorn`);

  const leaderboard = leaderboardResponse.valueArrayStringNumber().map((element) => {
    const time = new Date(element.score);
    const minutes = time.getMinutes();
    const seconds = time.getSeconds().toString().padStart(2, '0');
    const timeString = `${minutes}:${seconds}`;
  
    return {
      time: timeString,
      username: element.value
    }
  })
  return {
    statusCode: 200,
    body: JSON.stringify({ leaderboard })
  }
};

The above endpoint adds the time it took a player to find their first acorn and return the complete ordered list back to the caller. This means we have a full duration-based leaderboard in only two API calls!

Ready to build?

Leaderboards are a crucial part of any game (or daily task, if you’re as competitive as I am). Whether you’re tracking score based on a point-in-time, set of statistics, or a simple count, Momento Cache has you covered effortlessly. 

With just a couple of API calls, you can have a robust, scalable, serverless leaderboard implemented in your app. Sorted sets are available in our Node.js and Go SDKs, with Python, Rust, .NET, Java, and PHP not far behind.

If you’re interested in Acorn Hunt, feel free to poke around our open-source solution in GitHub. We’ll continue building it out as we release features in Momento Cache. 
Want to learn more about the incredible power of sorted sets? Check out our API reference page for more!

Share