Player storage
Everything else a room keeps is room state. It lives in the room, it hibernates with the room, and
it goes away when the room does. room.kv is the exception: a small key/value store, keyed by a
player identity you choose, that outlives the room, the match, and the machine the room was
running on. Another room in the same project reads back what this one wrote.
It is deliberately small. Values are strings, reads are by exact key, and there is no way to list, scan, or query. Think of it as a shelf you put labelled boxes on, not a database.
room.kv.get(playerId, key) // Promise<string | undefined>
room.kv.set(playerId, key, value) // Promise<void>
room.kv.delete(playerId, key) // Promise<void> The value arrives after the handler returns
All three methods return promises, and room handlers are synchronous. The continuation runs as its own event between ticks, after the handler that started it has already returned. So a value you ask for is not available in the handler that asked for it. There is no blocking read and no synchronous accessor, because either one would be a lie.
// irtio/room.ts (wrong: this will look like the store is empty)
onJoin(state, ctx) {
let profile: string | undefined;
void ctx.room.kv.get(ctx.playerId, 'profile').then((v) => { profile = v; });
ctx.room.log(profile); // always undefined
} The right shape is to write the result into state from inside the continuation. State you mutate there is tracked and flushed exactly like state you mutate in a handler, so it reaches clients on the next delta.
// irtio/room.ts (right)
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(ctx.clientId, { /* … */ lifetimeWins: 0, profileLoaded: false });
void ctx.room.kv.get(ctx.playerId, 'profile').then((json) => {
const me = state.players.get(ctx.clientId);
if (!me) return; // they left while the read was in flight
me.lifetimeWins = json ? (JSON.parse(json) as { wins: number }).wins : 0;
me.profileLoaded = true;
});
} The profileLoaded flag is not decoration. Between the join and the continuation the field holds
its default, so without it the client cannot tell “no wins yet” from “not loaded yet” and will
show a zero that turns into a real number a moment later.
A worked example
A room that carries each player’s lifetime win count across sessions. The room reads the profile on join, publishes it in state, and writes it back when a match ends.
The schema
// irtio/schema.ts
import { defineSchema, entity, singleton, bool, enumOf, str, u16, u32 } from '@irtio/schema';
import { rpc } from './rpc.js';
export const schema = defineSchema(
{
players: entity(
{
// The room has to keep this itself. `room.clients` carries clientId, role, name and
// connected, and does not carry playerId, so a room that wants to write to a player
// other than the one whose RPC it is handling needs the id on the record.
playerId: str(128),
name: str(24),
score: u16,
lifetimeWins: u32,
profileLoaded: bool,
},
{ serverOwned: true },
),
match: singleton({ phase: enumOf('lobby', 'play', 'over') }, { serverOwned: true }),
},
{
project: 'p_your_project_id',
roles: ['player'] as const,
rpc,
},
); playerId is visible to every client here, which is fine today because it is the client id every
client can already see. If your game later keys storage by something you would rather not publish,
move that field into its own collection with visibility: 'role'.
The RPCs
// irtio/rpc.ts
import { server, u16 } from '@irtio/schema';
export const rpc = {
start: server({}),
score: server({ params: { points: u16 } }),
finish: server({}),
}; The room file
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
/** One key per player. irtio never parses the value, so the shape is yours to keep stable. */
const PROFILE_KEY = 'profile';
interface Profile {
wins: number;
}
/** A row you cannot parse is a row an older version of your game wrote. Do not throw over it. */
function readProfile(json: string | undefined): Profile {
if (!json) return { wins: 0 };
try {
const parsed = JSON.parse(json) as Partial<Profile>;
return { wins: typeof parsed.wins === 'number' ? parsed.wins : 0 };
} catch {
return { wins: 0 };
}
}
export default defineRoom(schema, {
mode: 'event',
onCreate(state) {
state.match.phase = 'lobby';
},
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(ctx.clientId, {
playerId: ctx.playerId,
name: ctx.name || 'anon',
score: 0,
lifetimeWins: 0,
profileLoaded: false,
});
void ctx.room.kv
.get(ctx.playerId, PROFILE_KEY)
.then((json) => {
const me = state.players.get(ctx.clientId);
if (!me) return;
me.lifetimeWins = readProfile(json).wins;
me.profileLoaded = true;
})
.catch((err: unknown) => {
// The room keeps running. A profile that failed to load reads as zero wins,
// and `profileLoaded` stays false so the client can say so.
ctx.room.log('profile read failed', err);
});
},
onLeave(state, ctx) {
if (state.players.has(ctx.clientId)) state.players.remove(ctx.clientId);
},
rpc: {
start(state) {
if (state.match.phase !== 'lobby') return;
state.match.phase = 'play';
},
score(state, { points }, ctx) {
if (state.match.phase !== 'play') return;
const me = state.players.get(ctx.clientId);
if (me) me.score += points;
},
finish(state, _params, ctx) {
if (state.match.phase !== 'play') return;
state.match.phase = 'over';
let bestId: string | undefined;
let bestScore = -1;
for (const [id, player] of state.players) {
if (player.score > bestScore) {
bestId = id;
bestScore = player.score;
}
}
if (bestId === undefined) return;
const winner = state.players.get(bestId)!;
const wins = winner.lifetimeWins + 1;
winner.lifetimeWins = wins;
// Read, modify, write. There are no counters and no atomic operations: the last write
// for a key wins, so hold the current value in state and write the whole value back.
void ctx.room.kv
.set(winner.playerId, PROFILE_KEY, JSON.stringify({ wins } satisfies Profile))
.catch((err: unknown) => ctx.room.log('profile write failed', err));
},
},
}); The client
// src/main.ts
import { joinRoom } from '@irtio/client';
import { schema } from '../irtio/schema.js';
const room = await joinRoom(schema, { name: 'you' });
const banner = document.querySelector('#profile') as HTMLElement;
document.querySelector('#start')!.addEventListener('click', () => {
room.call.start().catch((err: unknown) => console.error('start failed:', err));
});
document.querySelector('#finish')!.addEventListener('click', () => {
room.call.finish().catch((err: unknown) => console.error('finish failed:', err));
});
function frame() {
const me = room.state.players[room.me];
banner.textContent = !me
? 'joining'
: !me.profileLoaded
? 'loading your profile'
: `${me.lifetimeWins} lifetime wins, ${me.score} this match`;
requestAnimationFrame(frame);
}
frame(); room.state.players[room.me] is undefined for a frame or two after the page loads, because the
record is created by onJoin on the server. Guard it, as above.
Limits
Every limit is enforced at the gateway, not by convention, and each one has its own error name so you can tell a limit you can design around from an outage you cannot.
| Limit | Value |
|---|---|
| Value size | 16 KiB of UTF-8 |
| Key size | 256 bytes of UTF-8 |
| Player id size | 256 bytes of UTF-8 |
| Keys per player, per project | 128 |
| Rows per project | 1,000,000 |
Values are strings. JSON-encode structured data yourself. irtio never parses the value and has no opinion about what is in it.
| Error | Means |
|---|---|
E_KV_VALUE_TOO_LARGE | The value is over 16 KiB |
E_KV_TOO_MANY_KEYS | This player already holds 128 keys in this project |
E_KV_BAD_KEY | Empty, oversized, or containing a control character |
E_KV_BAD_PLAYER | Same, for the player id |
E_KV_PROJECT_FULL | The project is at its row limit |
E_KV_FORBIDDEN | A read outside this project’s confinement |
E_KV_UNAVAILABLE | The store could not be reached. The only one that is not your bug. |
A rejected call rejects its promise and the room keeps running. Attach a .catch() and decide
what the game does without the value.
What it is not
- It is not queryable. No listing, no prefix scans, no secondary indexes, no sorting. The only
access path is an exact
(playerId, key)lookup. You cannot build a leaderboard by asking the store for the top scores, because there is no route that answers that question. - Confinement is per project, not per player. A room may pass any
playerId, and reading another player’s row on purpose is allowed. That is what lets the example above write to the winner rather than only to the caller. A read across projects is refused by the gateway. - There are no transactions or atomic operations. Read, modify, write, and the last write for a key wins. Two rooms writing the same key at the same time will lose one of the writes.
ctx.playerId, and how long it lasts
ctx.playerId is the identity room.kv is meant to be keyed by, and which one you get depends on
how the client joined.
- Key joins (the ordinary
joinRoom({ key })path, and the default):ctx.playerIdis the client id, which a resume token carries across a reconnect. It is stable exactly as long as that token is —reconnectGraceMs, 30 seconds by default — and no longer. A player who comes back after their token has expired arrives as a newplayerId, with none of their old rows. - JWT joins (
tokeninjoinRoom, see reference/client):ctx.playerIdis"<iss>:<sub>", the verified token’s subject namespaced by the issuer that minted it. It is durable — the same person gets the sameplayerIdacross devices and across time — which is what makes it the identity to build a real player record on.
That is enough for “keep this player’s progress across a disconnect or a page reload” on a key join. It is not enough for “this is the same person next week” until the room adopts JWT. Say what your game actually offers, and do not build a feature that promises durable accounts on top of a key join.
If your game hasn’t adopted JWT yet
Three options, in the order you should want them:
- Adopt JWT. Your own server mints the token,
ctx.playerIdbecomes"<iss>:<sub>", and the identity is as durable as your accounts are. It is the only one of the three where irtio actually verifies that the player is who they claim to be. - Pass your own stable id.
room.kv’splayerIdargument is just a string, and irtio never inspects it —ctx.playerIdis only the default value you have in hand, not a requirement. Send whatever identity your game already has (a launcher id, a per-device id you generated once and kept inlocalStorage) and pass that instead ofctx.playerIdtoroom.kv.get/set/delete. This survives reloads and works today, but it is client-asserted: nothing verifies it, so anyone can claim anyone’s id, and you should key nothing on it you would mind a stranger overwriting. - Key on the room instead of the player. A per-room record under one fixed
playerId— the room id, say — is durable and un-spoofable, and covers “this table’s running score” even though it cannot follow a player into the next room.
Adopting JWT later starts a player’s storage over. Rows written under a key join are keyed by
the client id; once the project starts verifying tokens, ctx.playerId becomes "<iss>:<sub>", an
entirely different key space. There is no automatic migration, and because room.kv has no
listing or scan capability, there is no way to write one either — old rows stay keyed by the dead
client id forever. Decide your identity before you store anything you would miss.
Reach for this when
- A player should keep something between sessions: unlocks, a cosmetic choice, a settings blob, a tutorial-completed flag, a running total.
- You know the exact key you want to read, at the moment you want to read it.
- The value is small and you are happy to encode it yourself.
Do not reach for this when
- You need to rank, list, or search across players. There is no query path, and there will not be one you can fake with 128 keys.
- You need the value inside the handler that asked for it. Restructure around the continuation instead.
- You need the state of the room itself to survive. That is hibernation, plus saves.
- You are storing something large. 16 KiB is the ceiling, and a big blob per player is a sign the data belongs in your own backend.
Next steps
- Saves and restore for state that belongs to the room
- Durable alarms for work that has to happen while nobody is connected
- Hibernation and the room file API