⌘K
↑↓ Navigate ↵ Open Esc Close
Tier 3Leaderboard15 / 29

Leaderboards: post a score, read a page, and watch the config gotchas

Persistent State: leaderboards are tier 4 in that article's framing (durable, cross-session); this article is the deep dive.

brainCloud provides both global and social leaderboards, each configured with a score type: High Value, Low Value, Cumulative, or Last Entry. Posting and reading a leaderboard only takes two calls. The hard part is that a leaderboard's score type (does it keep the highest value, or the lowest?) has to match how the app posts to it, or scores look fine on read and then silently vanish on the next post. The spider_solitaire Dart example (repositories/dart-examples/spider_solitaire) runs three leaderboards per difficulty (high score, fastest time, fewest moves), and its LeaderboardService documents every one of these gotchas inline, from real production pain.

How it works

  1. Pick a score type that matches the sort direction, and pass it at post time. HIGH_VALUE keeps the best (highest) score per player; LOW_VALUE keeps the best (lowest). Time and move-count leaderboards need LOW_VALUE; otherwise brainCloud's default "keep the highest" behavior silently drops every improvement.
  2. postScoreToDynamicLeaderboardUTC lets the score type be specified per call. That matters because it only takes effect the first time a leaderboard is created. Once a leaderboard exists with the wrong type, the only fix is deleting it in the portal; it can't be reconfigured from the client.
  3. A leaderboard doesn't exist until someone posts to it. Reading one nobody has played yet returns a noLeaderboardFound reason code instead of an empty page. UI logic should treat that as zero entries, not an error.
  4. Post multiple metrics in parallel when one game result feeds more than one leaderboard. A win here posts to high-score, fastest-time, and fewest-moves all at once. They're independent leaderboards, so there's no ordering dependency between the writes; posting them one after another isn't necessary.
  5. Embed a display name in the score payload as a fallback. Reading a page back returns a profile-level name when one exists. Embedding a player-supplied name in the score data at post time covers the case when it doesn't.

Code

Retention type must match sort order, chosen and documented up frontleaderboard_service.dart:9-26
enum LeaderboardMetric {
  highScore('High Score', SortOrder.HIGH_TO_LOW, SocialLeaderboardType.HIGH_VALUE),
  fastestTime('Fastest Time', SortOrder.LOW_TO_HIGH, SocialLeaderboardType.LOW_VALUE),
  fewestMoves('Fewest Moves', SortOrder.LOW_TO_HIGH, SocialLeaderboardType.LOW_VALUE);
  // HIGH_TO_LOW pairs with HIGH_VALUE; LOW_TO_HIGH pairs with LOW_VALUE. Mismatch here
  // means scores look correct on read but get silently dropped on the next post.
}
Posting, with the retention type gotcha spelled out at the sourcebraincloud_service.dart:159-186
/// Uses `postScoreToDynamicLeaderboardUTC` so we can specify the retention
/// type per call. brainCloud's default leaderboard config keeps the
/// *highest* score per player, which silently drops every faster-time /
/// fewer-move post on a LOW_VALUE leaderboard.
Future<void> postScore({
  required String leaderboardId,
  required int score,
  required SocialLeaderboardType leaderboardType,
  Map<String, dynamic>? data,
}) async {
  final response = await _bc.socialLeaderboardService
      .postScoreToDynamicLeaderboardUTC(
        leaderboardId: leaderboardId,
        score: score,
        data: data,
        leaderboardType: leaderboardType,
        rotationType: RotationType.NEVER,
        retainedCount: 8,
      );
  _throwIfFailed(response, 'postScoreToDynamicLeaderboardUTC($leaderboardId)');
}
A win posts to three leaderboards in parallelleaderboard_service.dart:112-137
Future<void> postWin({required Difficulty difficulty, required int score,
                       required int elapsedSeconds, required int moves, String? playerName}) async {
  final payload = {'score': score, 'time': elapsedSeconds, 'moves': moves,
                    'suits': difficulty.suitCount, 'completed': true,
                    if (playerName != null) 'playerName': playerName};
  await Future.wait([
    _bc.postScore(leaderboardId: LeaderboardIds.highScore(difficulty.suitCount),
                  score: score, leaderboardType: LeaderboardMetric.highScore.leaderboardType, data: payload),
    _bc.postScore(leaderboardId: LeaderboardIds.fastestTime(difficulty.suitCount),
                  score: elapsedSeconds, leaderboardType: LeaderboardMetric.fastestTime.leaderboardType, data: payload),
    _bc.postScore(leaderboardId: LeaderboardIds.fewestMoves(difficulty.suitCount),
                  score: moves, leaderboardType: LeaderboardMetric.fewestMoves.leaderboardType, data: payload),
  ]);
}
Reading a page, with the "not created yet" case handled explicitlybraincloud_service.dart:191-219
/// A leaderboard only starts existing once someone posts the first score to
/// it, so reading one nobody has played yet returns `noLeaderboardFound`
/// rather than an empty page, so treat that the same as "no entries."
Future<Map<String, dynamic>> getLeaderboardPage({
  required String leaderboardId, required SortOrder sortOrder,
  int startIndex = 0, int endIndex = 99,
}) async {
  final response = await _bc.socialLeaderboardService.getGlobalLeaderboardPage(
      leaderboardId: leaderboardId, sortOrder: sortOrder, startIndex: startIndex, endIndex: endIndex);
  if (!response.isSuccess() && response.reasonCode == ReasonCodes.noLeaderboardFound) {
    return const {'leaderboard': []};
  }
  _throwIfFailed(response, 'getGlobalLeaderboardPage($leaderboardId)');
  return _unwrap(response.data);
}
Name fallback when parsing a pageleaderboard_service.dart:163-176
// Prefer profile-level name; fall back to the playerName embedded in the
// score data; finally fall back to a placeholder.
final name = profileName.isNotEmpty ? profileName
    : (dataName.isNotEmpty ? dataName : 'Player');

Gotchas worth knowing up front

  • Score type and sort order are a pair; getting them wrong fails silently. A LOW_TO_HIGH leaderboard posted to with the default (HIGH_VALUE) score type keeps looking correct until a player posts a better (lower) score and it doesn't update, because the server treats a higher number as the improvement.
  • Fixing a misconfigured leaderboard means deleting it in the portal. No post method reconfigures it: postScoreToDynamicLeaderboardUTC's type argument only governs creation of a brand-new leaderboard.
  • noLeaderboardFound isn't a failure state for a fresh leaderboard. It's the expected response before the first post. The caller should special-case that reason code rather than surface it as an error.
  • Post a partial or incomplete result to fewer leaderboards, deliberately. This reference app posts an abandoned game's score to the high-score board only, skipping fastest-time and fewest-moves, because those metrics only mean something for a completed game.