← All docs
Raw

Leaderboards

Every Unbase project includes a leaderboard service for HTML games. A static game (on your own domain, itch.io, GitHub Pages, Netlify, gated.page…) gets high-score tables with one script tag and no backend of its own:

  • leaderboards per all-time, daily, weekly, monthly or season period, ranked by up to three fields,
  • cheat resistance that works without a game server: single-use run tokens, a server-measured run clock, plausibility rules, rate limits and an origin allowlist,
  • moderation in the dashboard: shadow-ban, block, delete runs and review flagged scores.

See it live in Glowdeep, a roguelite shooter on itch.io, or read the overview at unbase.dev/games.

Hard to cheat, easy to clean up, but not cheat-proof. Anything a browser game sends can be forged by a determined player. The checks here stop casual tampering and impossible scores, and the moderation tools handle the rest.

Quick start

  1. Create a project (or open one in the dashboard) and copy its anon key. It's safe to ship in a web page.
  2. Add the script and call start() when a run begins and submit() when it ends:
<script src="https://api.unbase.dev/v1/leaderboard.js" data-key="YOUR_ANON_KEY"></script>
<div id="leaderboard"></div>
<script>
  const lb = UnbaseLeaderboard;
  const board = lb.widget(document.getElementById("leaderboard"), { boards: ["all", "daily"] });
  let run;

  async function onGameStart() {
    run = await lb.start();
  }

  async function onGameOver(score) {
    const res = await lb.submit(run, { name: lb.getName() || "Player", values: { score } });
    if (res.ok) console.log("You placed #" + res.rank.all);
    board.refresh();
  }
</script>

That's it. Until you configure anything, every project has one integer field, score, ranked on an all-time board (all) and a daily board (daily).

Keep the project. Anonymous projects are deleted after 7 days. Before you ship, claim it with your email, or create it from a logged-in account, so your boards don't vanish.

Concepts

Concept What it is
Field A named number your game submits, e.g. score, level, time_s. Each has a type (int or float) and a min/max. Up to 8 fields.
Board A ranking over the fields: up to three [field, "asc" | "desc"] sort keys, a period, and whether to keep each player's best run or all runs. Up to 10 boards.
Run One play session. start() issues a single-use token and starts the server's clock; submit() spends it.
Device A random id the SDK keeps in localStorage. It's the player identity for "best run per player", rate limits and bans. Players don't sign up.
Rule A plausibility check from a fixed library (no custom code) that runs on every submit.

Configuring your game

Edit the config in Dashboard → Leaderboards → Install & setup (presets: Arcade, Roguelite, Speedrun), or PUT it with the secret key:

{
  "fields": [
    { "name": "level",  "type": "int", "min": 1, "max": 200 },
    { "name": "kills",  "type": "int", "min": 0, "max": 100000 },
    { "name": "rads",   "type": "int", "min": 0, "max": 1000000, "public": false },
    { "name": "time_s", "type": "int", "min": 0, "max": 21600, "duration": true }
  ],
  "boards": [
    { "slug": "all",   "sort": [["level", "desc"], ["kills", "desc"], ["time_s", "asc"]], "period": "all",   "keep": "best" },
    { "slug": "daily", "sort": [["level", "desc"], ["kills", "desc"], ["time_s", "asc"]], "period": "daily", "keep": "best" }
  ],
  "rules": [
    { "type": "min_per", "field": "time_s", "per": "level", "offset": 1, "ratio": 12, "message": "too fast" },
    { "type": "max_per", "field": "kills", "per": "level", "ratio": 75, "message": "too many kills" },
    { "type": "max_linear", "field": "rads", "terms": [{ "field": "kills", "k": 12 }, { "field": "level", "k": 40 }] },
    { "type": "max_rate", "field": "kills", "per_second": 3, "flag": true }
  ],
  "allowedOrigins": ["https://mygame.com", "https://*.gated.page", "itch"]
}

Fields. Every field is required on every submit, and anything not in the schema is refused. "public": false hides a field from public boards; it is still validated and visible to you. Mark at most one field "duration": true (seconds of play). A run can't claim to have lasted longer than the time the server measured between start() and submit(), plus 5 seconds of grace. Shorter claims are fine, because games pause.

Boards. Periods roll over at midnight UTC: daily keys look like 2026-10-02, weekly uses ISO weeks (2026-W40, Monday start), and monthly looks like 2026-10. A season board needs "season": "s1"; change the id to start a fresh season. Old periods stay readable with ?period=<key>. With keep: "best" a player has one row (their best run). With keep: "all" every accepted run is a row. Changing a board's sort order re-ranks it, and removing a board deletes its scores.

Scoped boards

