Leaderboards

The leaderboards plugin is designed to allows creation of any number of leaderboards in a game. Leaderboards are accessible using 3 APIs:

  • A client API that allows querying.

  • A C# server API that allows both querying and updating.

  • An Admin WebAPI that allow both querying and updating.

Scores

Scores are stored as a Json object in the database:

{
    "id":"xxxxx",   // The Id of the entity the score is related to. It is provided when storing the document. Most of the time, it's the user id, but it can be anything (guild or clan id, faction id...)

    //metrics that can be used to order scores in the leaderboard.
    //All of them should be numbers.
    "scores":{
        "metric1":23,
        "foo",34.656,
        ...
    },
    "document":{    //Additionnal metadata about the score, can be used for filtering, for display etc...
        "faction":"elves",
        "bar":"foo",
        "onlineId":"steamId",
        "pseudonym":"xxxx"
        ...
    },

    "createdOn":"2019-04-22T06:00:00Z"  //Date the score was created or last updated.
}

Using the player id as the score id limits players to having a single score in the leaderboard.

Updating scores

From the server

Server code can import ILeaderboardService in their constructor to access the UpdateScore and UpdateScores functions. These function enable servers to create, update and delete (CRUD) scores. UpdateScores provides batched CRUD operations on one or several leaderboards:

await leaderboard.UpdateScores(
new []{ new LeaderboardEntryId("ranked", winnerId), new LeaderboardEntryId("ranked", loserId) },
async (leaderboardEntryId, oldScoreRecord) =>
{
    var oldScore = oldScoreRecord?.Scores?.ToObject<ScoreData>()??new ScoreData(); //if no leaderboard entry, initializes oldScore at default.

    if (leaderboardEntryId.Id == winnerId)
    {

        return new ScoreRecord
        {
            Scores = JObject.FromObject(new ScoreData
            {
                MMR =  oldScore.MMR + winnerGain,
                WinStreak = oldScore.WinStreak + 1,
                TotalGames = oldScore.TotalGames + 1
            }),
            Document = JObject.FromObject(new ScoreDocument
            {
                //Put the platform id of the player in the score additional data. (if running on steam, the steam id for instance)
                Id = (await sessions.GetPlatformId(winnerId)).ToString()
            })
        };
    }
    else
    {
        return new ScoreRecord
        {
            Scores = JObject.FromObject(new ScoreData
            {
                MMR = Math.Max(0, oldScore.MMR + loserGain),
                WinStreak = 0,
                TotalGames = oldScore.TotalGames + 1
            }),
            Document = JObject.FromObject(new ScoreDocument
            {
                //Put the platform id of the player in the score additional data. (if running on steam, the steam id for instance)
                Id = (await sessions.GetPlatformId(loserId)).ToString(),
            })
        };
    }
});

Both methods supports concurrent updates through optimistic concurrency: The update callback provides the old value as a parameter, and if score was updated while it was executed, it is executed again with the updated value.

The update callback enables:

  • Deletion by returning null as new score.

  • Creation when it provides a null oldScore argument.

  • Updating when it provides a non null oldScore argument and the callback returns a non null new score.

From client

For security reasons, the plugin doesn’t currently provide an update scores client API. It is currently recommanded for developers to create custom server APIs that provide limited access to the functionality.

Accessing Scores

Client and server applications use the Query method to query score pages or GetRanking to get the rank of a given player.

As a score record can contain many score metrics, a path to the metric (relative to the score object) must be provided as argument of both methods.

Ex: To use the metric foo for ranking in the above example, use the path foo.

C++

The Query method takes a type template argument the scores object will be deserialized into. For the runtime do be able to deserialize, the provided type must contain the MSGPACK_DEFINE_MAP() macro. In the above example:

"scores":{
"metric1":23,
"foo",34.656
}

When calling Query<T>, T should be:

#include "stormancer/msgpack_define.h
struct T
{
    int metric1;
    float foo;

    MSGPACK_DEFINE_MAP(metric1,foo)
}

Storage

Leaderboards are stored in Elasticsearch. By implementing the interface ILeaderboardIndexMapping in the Server application, it’s possible to aggregate several leaderboards into a single ES index.

By default each leaderboardname is mapped to its own index name. ILeaderboardIndexMapping provides a GetIndexName(string leaderboardName) method that returns the index name that should be used for the provided leaderboardName. This allows sharing an index between leaderboards.

Recommendations

Elasticsearch scales very well to up to 25GB of data per index shard, but doesn’t scale well with the number of indices in the database. If the game consists of a lot of small leaderboards (game leaderboards, activity leaderboards etc…) it’s recommended to aggregate all of them into a single index, especially if they share the same metrics.

Elasticsearch supports up to 1024 different fields in an index. If leaderboards in the game have a lot of different fields depending on the leaderboard, it’s recommended to store them by schema in different indices.