Introduction

A widely used feature of GameSparks is partitioned Leaderboards. These types of Leaderboards use the same APIs for posting and listing Leaderboard scores as regular Leaderboards, but they can be subdivided into smaller Leaderboards using a partition key.

For example a world-wide Leaderboard on GameSparks with the shortCode “Arena” could be subdivided into countries by posting the partition key as the country code. In this case, all entries will go into the world-wide “Arena” Leaderboard, but the scores are also automatically posted to separate Leaderboards for the player’s country, for example “Arena.us”.

For alternative platforms this is not always the case, so in this topic we will cover how to use the native APIs to achieve partitioned Leaderboards, and if the platform does not have an alternative to partitioned Leaderboards, how would you replicate the same functionality so you can transition your existing GameSparks partitioned Leaderboards.

Note - Creating, posting and listing leaderboard data isn't covered in this for each case as these points are covered in the topic on Leaderboard Basics available here.

Beamable

Beamable does not currently support Partitioned Leaderboards in the same way as GameSparks does. You can only post to one leaderboard at a time. Therefore, posting to a partition required making a new instance of the leaderboard as we did in the previous tutorial here.

GameSparks Partitioned Leaderboards are essentially just separate leaderboards with separate names anyway, so although it does require more setup, it is much the same process.

Following on from the previous tutorial on Leaderboards we have created some partitioned Leaderboards and synced them to the backend.

All we really need to do here is post to both Leaderboards one after another. However, we should also check if the partitioned Leaderboard exists before posting otherwise we’ll get an error when trying to post. To do this we are going to check if the Id can be found in the content manifest.

/// <summary>
/// Checks if the leaderboard with the given Id exists
/// </summary>
/// <param name="lbId">The leaderboard Id</param>
private async Task<bool> CheckLeaderboardExists(string lbId)
{
   // Loads the manifest for all content and checks if the content with the given id exists //
   // manifest is cached to speed up this request //
   return await beamableAPI.ContentService.GetManifest().Map(manifest => manifest.entries.Exists(content => content.contentId == lbId));

   // example 2 //
   // Check if the content is null by trying to load it and catching the exception if it didnt load //
   // try
   // {
   //     var contentFromRef = await beamableAPI.ContentService.GetContent<LeaderboardContent>(lbId);
   //     return true;
   // }
   // catch (Exception e)
   // {
   //     return false;
   // }
}

To check this, we can create a function which can validate that Content exists with that Leaderboard Id.

/// <summary>
/// Posts the given score to the leaderboard and the partition
/// </summary>
/// <param name="paritionId">PartitionId of the leaderboard</param>
/// <param name="score">The player's current score</param>
async void PartitionedLeaderboardPost(string paritionId, double score)
{
   // post to main leaderboard //
   await beamableAPI.LeaderboardService.SetScore(_leaderboardRef.Id, score);
   Debug.Log($"Score [{score}] Posted to {_leaderboardRef.Id}");
   // create full lb name for partition //
   string partitionLBName = _leaderboardRef.Id + "_" + partitionId;
   // check lb exists before posting //
   if (await CheckLeaderboardExists(partitionLBName))
   {
       await beamableAPI.LeaderboardService.SetScore(partitionLBName, score);
       Debug.Log($"Score [{score}] Posted to {partitionLBName}");
   }
   else
   {
       Debug.Log($"No LB exists with Id {partitionLBName}");
   }
}

Note - for this example we won't show posting supplemental data just to make things easier. This is covered in the previous leaderboards topic.

You can check if your Leaderboard got updated from the portal.

Microservices Example

The above example shows how you can post to a partitioned Leaderboard from the client using two calls. This is a valid alternative to GameSparks partitioned Leaderboards, however, with GameSparks you are always only sending a single request to post to the main Leaderboard and the partition.

Therefore, let's take a look at how we might model the same request using Beamable microservices.

We will not be covering a full tutorial on how to create microservices in this topic as they are already covered in this topic so consult that guide if you are new to Beamable microservices.

For this example we just need a microservice for posting scores, but you might want to turn this into a service for all server-side Leaderboard logic if that suits your needs.

Below is the same example we saw earlier, but modified for microservices.

[ClientCallable]
public async void PostScore(string leaderbordId, string paritionId, double score)
{
  // post to main leaderboard //
  await Services.Leaderboards.SetScore(leaderbordId, score);
  Debug.Log($"Score [{score}] Posted to {leaderbordId}");
  // create full lb name for partition //
  string partitionLBName = leaderbordId + "_" + paritionId;
  // check lb exists before posting //
  if (await CheckLeaderboardExists(partitionLBName))
  {
     await Services.Leaderboards.SetScore(partitionLBName, score);
     Debug.Log($"Score [{score}] Posted to {partitionLBName}");
  }
  else
  {
     Debug.Log($"No LB exists with Id {partitionLBName}");
  }
}

