Leaderboards

Declaring the game's own boards, filing entries on them, and reading a page back for an in-client HUD — plus why a board is a key and a scope rather than a fixed set of columns.

A leaderboard here is the game’s own, not ours: you declare it, you file its entries, and we hold it and rank it. A surf timer’s map records, a deathmatch’s top fraggers, a racing game’s lap times.

Nothing in any body names an app or a server. The token decides which boards these are — a credential scoped to anything but an app or one of its servers is refused, because “leaderboards for an article” is not a thing and a broader surface is a broader surface.

Declaring a board

POST/api/integration/v1/leaderboard/defineIntegrationLEADERBOARD_WRITE

Declare the game’s boards. Idempotent — send the whole table at boot and only the changed ones move.

{
  "boards": [
    {
      "key": "fastest",
      "scope": { "map": "surf_beginner", "track": "0", "style": "normal" },
      "name": "Fastest time",
      "kind": "TIME",
      "decimals": 3,
      "unit": ""
    }
  ],
  "ts": 1735689600,
  "nonce": "…"
}
{ "ok": true, "created": 1, "updated": 0 }

A board is a key and a scope

The scope is a map, not a fixed set of columns. A surf timer scopes by map, track and style; a deathmatch by map and mode; a 2D game by nothing at all. Fixed columns would mean every consumer carrying a track field that most games leave at zero, and a game with a fourth dimension having nowhere to put it.

At most eight dimensions, all identifiers.

kind is only ever about ordering

There are four, because ordering is the only thing that differs:

Kind Ordering
TIME Lower is better
PENALTY Lower is better
SCORE Higher is better
POINTS Higher is better

Ceilings

25 boards per request, 2000 per owner. Past the second, define refuses and says so in refused[] rather than growing.

That ceiling exists for one specific bug: a reporter that scopes a board by player id creates one board per player, and every individual request that does it looks perfectly reasonable.

Filing entries

POST/api/integration/v1/leaderboard/submitIntegrationLEADERBOARD_WRITE

File up to fifty entries — twenty players finishing a round would otherwise be twenty POSTs to one endpoint.

{
  "entries": [
    {
      "board": "fastest",
      "scope": { "map": "surf_beginner", "track": "0", "style": "normal" },
      "player": "srv-scoped-pseudonym",
      "name": "Christian",
      "value": 42.517,
      "setAt": 1735689600,
      "meta": { "splits": { "1": 12.4 }, "jumps": 212, "sync": 0.91 }
    }
  ],
  "overwrite": false,
  "ts": 1735689600,
  "nonce": "…"
}
{ "ok": true, "accepted": 3, "improved": 1, "unchanged": 2 }

Only an improvement is written, unless overwrite is set. “Keep the best” is what a leaderboard means, and a reporter that sent every attempt would otherwise replace the record with the most recent failure. overwrite is for a board whose value is a running total — points, playtime — where the newest figure is the right one.

A result that did not beat the player’s own best is accepted and counted in unchanged, not refused. Most results are worse than the player’s best, and a reporter sending them is behaving correctly.

meta is capped at 2 KB once serialised, because a nested object’s size is not its key count. Omitting it leaves whatever was there; sending an explicit null clears it.

A board holds 10 000 entries and prunes the worst, not the oldest — a leaderboard is a ranking, and pruning by age eventually deletes the record itself.

A TIME board refuses a negative value

It sorts ascending, so −1 sorts above every real run and can never be beaten: one malformed submission takes the top of the board permanently.

SCORE and PENALTY boards hold negative values legitimately, which is why this depends on the board rather than on the field.

player is never a site account

It is whatever the reporting server uses to mean “this player on this server”. The play backbone hands a server a per-scope pseudonymous id precisely so operators cannot correlate their players across servers, and a leaderboard storing a global id would undo that for every game that adopts it. See Avatars for how that key is derived.

There is deliberately no field for linking a score to a site account. A server asserting “this player is that member” is a server that can put anybody’s name on anybody’s score; the only sound way to do it is for the player to prove the link. Until that runs end to end, these boards are pseudonymous and a display name is a display name.

Reading a board back

GET/api/integration/v1/leaderboard/boardIntegrationLEADERBOARD_READ

A page of one of the credential’s own boards, for an in-client HUD.

GET /api/integration/v1/leaderboard/board
      ?board=fastest
      &scope=map%3Dsurf_beginner%3Bstyle%3Dnormal%3Btrack%3D0
      &offset=0&limit=25
      &player=srv-scoped-pseudonym

scope is the canonical k=v;k=v string, because a query string cannot carry an object — and it is re-sorted server-side all the same, so a caller who writes the segments out of order still reaches the board they meant.

{
  "ok": true,
  "board": {
    "key": "fastest",
    "scope": "map=surf_beginner;style=normal;track=0",
    "name": "Fastest time",
    "kind": "TIME",
    "decimals": 3,
    "unit": "",
    "entries": 412
  },
  "rows": [
    { "rank": 1, "player": "…", "name": "…", "value": 38.204, "setAt": 1735689600, "meta": null }
  ],
  "self": { "rank": 37, "player": "…", "name": "…", "value": 42.517, "setAt": 1735689600, "meta": {} }
}

Ranks are derived from the ordering, not stored. A stored rank is wrong the moment anybody else scores, and keeping one correct means rewriting every row on every submission. self.rank is a count of strictly better entries plus one, so a tie is joint rather than pushing somebody down.