Bucketed Leaderboards #

If you haven’t already, check out the Leaderboards documentation to familiarize yourself with the relevant concepts and features before proceeding.

As a game’s player base grows a fundamental problem of social interaction in online games appears. Global leaderboards, and leaderboards generally, become static, with little movement at any particular skill or score level. This results in a loss, or lack, of engagement for newer players and so a lack of interest in continuing to play the game. One solution for this is the implementation of bucketed leaderboards.

In bucketed leaderboards players don’t play against all other players but instead see a limited view of other players (often 25-50). They compete against this group until the leaderboard expires or rolls over to another start time. These smaller groups of players are known as cohorts or “player buckets” which is where the bucketed leaderboards name originates.

Bucketed leaderboards can be seen in many popular games, including several titles in Rovio’s Angry Birds franchise. Using Nakama’s Storage Engine, Leaderboards, and Tournaments features enables you to implement bucketed leaderboards in your games with server runtime code, specifically RPC functions.

The Nakama leaderboards API already allows you to pass in a set of user IDs (or usernames) that become the filter used to generate the “bucketed view” of the leaderboard with just those players on it.

This just leaves how to form the user ID set for a specific player, and if it is personalized or follows some other game criteria. This is the key to successful implementation and depends on the particular mechanics of your game. You may want the “views” to be of similarly ranked players, VIP players, the user’s friends, or players in the same region.

For our example here, we use a “random” selection of users as the bucket for the specific player to see. We walk through the key portions of the code first, and provide the complete file for reference at the end.

Create bucketed leaderboard #

Here we create a new bucketed leaderboard, Bucketed Weekly #1, that rolls over every week.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
  // Set up the bucketed leaderboards tournament
  id := "bucketed_weekly"
  authoritative := true
  sortOrder := "desc"
  operator := "incr"
  resetSchedule := "0 0 * * 0"
  metadata := map[string]interface{}{}
  title := "Bucketed Weekly #1"
  description := ""
  category := 1
  startTime := 0
  endTime := 0
  duration := 604800
  maxSize := 100000000
  maxNumScore := 100000000
  joinRequired := false

  if err := nk.TournamentCreate(ctx, id, authoritative, sortOrder, operator, resetSchedule, metadata, title, description, category, startTime, endTime, duration, maxSize, maxNumScore, joinRequired); err != nil {
    return err
  }
  if err := initializer.RegisterRpc("get_bucket_records", rpcGetBucketRecordsFn([]string{id}, 2000)); err != nil {
    return err
  }
  return nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  // Set up the bucketed leaderboards tournament
  const id = 'bucketed_weekly';
  const authoritative = true;
  const sortOrder = nkruntime.SortOrder.DESCENDING;
  const operator = nkruntime.Operator.INCREMENTAL;
  const duration = 604800;
  const resetSchedule = '0 0 * * 0';
  const metadata = {};
  const title = 'Bucketed Weekly #1';
  const description = '';
  const category = 1;
  const startTime = 0;
  const endTime = 0;
  const maxSize = 100000000;
  const maxNumScore = 10000000;
  const joinRequired = false;

  nk.tournamentCreate(id, authoritative, sortOrder, operator, duration, resetSchedule, metadata, title, description, category, startTime, endTime, maxSize, maxNumScore, joinRequired);
  initializer.registerRpc('get_bucket_records', RpcGetBucketRecords([id], 2000));
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

Generate user bucket #

Each player will have an individual bucket - the opponent set they are playing against - that is created for, and unique to them. First we define the bucket storage object:

Server
1
2
3
4
5
// Define the bucketed leaderboard storage object
type userBucketStorageObject struct {
  ResetTimeUnix uint32   `json:"resetTimeUnix"`
  UserIDs       []string `json:"userIds"`
}
Server
1
2
3
4
5
// Define the bucketed leaderboard storage object
interface UserBucketStorageObject {
  resetTimeUnix: number,
  userIds: string[]
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

Then we define an RPC function to get the player’s ID and check for any existing bucket. If one is found we fetch it, if not we create a new bucket. Lastly we fetch the actual leaderboard.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Get a user's bucket (records) and generate a new bucket if needed
func RpcGetBucketRecordsFn(ids []string, bucketSize int) func(context.Context, runtime.Logger, *sql.DB, runtime.NakamaModule, string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    if len(payload) > 0 {
      return "", ErrNoInputAllowed
    }

    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      return "", ErrNoUserIdFound
    }

    collection := "buckets"
    key := "bucket"

    objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{
      {
        Collection: collection,
        Key:        key,
        UserID:     userID,
      },
    })
    if err != nil {
      logger.Error("nk.StorageRead error: %v", err)
      return "", ErrInternalError
    }

    // Fetch any existing bucket or create one if none exist
    userBucket := &userBucketStorageObject{ResetTimeUnix: 0, UserIDs: []string{}}
    if len(objects) > 0 {
      if err := json.Unmarshal([]byte(objects[0].GetValue()), userBucket); err != nil {
        logger.Error("json.Unmarshal error: %v", err)
        return "", ErrUnmarshal
      }
    }

    // Fetch the leaderboard
    leaderboards, err := nk.LeaderboardsGetId(ctx, ids)
    if err != nil {
      logger.Error("nk.LeaderboardsGetId error: %v", err)
      return "", ErrInternalError
    }
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const RpcGetBucketRecordsFn = function (ids: string[], bucketSize: number) {
  return function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
    if (!payload) {
      throw new Error('no payload input allowed');
    }

    const collection = 'buckets';
    const key = 'bucket';

    const objects = nk.storageRead([
      {
        collection,
        key,
        userId: ctx.userId
      }
    ]);

    // Fetch any existing bucket or create one if none exist
    let userBucket: UserBucketStorageObject = { resetTimeUnix: 0, userIds: [] };

    if (objects.length > 0) {
      userBucket = objects[0].value as UserBucketStorageObject;
    }

    // Fetch the tournament leaderboard
    const leaderboards = nk.tournamentsGetId(ids);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

Check bucket status #

Before proceeding further, we perform a check to see if the leaderboard has reset or if no opponents are present. If either is the case, we must generate a new opponent set.

Server
1
2
3
4
5
6
// Leaderboard has reset or no current bucket exists for user
if userBucket.ResetTimeUnix != leaderboards[0].GetPrevReset() || len(userBucket.UserIDs) < 1 {
  logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

  // Code below goes here...
}
Server
1
2
3
4
5
6
// Leaderboard has reset or no current bucket exists for user
if (userBucket.resetTimeUnix != leaderboards[0].endActive || userBucket.userIds.length < 1) {
  logger.debug(`RpcGetBucketRecordsFn new bucket for ${ctx.userId}`);

  // Code below goes here
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

Generate opponent set #

To generate our random opponents list, we will use the UsersGetRandom function available in Nakama 3.5.0.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
userBucket.UserIDs = nil
logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

users, err := nk.UsersGetRandom(ctx, bucketSize)
if err != nil {
  logger.Error("Error getting random users.")
  return "", ErrInternalError
}

for _, user := range users {
  userBucket.UserIDs = append(userBucket.UserIDs, user.Id)
}
Server
1
2
3
4
const users = nk.usersGetRandom(bucketSize);
users.forEach(function (user: nkruntime.User) {
  userBucket.userIds.push(user.userId);
});
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

If you want to ultimately end up with a specifically defined opponents list (e.g. Level 10-20 players only), the recommended approach is to (over)scan in the database query by a factor of the desired bucket size, then in the application layer filter based on your relevant criteria (player metadata).

Write the new bucket #

After generating the new opponents list, we write this new user bucket, first setting the bucket reset and leaderboard end times to match.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Set the Reset and Bucket end times to be in sync
userBucket.ResetTimeUnix = leaderboards[0].GetNextReset()

value, err := json.Marshal(userBucket)
if err != nil {
  return "", ErrMarshal
}

// Store generated bucket for the user
if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{
  {
    Collection:      collection,
    Key:             key,
    PermissionRead:  0,
    PermissionWrite: 0,
    UserID:          userID,
    Value:           string(value),
  },
}); err != nil {
  logger.Error("nk.StorageWrite error: %v", err)
  return "", ErrInternalError
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Set the Reset and Bucket end times to be in sync
userBucket.resetTimeUnix = leaderboards[0].endActive;

// Store generated bucket for the user
nk.storageWrite([{
  collection,
  key,
  userId: ctx.userId,
  value: userBucket,
  permissionRead: 0,
  permissionWrite: 0
}]);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

Lastly, since the user list is pseudo-randomly generated the user themselves may or may not be included, so we also explicitly add the user to the bucketed leaderboard before listing the records.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Add self to the list of leaderboard records to fetch
userBucket.UserIDs = append(userBucket.UserIDs, userID)

_, records, _, _, err := nk.LeaderboardRecordsList(ctx, ids[0], userBucket.UserIDs, bucketSize, "", 0)
if err != nil {
  logger.Error("nk.LeaderboardRecordsList error: %v", err)
  return "", ErrInternalError
}

result := &api.LeaderboardRecordList{Records: records}
encoded, err := json.Marshal(result)
if err != nil {
  return "", ErrMarshal
}

logger.Debug("rpcGetBucketRecordsFn resp: %s", encoded)
return string(encoded), nil
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Add self to the list of leaderboard records to fetch
userBucket.userIds.push(ctx.userId);

// Get the leaderboard records
const records = nk.tournamentRecordsList(ids[0], userBucket.userIds, bucketSize);

const result = JSON.stringify(records);
logger.debug(`RpcGetBucketRecordsFn resp: ${result}`);

return JSON.stringify(records);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

You can verify your leaderboard and its settings using Nakama Console:

Bucketed Leaderboard
Bucketed Leaderboard

Example file #

Download the full example file bucketed_leaderboards.go, bucketed_leaderboards.ts.