You will also need to modify the calls to check Content exists for use in the microservice. This is also pretty simple.

return await Services.Content.GetManifest().Map(manifest => manifest.entries.Exists(content => content.contentId == lbId));

Note - Remember to make sure your server has been built with those new changes and is running.

We can then invoke the microservice with the following code and see our debug logs in the console.

_postScoreMS = new PostScoreMSClient();
await _postScoreMS.PostScore(_leaderboardRef.Id, "us", 5d);

There are a few differences between client-side and server side, mainly that we don't have the LeaderboardRef object so we can get the Id of the main Leaderboard. It is therefore passed in as a separate field to the microservice.

The calls to the LeaderboardService are also slightly different because we are using the server API which uses Services.Leaderboards to access the LeaderboardService.

AccelByte

Unfortunately AccelByte does not provide a solution for partitioned leaderboards out-of-the-box. You will need to recreate that functionality yourself. Check out the topic here so see how basic leaderboards are set up first.

The simple alternative solution to this would be to create multiple leaderboards connected to the same stat.

For example, if you have a main “global” leaderboard called “Arena” you can create one leaderboard with that name and then a leaderboard for each of your country codes, i.e. “Arena.us”.

Because you are not actually posting to the leaderboard but instead, the leaderboard gets updated when the stat changes, when that stat changes both leaderboards will get updated automatically so there is no need for custom code.

This does mean manually setting up each leaderboard with each key name, which could take some time, but a transition using AccelByte leaderboards is possible without custom backend code or client code.

Nakama

In this topic we will be following on from the examples we work with in the topic on Basic Leaderboards here.

For this example we are going to modify one of the Leaderboards we transitioned in the previous section in order to make it into a Partitioned Leaderboard.

The JSON config for a partitioned Leaderboard for the example above would look like this.

{
  "@id": "/~leaderboards/WEEKLY_RESET",
  "description": "WEEKLY_RESET",
  "highScoreNotifications": false,
  "name": "WEEKLY_RESET",
  "propertySet": null,
  "segmentData": [],
  "shortCode": "WEEKLY_RESET",
  "snapshotFrequency": "WEEKLY",
  "socialNotifications": false,
  "team": null,
  "topNNotifications": false,
  "topNThreshold": null,
  "updateFrequency": "REALTIME",
  "~fields": [
   {
   "@id": "/~leaderboards/WEEKLY_RESET/~fields/score-all",
   "calcType": "MAX",
   "collector": {
      "@ref": "/~runningTotals/POST_SCORE_PART/~collectors/score-all"
   },
   "filterType": "*",
   "filterValue": null,
   "sort": "DESC"
   },
   {
   "@id": "/~leaderboards/WEEKLY_RESET/~fields/region-all",
   "calcType": "PARTITION",
   "collector": {
      "@ref": "/~runningTotals/POST_SCORE_PART/~collectors/region-all"
   },
   "filterType": "*",
   "filterValue": null,
   "sort": "DESC"
   }
  ]
}

You can see from the example above that we have two fields in the JSON config and we know one of them is the partition field while the other is the score.

We can easily adapt our previous code to check if we are working with a Partitioned Leaderboard and then post to the partition at the same time as the root-Leaderboard. The code is simple but there are a number of steps we need to take.

Partition Name

First we need to get the name of the partition from the JSON config above. Then we need to check if a Leaderboard with that partition has been created (remember that Nakama Leaderboards have to be created first).

If the Leaderboard exists, we can post to both the root-Leaderboard and the partitioned Leaderboard.

We will create a function which will get the name of the partition and let us know if we are posting to a partitioned Leaderboard or not.

/**
* Returns the name of the partition
* @param lbConfig {any}
* @returns {string}
*/
   private static evaluatePartition(lbConfig: any): string  {
   // Here we can loop through the fields array and identify if we have a partitioned LB //
   let partitionName : string = '';
   lbConfig["~fields"].forEach(field => {
       if(field.calcType === "PARTITION"){
           partitionName = field['@id'].split('/')[4].split('-')[0];
       }
   });
   return partitionName;
}

Now we are going to create another function which will post to the root-Leaderboard and the Partitioned Leaderboard.

