Rooms
Player data, collections and leaderboards
Keep a player's progress across rooms, store what players make, and rank players, from room code or the client, with no backend of your own.
A room’s state lasts as long as the room. Some data outlives every room: a player’s progress, the levels players build, a high score table. IRT keeps it for you in the data service, the same from room code, from a client and from the server API:
- Player data: keys per player per project, each a JSON or bytes value of at most 64 KB with a version that goes up on every write. A player has at most 256 keys and 1 MB.
- Collections: named sets of entries you declare in
irt.config.ts. An entry has anid, a value, flatmetayou filter on, an optionalscoreyou sort on, an optionalowner, aversionand itscreatedandupdatedtimes. - Leaderboards: collections with leaderboard settings, holding one
score per player per period, written by
submit.
Data for one player in one room needs none of this: keep it in the room’s private state, which is room state.
Declaring it
// irt.config.ts
import { defineConfig } from "@irt/cli/config";
export default defineConfig({
project: "my-game",
room: "src/room.ts",
settings: {
data: {
player: {
clientWrite: ["settings"],
},
collections: {
levels: { clientRead: true, clientWrite: "own", maxOwn: 50 },
highscores: {
clientRead: true,
leaderboard: { order: "desc", keep: "best", periods: ["all", "week"] },
},
},
},
},
});
| Setting | Default | Meaning |
|---|---|---|
data.player.clientWrite |
none | The player data keys a client may write for its own player. A client always reads its own player’s data. |
collections.<name> |
Names are 1 to 32 of a-z 0-9 _ -, starting with a letter; at most 32 collections |
|
clientRead |
false |
Any client may get and list the entries, or read the board |
clientWrite |
none | "own": a client may create entries and change and delete the ones it owns, or submit its own score |
maxOwn |
100 |
The most entries one player creates from clients, 1 to 10,000 |
leaderboard.order |
"desc" |
"desc" or "asc"; it also decides what the best score is |
leaderboard.keep |
"best" |
"best", "latest" or "sum" |
leaderboard.periods |
["all"] |
Any of "all", "day", "week" and "month", in UTC; weeks are ISO weeks from Monday |
Room code may do anything with any declared collection; the client
settings only limit clients. A board’s order and keep can’t change
once a live build declared it: declare a board under a new name for a
new rule. A collection you remove keeps its entries, unreachable, until
you declare it again.
From room code
// src/room.ts
import { DataError, defineRoom } from "@irt/room";
interface Save {
level: number;
coins: number;
}
export default defineRoom({
async onJoin(room, conn) {
// Load once, then keep it in memory for the rest of the visit.
const save = await room.playerData(conn.player).get<Save>("save");
conn.data.save = save?.value ?? { level: 1, coins: 0 };
conn.data.version = save?.version ?? 0;
},
async onMessage(room, conn, message) {
const m = message as { type?: string; score?: unknown };
if (m.type === "level-done" && typeof m.score === "number") {
const save: Save = { ...conn.data.save, level: conn.data.save.level + 1 };
try {
const { version } = await room.playerData(conn.player).set("save", save, { version: conn.data.version });
conn.data.save = save;
conn.data.version = version;
} catch (e) {
if (!(e instanceof DataError)) throw e;
room.send(conn, { error: e.code });
}
await room.leaderboard("highscores").submit(conn.player, m.score, { meta: { level: save.level } });
}
},
});
room.playerData(player) has get, set, increment, delete,
list and refresh:
get(key)resolves with{ value, version }, orundefinedwhen the key has none. A value is any JSON or aUint8Array.set(key, value, { version?, once? })resolves with{ version }. Withoutversionthe write is unconditional. With it, the write fails withE_VERSIONunless the key has that version;version: 0means the key must have none.increment(key, by, { once? })adds to a number in one step, creating it at 0, and resolves with the new number. On a value that isn’t a number it fails withE_TYPE.delete(key, { version?, once? })resolves with whether the key existed.list()gives every key with its value and version.
room.collection(name) has get(id), put({ id?, value, meta?, score?, owner?, version?, once? }), which resolves with { id, version } and
makes up an ID when you give none, list({ where?, owner?, order?, limit?, after? }) and delete(id, { version?, once? }). once makes
a write safe to repeat; see
Writes that may or may not have happened. put replaces the whole entry:
meta, score and owner left out become empty.
// levels.ts
import type { Connection, Room } from "@irt/room";
export async function publish(room: Room, conn: Connection, tiles: number[]) {
const levels = room.collection("levels");
const { id } = await levels.put({ value: { tiles }, meta: { theme: "ice", size: tiles.length }, owner: conn.player });
const page = await levels.list({ where: { theme: "ice" }, order: "-created", limit: 20 });
const mine = await levels.list({ owner: conn.player });
return { id, newest: page.entries, count: mine.entries.length, more: page.next !== null };
}
A list gives at most 100 entries a page, 20 by default, each without
its value, so a page stays small; get fetches one value. Pass
next as after for the next page; it’s null on the last. where
matches meta keys by equality, all of them; order is score,
-score, created, -created (the default), updated or -updated.
room.leaderboard(name) has submit(player, score, { meta?, once? }), which
applies the board’s keep rule to the player’s entry of every period at
once and resolves with what each keeps, top({ period?, limit?, after? }) and around(player, { period?, count? }), the player’s rank and up
to count entries either side, or undefined when they have no entry.
A period is "all" (the default), "day", "week" or "month" for
the current one, or a full name such as "week:2026-W45" for one of the
three before it, which are kept; older ones are deleted. Ranks go by
score, then by who reached it first.
The cache in the room
Each room keeps what it read and wrote of player data and entries for 5
seconds: a get within 5 seconds of the room’s last read or write of
the same key or entry is answered from memory, with no call. So:
- A room always sees its own writes. After its own
put, the nextgetof that entry is a call, since the data service sets the entry’s times; the rest is cached as above. - Another writer’s change, from another room, a client or your backend,
shows within 5 seconds. Call
refresh()on the player’s data or the collection to drop what the room holds and read it again. list,topandaroundare never cached; each is a call.
The cache is what keeps a get in onTick from reaching the limits,
but a hit doesn’t extend it: a get every tick still makes a call
every 5 seconds. Load what a player
needs in onJoin, keep it in conn.data or private state, and write it
back when it changes, rather than calling get every tick for data the
room is the only writer of. Don’t call list or top per tick; call
them on an event, or every few seconds.
From a client
Clients use the same calls for the player they are, over the router’s HTTP API, with or without a room connection:
// client-data.ts
import { connect, dataClient } from "@irt/client";
const router = "wss://eu.irt.io";
const data = dataClient({ router, project: "my-game" });
await data.playerData.set("settings", { volume: 0.5 });
const best = await data.leaderboard("highscores").top({ period: "week", limit: 10 });
const me = await data.leaderboard("highscores").around({ count: 2 });
const room = await connect({ router, project: "my-game", room: "arena" });
const levels = await room.collection("levels").list({ order: "-score" });
console.log(best.entries, me?.rank, levels.entries);
client.playerData, client.collection(name) and
client.leaderboard(name) on a connected RoomClient are the same as
dataClient’s. A guest with no token gets one from POST /v1/guest on
its first call, kept where connect keeps its guest token, so a guest
is one player across rooms and data calls. A player token from your
backend works too: pass token. A client may:
- read its own player’s data, and write only the keys in
data.player.clientWrite; - get and list entries and read a board with
clientRead; - with
clientWrite: "own", create entries, change and delete its own, and submit its own score.owneris always set to the client’s player, whatever it sends, andmaxOwncaps how many it creates.
A score a client submits can be faked: any player can send any
number. Submit from room code, which decides the score, for any board
that matters. Native rooms, which run no room code, need
clientWrite: "own".
Writes that may or may not have happened
Every call has a 5-second deadline. When the data service can’t be
reached in time, room code and clients get E_UNAVAILABLE; nothing is
queued. A write that timed out may or may not have been made. A room can
also wake after a host failure without the last 30 seconds of its room
state, while the data writes it made in them are kept. Either way, room
code may make a write again that already happened.
From room code, give each write a once key built from what happened,
and repeating it is safe. A write whose key the project used in the
last day doesn’t run again: it resolves as the first one did, without
counting as a write. The same key with different arguments fails with
E_INVALID. Every write takes it: set, increment and delete on
player data, put and delete on a collection, and submit on a
leaderboard. Keys are 1 to 64 characters of letters, digits, _, .,
: and -.
// reward.ts
import { DataError, type Room } from "@irt/room";
// The match's ID was in room state since the match began, so a room that wakes after a host
// failure still knows it, and rewarding the match again adds nothing.
export async function reward(room: Room, matchId: string, winners: string[]): Promise<void> {
for (const player of winners) {
for (let attempt = 0; ; attempt++) {
try {
await room.playerData(player).increment("coins", 50, { once: `reward:${matchId}:${player}` });
break;
} catch (e) {
if (e instanceof DataError && e.code === "E_UNAVAILABLE" && attempt < 3) continue;
throw e;
}
}
}
}
To pair a write with room state, keep what you’re about to do in room
state first, such as the matches still to reward, then write with a
once key derived from it, then clear it. In onWake, finish whatever
is still there. The key has to come from something in room state from
before the write, not from a counter the lost seconds could reset, or
the repeat gets a new key and runs again. A repeated increment
resolves with the number the first one made, which may have changed
since; get reads it now.
Clients can’t use once, so a player can’t take a room’s key first.
Retry a client’s write with version, which fails with E_VERSION if
it was made:
// retry.ts
import { DataError, type PlayerData } from "@irt/room";
export async function saveOnce(data: PlayerData, value: unknown, version: number): Promise<number> {
for (let attempt = 0; ; attempt++) {
try {
return (await data.set("save", value, { version })).version;
} catch (e) {
if (!(e instanceof DataError)) throw e;
if (e.code === "E_UNAVAILABLE" && attempt < 3) continue;
if (e.code === "E_VERSION") {
const now = await data.get("save");
// Written by the try that timed out, or by another writer: compare before writing again.
if (now && JSON.stringify(now.value) === JSON.stringify(value)) return now.version;
}
throw e;
}
}
}
Who wrote an entry
Use owner for authorship, not meta. A client’s writes always carry
its own player as owner, set by the data service, while meta is
whatever the writer sent, so a meta.author a client set proves
nothing. list({ owner }) finds a player’s entries, and deleting a
player through the server API deletes every entry they own.
Errors
Calls reject with DataError, whose code is one of:
| Code | When |
|---|---|
E_INVALID |
A malformed call, or one over a size limit |
E_FORBIDDEN |
The caller may not do it, such as a client writing a key not in clientWrite |
E_UNKNOWN |
The collection isn’t declared in the live build, or it’s the wrong kind for the call |
E_VERSION |
version didn’t match |
E_TYPE |
increment on a value that isn’t a number |
E_QUOTA |
Over a player’s 256 keys or 1 MB, a collection’s 1,000,000 entries, or maxOwn |
E_LIMIT |
Over a rate limit |
E_SPENDING |
The project is over its spending limit, or out of credit |
E_UNAVAILABLE |
The data service couldn’t be reached; a write may or may not have happened |
Limits
| What | Limit |
|---|---|
| A value | 64 KB of JSON or bytes |
| A player | 256 keys, 1 MB |
meta |
1 KB, flat strings, numbers and booleans |
| A collection | 1,000,000 entries |
| A project | 32 collections, 2,000 calls a second |
| A room | 200 calls a second, 64 at once |
| A client address, and a player | 10 calls a second |
| Guests minted per client address | 10 a minute |
| A request | 128 KB |
Guests’ player data is deleted 90 days after it was last written; a guest whose token is lost can’t get back to it. Player data of your own player IDs is kept until your backend deletes the player. Calls are metered and free within the limits; see Limits.
Locally and in tests
irt dev and self-hosted room hosts run the same
calls on SQLite, in data.sqlite in the data directory, .irt/data for
irt dev, and serve POST /v1/data/{project} and POST /v1/guest as
the router does. @irt/testing keeps it in memory and lets a test seed
and read it:
// data.test.ts
import { createTestHost } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./src/room.js";
test("a finished level is saved and scored", async () => {
const host = createTestHost(room, {
settings: { data: { collections: { highscores: { leaderboard: { periods: ["all", "week"] } } } } },
});
await host.playerData("ada").set("save", { level: 3, coins: 0 });
const ada = await host.room("arena").connect({ player: "ada" });
ada.send({ type: "level-done", score: 1200 });
await host.tick();
expect(await host.playerData("ada").get("save")).toMatchObject({ value: { level: 4 } });
const top = await host.leaderboard("highscores").top({ period: "week" });
expect(top.entries[0]).toMatchObject({ player: "ada", score: 1200 });
});