Leaderboards

A leaderboard is a named, ranked list of players and scores, scoped to your project. Your room posts to it; anyone can read it.

// in your room
rpc: {
  finishRound(state, _params, ctx) {
    const score = state.players.get(ctx.clientId)?.score ?? 0;
    void ctx.room.leaderboard.submit('weekly', ctx.playerId, score);
  },
}
// on your website, or anywhere else
const res = await fetch('https://irt.io/v1/leaderboard/<project>/weekly/top?limit=10');
const { entries } = await res.json();

Only your room can write

There is no client-side submit. Not a discouraged one, not a signed one — there is no API for it anywhere in the SDK, and no HTTP endpoint a browser could call. A score reaches a board along exactly one path: your room code calls room.leaderboard.submit, and the platform carries it the rest of the way with a credential no client has.

Two checks happen outside your tenant on the way:

  • The player has to be in the room. Submitting for ctx.playerId always works; submitting for an id you made up is refused with E_LB_NOT_IN_ROOM.
  • The project is taken from the machine credential, never from anything the room says, so a room cannot write to another project’s board.

What that does and does not buy you

It means a player cannot post 999999 from the browser console, because there is nothing to post to. That is worth a great deal and it is not the same as cheat-proof.

If your room trusts a number a client wrote into its own entity and forwards it to a board, the board will faithfully record that number. Server-authoritative means your server decides; it does not mean your server decided correctly. Compute the score from state you control.

Anti-abuse on irtio is server authority plus rate limits. There is nothing else behind that sentence — no heuristics, no anomaly detection, no shadow bans. If a board matters enough to be worth attacking, design the room so the score is not the client’s to influence.

Best score wins

A submit replaces a player’s stored score only if it is better. Three things follow, and all three are load-bearing:

  • A worse score cannot knock a player down. Submit freely at the end of every round.
  • The same score submitted twice changes nothing at all, so a retry or a duplicate is harmless. There is no “did I already post this” bookkeeping for you to get wrong.
  • The response says whether your submit is the one that now stands, so you can tell a player they beat their record without reading the board back.

Ties break by who got there first.

Direction

Boards are higher-is-better unless you say otherwise. For times, lap counts and stroke counts, say otherwise:

In irtio.json (excerpt):

"boards": { "lap-times": { "direction": "lower" } }

Every irtio deploy syncs the boards declared in irtio.json, so the declaration lives with the room code that writes to the board. The same declaration reaches the project directly if you would rather not deploy to change one:

curl -X PATCH https://irt.io/v1/projects/<id> 
  -H 'content-type: application/json' 
  -d '{"name":"my game","boards":{"lap-times":{"direction":"lower"}}}'

Direction belongs to the board, not to a score, so a board cannot end up with rows that disagree about which of them is winning.

Scores are whole numbers

Integers only. A float score is refused with E_LB_BAD_SCORE.

This is a decision, not a limitation we mean to lift: floats produce ties that are not really ties, they drift between the JSON your room sends and the value stored, and “why is my 0.1 + 0.2 in second place” is a support thread nobody should have. Submit milliseconds, centimetres, or points.

Rotation

A board can rotate. Declare a period on it and the platform keeps one ranking per period, writes into whichever period the clock is in, and reads that one back by default:

In irtio.json (excerpt):

"boards": { "weekly": { "direction": "higher", "period": "weekly" } }

Or against the project directly:

curl -X PATCH https://irt.io/v1/projects/<id> 
  -H 'content-type: application/json' 
  -d '{"name":"my game","boards":{"weekly":{"direction":"higher","period":"weekly"}}}'

Four kinds:

periodkeyrotates
daily2026-09-03midnight UTC
weekly2026-W36Monday 00:00 UTC
monthly2026-09the first of the month, 00:00 UTC
season:<name><name>when you rename it

Your room code does not change. room.leaderboard.submit takes no period and cannot name one: the current key is computed on the server from the clock at the moment the score arrives. That is deliberate, and it is the same reasoning as “there is no client submit” one level up. A period a room could name is a period a room could backdate.

There is no cron job and no rotation event. The current period is a pure function of the clock, so nothing has to run at midnight for a new day’s board to exist, and nothing can fail to run.

UTC, and ISO weeks

Every boundary is UTC. One time zone is the only choice that makes a board mean the same thing to every player on it, and a per-project time zone would mean two players in one game seeing different weeks.