/**
* Checks if the LB is partitioned and posts the score to both the partition and the main LB
* @param lbConfig {any}
* @param userId {string}
* @param userName {string}
* @param score {number}
* @return {number}
*/
   private static postToLB(lbConfig: any, userId : string, userName: string, score: number, partKey: string, nk: nkruntime.Nakama, logger: nkruntime.Logger) : number {
   // First we check if the LB is partitioned //
   let partitionName : string = this.evaluatePartition(lbConfig);
   if(partitionName !== ''){
       // check if an LB with that partition exists //
       let partitionLBName: string =  lbConfig.name+"."+partitionName+"."+partKey;
       let lbList: nkruntime.Leaderboard[] = nk.leaderboardsGetId([partitionLBName]);
       // check if the LB exists //
       // if not, then we will create it //
       if(!lbList || lbList.length === 0){
           lbConfig.shortCode = partitionLBName;
           logger.info(`Creating [${lbConfig.shortCode}]`);
           try {
               // setup params for the LB //
               let id : string = lbConfig.shortCode;
               let authoritative : boolean = false;
               // get the sort order of the GS LB //
               let sortOrder =  parseGSLeaderboardOrder(lbConfig['~fields'][0]);
               // get the aggregation type, called the 'operator' in Nakama //
               let operator = parseGSLeaderboardAggregation(lbConfig['~fields'][0]);
               // get the reset frequency and convert it to Nakama reset date //
               let reset = parseGSLeaderboardReset(lbConfig.snapshotFrequency);
               // add any other config or information you might need based on the GS config //
               var metaData: any = {
                   description:        lbConfig.description,
                   name:               lbConfig.name,
                   segmentData:        lbConfig.segmentData,
                   socialNotifications:lbConfig.socialNotifications,
                   topNNotifications:  lbConfig.topNNotifications,
                   topNThreshold:      lbConfig.topNThreshold,
                   // leaderboard score value filtering //
                   filterValue:        parseGSLeaderboardFilters(lbConfig['~fields'][0])
               }
               logger.info(`Id:${id}, auth:${authoritative}, SortOrder:${sortOrder}, Operator:${operator}, Reset:${reset}, MetaData [${JSON.stringify(metaData)}]`);
               nk.leaderboardCreate(id, authoritative, sortOrder, operator, reset, metaData);
               logger.info(`Created [${lbConfig.shortCode}] ...`);
           } catch(error) {
               logger.info(`Error creating LB ${lbConfig.shortCode} ...`);
               logger.error(error.message);
           }
           // post to the partition //
           nk.leaderboardRecordWrite(partitionLBName, userId, userName, score);
       }  
   }
   // post to the regular leaderboard //
   let lbRecord: nkruntime.LeaderboardRecord = nk.leaderboardRecordWrite(lbConfig.shortCode, userId, userName, score);
   return lbRecord.rank;
}

You can see from the example above that we first check to see if the Leaderboard exists, and if it does not, then we create it.

And now we can clean up the postScore function to call this new function. We can include this at the bottom of the postScore function.

/ >> post & return rank << //
let currRank : number = this.postToLB(lbConfig, userId, userName, score, partitionKey, nk, logger);
// check if we successfully posted to the LB //
if(currRank === 0){
   // handle errors //
}
return JSON.stringify({
   rank: currRank,
   isHighScore: isHighScore
});

And we also need to make sure the RPC call is getting the partition key and sending it to the postScore function.

/**
* Posts a score to the leaderboard with the given ID
* @param context
* @param logger
* @param nk
* @param payloadString
*/
function rpcPostScoreCustom(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payloadString: string): string {
   logger.info("Data In: " + payloadString);
   let payloadData = JSON.parse(payloadString);
   // grab the request details //
   let lbId            =  payloadData.lbId;
   let score           =  payloadData.score;
   let partitionKey    =  payloadData.partitionKey;
   return GSLeaderboards.postScore(lbId, score, partitionKey, context.userId, context.username, logger, nk);
}

From the previous Leaderboards topic you will have seen how to add parameters to this RPC request in Unity. We will have to add the partitionKey parameter to the Dictionary too.

/// <summary>
/// Post a score to the given LB for the current player
/// </summary>
/// <param name="session"></param>
/// <param name="lbID"></param>
/// <param name="partKey"></param>
/// <param name="score"></param>
private async void PostScoreCustom(ISession session, string lbId, string partKey, int score)
{
   Debug.Log($"Posting Score [{score}] to {lbId}...");
   string rpcId = "postScoreCustom";
   Dictionary<string, object> requestPayload = new Dictionary<string, object>()
   {
       { "lbId" , lbId },
       { "score" , score },
       { "partitionKey", partKey }
   };
   var payload = Nakama.TinyJson.JsonWriter.ToJson(requestPayload);
   IApiRpc responseData = await nakamaClient.RpcAsync(session, rpcId, payload);
   Debug.Log(responseData.Payload);
}

And that's all you need to get some basic Partitioned Leaderboards working in Nakama!

Something to note about the example above is the dynamic creation of Partitioned Leaderboards. This happens automatically in GameSparks, but it is not automatic with Nakama and in the example above we check if the partition exists before we post to it. This allows us to create a new Leaderboard on-the-fly based on the partition key.

This makes the code somewhat inefficient as we always have to perform this check before posting our scores every time.

If you know the keys for all your partitioned Leaderboards it would be more efficient to create those Leaderboards when the runtime server boots, as we did in the Basic Leaderboards topic. That way you know the Leaderboards always exist before posting to them and you can save on the check to see if the Leaderboard exists before posting.