# 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](#where-the-rows-come-from) — 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.

|                        | Who                      | When                                            |
| ---------------------- | ------------------------ | ----------------------------------------------- |
| **Declaring a board**  | you, the developer       | once, in your game config                       |
| **Reading a board**    | your game's running code | whenever 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:

```yaml
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

```js
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:

```yaml
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]
```

```js
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`](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 get | What happened | What to do |
| ------------ | ------------- | ---------- |
| `not_found` | no board with this id in the channel's active config | check the id against your config, and that the config is assigned to the channel you are on |
| `invalid` | bucket values do not match the board's `bucket_by` | supply exactly the buckets the board declares |
