All developer documentation

Leaderboards

Games do not have to rank anything. This is a capability, not an obligation — but if your game has a score, a fastest time, or a longest streak worth comparing between players, the platform can hold the standings and your game can read them with the token your frame already holds.

A leaderboard here is read-only from the browser, by design. Your game reads standings and renders them; it cannot write a score, because a browser credential is public and a client-asserted score would be forgeable. Where the rows actually come from is covered below — read that section before you design around this feature, because it decides whether your game can use it at all today.

Two roles, and they are not the same person#

The same split as save slots: one half is something you publish, the other is code.

WhoWhen
Declaring a boardyou, the developeronce, in your game config
Reading a boardyour game's running codewhenever a standings screen renders

A board your config does not declare does not exist: reading it is refused with not_found, at the first call and at every retry after it.

1. Declare the board, in your game config#

Two sections of the game config work together. A run type names the values a single play produces — up to two integers and two floats, each with a friendly name — and a leaderboard says how to rank runs of that type:

run_types:
  - id: race
    int_value_1_name: score

leaderboards:
  - id: top-score
    run_type: race
    order_by:
      - column: int_value_1
        direction: desc
    max_size: 100
    update_frequency_seconds: 600

The friendly name is what your game reads back: a row on this board carries stats.score, not stats.int_value_1. max_size caps the board at up to 1000 rows, and update_frequency_seconds is how often the served board refreshes — more on that below.

Three flags decide what a row shows, all declared on the board rather than chosen by the reader: expose_player (identity — on by default), expose_stats (the run's values — on by default), and expose_properties (the run's freeform properties — off by default).

2. Read it, whole#

const board = await gem.leaderboards.get('top-score');
console.log(`generated ${board.generatedAt}, refreshes every ${board.updateFrequencySeconds}s`);
for (const row of board.entries) {
  render(row.rank, row.displayTag ?? 'anonymous', row.stats?.score);
}

The whole board arrives in one response, already sorted, at most max_size rows. There is no paging and nothing further to assemble.

Poll at the cadence the response states, not faster. The platform serves a cached rendering and regenerates it at most once per updateFrequencySeconds; polling faster re-downloads the same rendering. generatedAt says when the rendering you hold was made.

Two facts about rows worth designing for:

  • A player can hold several rows. Ranks are per run, arcade-style — the same player's second-best run is still on the board if it ranks.

  • An anonymous row is still a row. A deleted player's entry keeps its rank — removing it would shift everyone below it — and arrives with an entityId but no display fields. Render it as "anonymous" rather than skipping it, or every rank under it will look wrong.

Buckets — one declaration, many boards#

A board that declares bucket_by is partitioned: each distinct bucket value is its own board with its own ranks. Declare the bucket column on the run type, list its allowed values, and name the bucket when you read:

run_types:
  - id: race
    int_value_1_name: score
    bucket_1_name: region
    bucket_1_allowed_values: [na, eu]

leaderboards:
  - id: regional
    run_type: race
    order_by:
      - column: int_value_1
        direction: desc
    bucket_by: [bucket_1]
const board = await gem.leaderboards.get('regional', { bucket1: 'na' });

A bucketed board owes exactly its declared buckets — omitting one, or supplying one the board does not declare, is refused with invalid.

Where the rows come from#

Runs are reported during a dedicated-server match, from the server side — never from the browser. That is the integrity model: the standings are only worth rendering because nothing a player controls can write to them.

Two consequences to plan around:

  • A game whose matches run entirely in the browser (single-player, or peer-to-peer multiplayer) has no path onto a board. If standings matter to your game, its scoring matches need to be dedicated-server matches — dedicated-server-flow.md.

  • The server-bundle call that reports runs is not available yet. The read surface this page documents works today, and a board with no runs yet is an ordinary empty board — entries is [], which your standings screen must render as a designed state, because every new game's board starts that way.

When a read is refused#

What you getWhat happenedWhat to do
not_foundno board with this id in the channel's active configcheck the id against your config, and that the config is assigned to the channel you are on
invalidbucket values do not match the board's bucket_bysupply exactly the buckets the board declares

Read this page as Markdown