Player storage
room.kv is a small key/value store, keyed by a player identity you choose, that outlives the room
and the match. Another room in the same project reads back what this one wrote.
room.kv is available to rooms that deploy room code. A relay project with a schema and no room
file keeps per-player values with persist instead: see values that outlive the room.
Use it when a player should keep something between sessions. Everything else a room holds is room state: it lives in the room, it hibernates with the room, and it goes away when the room does.
room.kv.get(playerId, key) // Promise<string | undefined>
room.kv.set(playerId, key, value) // Promise<void>
room.kv.delete(playerId, key) // Promise<void> It is deliberately small. Values are strings, reads are by exact key, and there is no way to list, scan, or query.
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.
// 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
} 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 in this example, where 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'. See Visibility.
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 by irtio, not by convention, and each one has its own error name.
| 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 |
| Reads per project | 12,000 a minute (200 a second), with a burst of 3,000 |
| Writes and deletes per project | 6,000 a minute (100 a second), with a burst of 2,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, containing a control character, or exactly . or .. |
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, or a write for a player who has deleted their account |
E_RATE_LIMITED | The project as a whole went over its read or its write rate. Reads and writes are counted separately |
E_KV_UNAVAILABLE | The store could not be reached. 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.
The rate limits count every room of the project together, not each room on its own. Saving on every
change is fine for a small game, but hundreds of concurrent players each saving every few seconds
add up. Keep the state in the room and write it back on a timer, at match end, or in onLeave,
rather than on every change. After an E_RATE_LIMITED, wait before retrying: the bucket refills
over a minute.
Usage notes
- 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. See Leaderboards. - 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. - 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.
Choose a player identity
Use ctx.playerId as the storage key. Choose an identity before saving persistent progress:
| Join option | Player ID | Lifetime |
|---|---|---|
| Project key only | Client ID | Current session and its resume window |
identity: true | Per-project account ID | Returning browser sessions and linked devices |
token | <iss>:<sub> | Your verified account’s lifetime |
See player identity for built-in accounts, or authentication for your own login system. Changing identity methods changes the storage key; existing rows remain under their original keys.
Use leaderboards for rankings and saves for room state.
Related
- Saves and restore for state that belongs to the room
- Durable alarms for work that has to happen while nobody is connected