Weeks are ISO 8601 weeks: they start on Monday, and they are numbered by the ISO year rather than the calendar one. The last days of December often belong to week 1 of the next year: 29 December 2025 is 2026-W01, and a score posted that evening lands on the same board as one posted on 1 January 2026. It goes the other way too, so some years have a week 53: 31 December 2026 is 2026-W53, and 2027’s first board opens on 4 January.

Reading a past period

A read defaults to the current period. Pass ?period= for a past one, using the key exactly as the table above spells it:

GET /v1/leaderboard/<project>/weekly/top?period=2026-W35

The key is read literally and never reinterpreted, so a board whose period you changed still serves its old rows under their old keys.

A cursor carries the period it was made for. A cursor from last week is refused on this week’s board with E_LB_BAD_CURSOR rather than quietly paging into the wrong ranking.

How many periods are kept

The last 12 by default, per board. Set keep to change it, from 1 to 120:

{"boards":{"daily":{"direction":"higher","period":"daily","keep":30}}}

Older periods are deleted. The current period is never deleted, all-time rows (period empty) are never touched, and season: boards are never pruned at all: season names do not sort, so there is no “older” for the platform to work out, and a season ends when you delete the board or move on to the next name.

Rows in past periods count towards your project’s one million row limit until they are pruned, so a daily board with a large player base is the case to set keep on.

Buckets

A bucket splits one board into cohorts that are ranked separately: a region, a platform, a league. Declare it on the board, and your room supplies one per submit:

In irtio.json (excerpt):

"boards": { "weekly": { "direction": "higher", "period": "weekly", "buckets": true } }

Or against the project directly:

curl -X PATCH https://irt.io/v1/projects/<id> 
  -H 'content-type: application/json' 
  -d '{"name":"my game","boards":{"weekly":{"direction":"higher","period":"weekly","buckets":true}}}'
// in your room
void ctx.room.leaderboard.submit('weekly', ctx.playerId, score, { bucket: 'eu' });
GET /v1/leaderboard/<project>/weekly/top?bucket=eu

The bucket is your string, chosen by your room. The platform never derives one: it does not look at the player’s IP, and it will not guess a region for you.

Four rules, and each is refused by name rather than guessed at:

  • A bucketed board’s submit must carry a bucket (E_LB_BUCKET_REQUIRED).
  • A bucketed board’s read must carry ?bucket= (E_LB_BUCKET_REQUIRED).
  • An unbucketed board refuses a submit or a read that carries one (E_LB_NO_BUCKETS).
  • A bucket name is 1 to 64 characters of a-z 0-9 . _ -, like a board name (E_LB_BAD_BUCKET).

A player holds one row per bucket per period, so the same player can lead eu and sit mid-table in na. A read of na cannot see a row in eu, and around for a player who is only in eu answers {"rank": null} on na.

There is no merged all-buckets read, on purpose. Merging would mean either ranking across cohorts that were never meant to be compared, or a second ranking the platform maintains and you cannot see. If you want both views, post to two boards: a bucketed one and an unbucketed one beside it. Two submits, two rankings, and no ambiguity about what either one means.

Reading

Two shapes, both public — no key, no session, no cookie. A leaderboard on a marketing page is a normal thing to want.

GET /v1/leaderboard/<project>/<board>/top?limit=10
GET /v1/leaderboard/<project>/<board>/around/<playerId>?window=5

Both take ?period= and ?bucket= as well. Without them a read gets the board’s current period and, on a board that declares no buckets, its only ranking:

GET /v1/leaderboard/<project>/weekly/top?period=2026-W35&bucket=eu&limit=10
GET /v1/leaderboard/<project>/weekly/around/<playerId>?period=2026-W35&bucket=eu

The answer says which ranking it read: a rotating board’s reply carries period, and a bucketed board’s carries bucket. An all-time, unbucketed board’s reply carries neither, exactly as before.

top is the wall of fame. around is the read most of your players actually care about: their own rank, and the handful of people just above and below them. Ranks are absolute in both, so you can print “you are 4,281st” from any slice.

A player who has never scored gets {"rank": null, "entries": []} rather than a 404. Not being on a board is an ordinary state.

Player ids are published

Every row top and around return carries its playerId, and neither read needs a credential. So a board publishes the id of every player on it, and anyone can page top to collect the set or call around/<playerId> to ask whether one particular id is ranked and where.

