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.playerIdalways works; submitting for an id you made up is refused withE_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:
| period | key | rotates |
|---|---|---|
daily | 2026-09-03 | midnight UTC |
weekly | 2026-W36 | Monday 00:00 UTC |
monthly | 2026-09 | the 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.submit | room.ratings.report | |
|---|---|---|
| what it takes | one player, one score | the finishing order of a match |
| update rule | best score wins | Glicko-2 over the pairwise results |
| who can read it | anybody, over public HTTP | you, with your login |
| what it is for | showing players where they stand | pairing 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.