Add "scoped": true to a board to make it a family of boards, one per scope your game names: one per level, per map, per world cell. Pass the scope when submitting, and name it after a colon when reading:

lb.submit(run, { name, values: { beam: 42 }, scope: "3,-2" });
lb.top("beam:3,-2"); // board "beam", scope "3,-2"

A scope is 1–64 letters, digits or _ . , : -. A submit without a scope skips scoped boards and still counts on the others; a submit with one counts on both. A scoped board keeps its period, so a weekly scoped board is one board per scope per week. Reading a scoped board without a scope, or giving a scope to an ordinary board, is 400 bad_scope. Scopes need no setup, so don't let players pick arbitrary ones. See Players & worlds for the world grid these pair with.

Rules. Each rule is checked against the submitted values and the server-measured run length:

type params passes when
min_per field, per, ratio, offset field >= (per - offset) * ratio, e.g. at least 12 s per level
max_per field, per, ratio, offset field <= (per + offset) * ratio, e.g. at most 75 kills per level
max_linear field, terms: [{field, k}], c field <= Σ k·term + c
max_rate field, per_second field / real_seconds <= per_second
cap_delta field, max_increase_pct the player didn't beat their own best by more than X% (always a flag)

A failing rule rejects the score (422 implausible, with your message) unless it has "flag": true. In that case the score is accepted and lands in the flags queue for you to review.