When you issue JWTs, ctx.playerId is <iss>:<sub>, so the sub claim appears in public rows. Use an opaque, project-scoped subject instead of an email address, platform account id, or username. Keep the mapping to your account on your server. Built-in irtio identities use project-scoped subjects.

See Auth for issuing tokens.

Reading more than one page

top answers at most 100 rows. When there are more, it hands back a nextCursor; pass it back as ?cursor= to get the next page:

GET /v1/leaderboard/<project>/<board>/top?limit=100
  -> {"entries":[…100 rows…], "nextCursor":"dGltZXN8…"}
GET /v1/leaderboard/<project>/<board>/top?limit=100&cursor=dGltZXN8…
  -> {"entries":[…the next 100…], "nextCursor":"…"}

No nextCursor means you have reached the end. Ranks stay absolute across pages, so row one of page three is 201st and says so.

The cursor is opaque. Pass it back exactly as you got it, do not build one, and do not use one from a different board. A cursor carries the board and direction it was made for, and anywhere else it is refused with E_LB_BAD_CURSOR rather than quietly returning the wrong rows.

There is no ?offset=, and there will not be one. An offset deep into a big board is a table scan, and the row it lands on moves every time anybody posts a score, so a player paging through would see some rows twice and miss others.

Listing boards

To see which boards a project actually has, with row counts:

GET /v1/projects/<id>/leaderboards

This one needs your login, because it is a view of your project rather than of one public ranking. It lists boards that have rows and boards your project configures, so a board you named in config that the game has never written to shows up with zero rows. That is almost always a name spelled two different ways.

From a terminal:

irtio leaderboard list
irtio leaderboard weekly --limit 20
irtio leaderboard weekly --all           # follow the cursors to the end
irtio leaderboard weekly --around irt:9tW2rQ...
irtio leaderboard weekly --period 2026-W35 --bucket eu

irtio leaderboard list names each board’s period, the key it is writing into right now, how many past periods it keeps, and whether it buckets.

The dashboard shows the same list at /app/leaderboards and reads one ranking at a time: a rotating board gets a picker over the periods it still keeps, and a bucketed board gets a field to type the bucket in, because only your game knows what its buckets are called.

There is no irtio leaderboard submit, for the reason at the top of this page.

Deleting

Three ways a row leaves a board, and they belong to different people.

A player deletes their own account. Everything keyed on that player goes: every board row and every player-KV row, in every project they played. See player identity.

You delete one row. This is the shape a privacy request usually arrives in: somebody asks to be taken off a ranking.

irtio leaderboard delete weekly --player irt:9tW2rQ... --yes

You delete a whole board. A board that was a mistake, a test board, a season that is over.

irtio leaderboard delete weekly --yes

Both need your login. Without --yes the command prints what would go, including how many rows a board holds, and changes nothing. There is no undo.

There is deliberately no way to delete from the MCP tool. Removing a board is destructive and irreversible, and it should be a person reading a row count before typing --yes.

Identity

A board is only as durable as the ids it ranks. With a key join, ctx.playerId is the client id, which expires with the resume token — so yesterday’s leader is a stranger today. Give your players an identity (or your own JWT) and their place on the board survives reconnects, hibernation and closing the tab.

It also survives them changing device. A player id belongs to an account rather than to a browser, so once a player has linked a second device both post to the same row. Their phone does not appear on your board as a second person.

Limits

One million rows per project across all boards, and reads are rate limited per address and per project — see the limits page. Board names are 1 to 64 characters of a-z 0-9 . _ -.

Board names, bucket names and season names all follow the same rule: 1 to 64 characters of a-z 0-9 . _ -, starting with a letter or digit. They are public URL segments and parameters, which is why the rules are tight.

Boards and ratings are different things

A board is a display: a number your game shows its players, in whatever direction and whatever units you like, read by anyone who can load your page. A skill rating is the matchmaker’s opinion: one number per player per queue, on a scale nobody chose, read by the queue and by you and by nobody else.

They come from different calls and behave differently on purpose:

room.leaderboard.submitroom.ratings.report
what it takesone player, one scorethe finishing order of a match
update rulebest score winsGlicko-2 over the pairwise results
who can read itanybody, over public HTTPyou, with your login
what it is forshowing players where they standpairing them with a fair opponent

Most games that want both should post both, from the same place in their code: the score for the player to see, the placements for the queue to use. A game that only wants one usually wants the leaderboard.