Allowed origins. An empty list accepts any site. Otherwise the game-facing routes only answer pages served from a listed origin: exact (https://mygame.com), wildcard (https://*.gated.page), or itch (itch.io's *.itch.zone / *.itch.io). This stops other websites from posting to your boards, but not scripts or curl, which can send any Origin header. Every itch.io game shares the same origins, so the allowlist can't tell itch games apart.

Browser SDK

<script src="https://api.unbase.dev/v1/leaderboard.js" data-key="ANON_KEY"> defines window.UnbaseLeaderboard. The anon key carries the project id, so it's the only setting. Every method returns a promise that never rejects: on failure it resolves { ok: false, error: { code, message } }, so a leaderboard outage can't crash your game. Requests time out after 6 s.

method
start() Begin a run → { ok, token, runId, startedAt }. Call it when play actually starts.
submit(run, { name, values, scope }) End a run → { ok, accepted, runId, rank: {board: n}, newBest: {board: bool}, top: {board: rows} }. name defaults to the last one used on this browser. scope also files the run on scoped boards.
top(board, { limit, period }) { ok, board, periodKey, rows: [{ rank, name, values, at, me? }] }. Rows are the top 10 by default (100 max), and ties share a rank. For a scoped board, pass "slug:scope".
around(board, { span, period }) This player's row and span neighbours either side (default 5) → { ok, rank, rows }, with rank: null if they have no score yet.
widget(el, { boards, limit, columns }) A ready-made table with a tab per board. Returns { refresh(), show(board) }. Theme it with CSS variables --ulb-fg, --ulb-bg, --ulb-accent, --ulb-muted, --ulb-font.
getName() / setName(name) The remembered display name.
device() This browser's player id, or null until it's known when storage is blocked (see below).
ready() Resolves the player id once it's known.
storage() Where the player id is kept: "local", "cookie" or "memory" (see below).
init({ key, api }) Configure manually instead of with data-key, e.g. when bundling.

Names are 1–20 characters: letters (any language), digits, spaces and _ . - '.

Where the player's identity lives

Players don't sign up. Each browser gets a random player id, which is what ties its scores and unlocks together. Normally the SDK keeps it in localStorage (storage() returns "local").

Games on itch.io and similar hosts run inside a cross-site iframe. Many browsers block storage there: Chrome in incognito or with third-party cookies blocked, Brave, and strict privacy modes. If the id lived only in memory, every reload would start a new player and the progress would seem lost. So when storage is blocked, the SDK keeps the id in a partitioned cookie on the API instead (storage() returns "cookie"). A partitioned cookie is keyed to the site hosting the game, so it survives reloads without letting anyone track players across sites. This needs no code in your game. Calls simply wait for the id; use ready() if you need it yourself.

storage() returns "memory" only when neither works (for example, every cookie is blocked). Then progress lasts until the page reloads. A game can check for this and suggest the player keep a save code.

getName() keeps working too. When storage is blocked, the SDK gets back the name the player last used together with their id, so getName() returns it once ready() has resolved.

HTTP API

If you'd rather not use the script, call the API directly. Base URL: https://api.unbase.dev/v1/projects/:id/leaderboards.

Game routes take the anon key in the apikey header and are subject to the origin allowlist:

  • POST /runs/start { "device": "<8–64 chars [A-Za-z0-9_-]>" } → 201 { runId, token, startedAt }. Each device can start 30 runs a minute and each IP 300 an hour; beyond that the response is 429 with a Retry-After header.
  • POST /runs/submit { "token", "name", "values": { … }, "scope"? } → 200 { accepted, runId, rank, newBest, top }. Tokens are single-use (409 run_used) and expire after 6 hours (410 run_expired).
  • GET /boards/:board/top?limit=10&period=current&device=… returns the top rows. Pass device to get me: true on your own row. For a scoped board, :board is slug:scope (or pass ?scope=).
  • GET /boards/:board/around?device=…&span=5&period=current returns your row and its neighbours.

Owner routes take the secret key (Authorization: Bearer …) and skip the origin check:

  • GET / returns the config, each board's current period and entry count, runs in the last 24 h by status, open flags and bans.
  • GET /config and PUT /config read and replace the whole config. Validation failures are 400 invalid_config with the reason.
  • GET /boards/:board/entries?period=&limit=50&offset=0 lists every row, any visibility, with private fields, device and run ids.
  • GET /runs?status=rejected|accepted|blocked|expired&limit=50 lists recent runs with their rejection reasons. Runs are kept for 14 days.
  • DELETE /runs/:runId removes a run from every board. With keep: "best", the player's previous best is not restored.
  • GET /bans, PUT /bans/:device { "kind": "shadow" | "block", "reason"? }, and DELETE /bans/:device manage bans.
  • GET /flags?resolved=true and POST /flags/:id/resolve work the flags queue.

The secret key can also call the game routes, for example to submit scores from your own server or to send a test score from the dashboard.

Errors use the standard { "error": { "code", "message" } } body. The codes are bad_request, bad_name, invalid_values, invalid_config, bad_scope, origin_not_allowed, unauthorized, not_found, run_not_found, run_used, run_expired, implausible and rate_limited. Messages are short and safe to show players.

Progression

Leaderboards rank single runs. Progression tracks what a player achieves across runs: lifetime stats, unlocks (achievements, characters, skins), and a board of who has unlocked the most. A browser game can't be trusted to report its own progress, so the server grants progress, and only from runs it has accepted. Progress is as hard to fake as your scores: a rejected or blocked run never changes it.

Configure it in Dashboard → Leaderboards → Progression (forms, no JSON needed), or add a progression section to the config:

"progression": {
  "stats": [
    { "name": "runs",        "agg": "count" },
    { "name": "best_level",  "agg": "max", "field": "level" },
    { "name": "total_kills", "agg": "sum", "field": "kills" },
    { "name": "secrets",     "agg": "sum", "field": "secrets", "public": false }
  ],
  "unlocks": [
    { "id": "beat_warden", "name": "Warden Slayer", "description": "Beat the Warden",
      "when": { "stat": "best_level", "gte": 6 } },
    { "id": "speed_demon", "name": "Speed Demon", "description": "Reach level 5 in under 6 minutes",
      "when": { "all": [ { "run": "level", "gte": 5 }, { "run": "time_s", "lte": 360 } ] } },
    { "id": "tunneller", "name": "The Tunneller", "description": "Find 3 secret rooms",
      "hidden": true, "requires": ["beat_warden"], "when": { "stat": "secrets", "gte": 3 } }
  ],
  "board": { "enabled": true }
}

Stats (up to 16) are per-player lifetime numbers, updated by every accepted run. agg is one of:

  • count: the number of runs.
  • sum, max, min or last of a score field.

A "public": false stat is shown only to the player and to you.

Unlocks (up to 64) are earned once, the first time their when condition holds after a run:

  • id is the stable key your game checks. For example, "character tunneller is playable". Renaming it counts as removing one unlock and adding another.
  • requires lists unlocks that must already be earned. Chains resolve in a single submit.
  • hidden: true shows the unlock as ??? until it's earned.

A condition is one of:

  • { "run": <score field>, <op>: n }: a value from the run just submitted.
  • { "stat": <stat>, <op>: n }: a stat, after this run is counted.
  • { "all": [...] } or { "any": [...] }: nested up to 4 deep, with at most 16 parts.

<op> is exactly one of gte, gt, lte, lt or eq.

The progress board ranks players by number of unlocks. On a tie, whoever completed their latest unlock first ranks higher. Turn it off with "board": { "enabled": false }. Bans apply here too: a shadow-banned player sees themselves on the board and nobody else does, and blocked players disappear from it.

In the game

const lb = UnbaseLeaderboard;

// Instantly and offline, from the last progress the SDK saw:
const unlocked = lb.cachedProgress()?.unlocked ?? [];       // e.g. ["beat_warden"]
if (unlocked.includes("tunneller")) enableCharacter("tunneller");

// After a run, celebrate anything new:
const res = await lb.submit(run, { name, values });
if (res.ok && res.progress) {
  for (const u of res.progress.newUnlocks) showToast("Unlocked: " + u.name);
}

const p = await lb.progress();        // { stats, unlocked: [{id, name, unlockedAt}], locked: [...], rank, total }
await lb.progressTop({ limit: 10 });  // the progress board
await lb.progressAround({ span: 5 });
lb.widget(el, { boards: ["all", "progress"] });  // a "progress" tab shows the progress board

submit() returns progress: { newUnlocks, unlocked, stats, rank, total } whenever progression is configured. cachedProgress() is synchronous and returns { stats, unlocked: [ids], rank, total, at }, or null before the first call. It is refreshed by every submit() and progress() call.

Moving a save to another browser

Players have no accounts; their identity is a random device id in localStorage. To continue on another browser or device, a player asks for a save code and enters it on the new one:

const { code } = await lb.linkCode();   // "NF3H-F8CJ" — show it to the player
// on the other browser:
await lb.claimCode("nf3h f8cj");        // case and dashes don't matter

The new browser becomes the original player: the SDK adopts the original device id, so it gets the same progress, leaderboard rows and bans. Progress from the two devices is never merged. Codes:

  • are 8 characters (no 0/O/1/I);
  • work once and expire after 15 minutes;
  • are stored only as hashes;
  • are limited to 5 per device per hour, with 10 claim attempts per IP per hour.

Save codes work even without progression, for moving leaderboard rows.

Changing the config over time

  • Adding an unlock: nobody has it yet. Run a backfill (button in the dashboard, or POST /progress/backfill) to grant it to players who already qualify.
    • stat: conditions use stored stats.
    • run: conditions use the player's best entry on an all-time, best-run board, so they're approximate: backfill checks the best run, not every run.
    • Backfill also includes players who only have leaderboard rows from before progression existed, and seeds their missing max/min/last stats from that entry.
  • Removing an unlock: players keep the row, but it stops counting and isn't shown. Re-adding the same id restores it.
  • Adding a stat: it counts from the next run (backfill can seed max/min/last).
  • Removing a stat, or changing its agg or field: existing values are deleted. The dashboard warns you before saving.
  • Deleting a run removes its leaderboard rows but doesn't roll back stats, since they're aggregates. Use the owner tools below to revoke an unlock or reset a player.

Progression API

Game routes take the anon key; device ids are never returned for other players.

Route
GET /progress?device= { stats, unlocked, locked, rank, total }, including the device's own private stats.
GET /progress/top?limit=10&device= { rows: [{ rank, name, unlocks, total, lastUnlockAt, me? }], total }. 404 if the board is off.
GET /progress/around?device=&span=5 The device's row and its neighbours.
POST /devices/link { device } { code, expiresAt }. 404 if the device has nothing to transfer.
POST /devices/claim { code, device } { device, progress }, where device is the original player's id. 400 bad_code if the code is unknown, used or expired.

Owner routes take the secret key:

Route
GET /progress/summary Players with progress, and each unlock's earned count and percentage of players.
GET /progress/players?q= Players ranked by progress, or matched by name fragment or exact device id.
GET /progress/devices/:device Stats (with updatedAt), every unlock (source run/owner/backfill, run id), profile and ban.
POST /progress/devices/:device/unlocks { id } Grant an unlock (support: "I lost my save").
DELETE /progress/devices/:device/unlocks/:id Revoke an unlock.
DELETE /progress/devices/:device Reset the player's stats and unlocks. Leaderboard rows stay.
POST /progress/backfill { cursor?, limit? } Processes up to 5,000 players and returns { processed, granted, nextCursor }. Repeat until nextCursor is null. Safe to re-run.

If the config has no progression section, progress routes return 400 progression_disabled.

Moderation

  • Shadow-ban: the player keeps seeing their own scores (in top with their device, in around and in submit responses), but nobody else does. Use it for cheaters you don't want to tip off.
  • Block: the player's existing rows are hidden, and new submits get a normal-looking accepted response while nothing is written.
  • Unban: the player's rows become public again.
  • Flags: soft-rule hits wait for review. In the dashboard, mark one "looks fine", delete the run, or shadow-ban the device.

Limits and billing

Leaderboard traffic counts against your project's plan like SQL traffic: rows read on board reads, and rows written on starts and submits. A submit writes about (boards + 1) rows, plus one per changed stat, one per new unlock and one profile row when progression is on. Leaderboard data lives in your project but in reserved tables, so your own SQL can't see or touch it, and it isn't part of SQL exports. Deleting the project deletes it.