Saves and restore
A room already writes one snapshot: the hibernation blob, overwritten every time the room sleeps
and read back every time it wakes. room.save() writes a second kind. It is a numbered
generation that sits beside the live snapshot and stays there, addressable by id, until retention
ages it out.
Saving does not interrupt the room. It copies the same bytes hibernation writes while the room
keeps running, so a save is a checkpoint rather than a departure. A failed save changes nothing at
all: save() never touches the room’s live key, so a room that tried to save and could not is in
the same shape as a room that never called it.
Restoring is the other half, and it happens from the CLI rather than from room code. See /docs/concepts/hibernation for what the live snapshot is and when it gets written.
The value arrives after the handler returns
room.save() returns a promise, and this is the part that catches people out.
Room handlers are synchronous. There is no way to block one on I/O, and irtio does not pretend
otherwise with a fake synchronous accessor. So every promise-returning room API (room.call, room.save, and all of room.kv) resolves in a continuation, which runs as its own event
between ticks, after the handler that started it has already returned.
The consequence is short and worth reading twice: the value you asked for is not available in the handler that asked for it.
// irtio/room.ts
rpc: {
checkpoint(state, _params, ctx) {
let id = '';
void ctx.room.save().then((saveId) => { id = saveId; });
ctx.room.log(id); // always '': the continuation has not run yet
},
} Write the handler as “ask, then carry on”, and do the rest inside .then(). State you mutate in
the continuation is tracked and flushed exactly like state you mutate in a handler, so writing the
save id into the room’s state from the continuation works and reaches every client on the next
delta.
A worked example
A shared building room. Anyone can place a block, and the host can take a checkpoint. The room publishes whether a save is in flight and what the last save id was, so the page can show it.
The schema
// irtio/schema.ts
import { defineSchema, entity, singleton, bool, str, u8, u16, u32, f32 } from '@irtio/schema';
import { rpc } from './rpc.js';
export const schema = defineSchema(
{
blocks: entity({ x: f32, y: f32, kind: u8 }),
// Server-owned, so no client can write it and the ids stay honest.
// A save id is 22 characters, so str(24) has room.
workshop: singleton(
{ saving: bool, lastSave: str(24), saveCount: u16, nextBlock: u32 },
{ serverOwned: true },
),
},
{
project: 'p_your_project_id',
roles: ['builder', 'host'] as const,
rpc,
},
); The RPCs
// irtio/rpc.ts
import { server, u8, f32 } from '@irtio/schema';
export const rpc = {
place: server({ params: { x: f32, y: f32, kind: u8 } }),
checkpoint: server({}),
}; The room file
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
export default defineRoom(schema, {
mode: 'event',
onCreate(state) {
state.workshop.saving = false;
state.workshop.lastSave = '';
state.workshop.saveCount = 0;
state.workshop.nextBlock = 0;
},
rpc: {
place(state, { x, y, kind }, ctx) {
const id = `b${state.workshop.nextBlock}`;
state.workshop.nextBlock += 1;
state.blocks.add(id, { x, y, kind }, { owner: ctx.clientId });
},
checkpoint(state, _params, ctx) {
if (ctx.role !== 'host') throw new Error('checkpoint: host only');
if (state.workshop.saving) return; // one at a time is plenty
state.workshop.saving = true;
// Ask, and carry on. Everything that depends on the id lives in the continuation.
void ctx.room
.save()
.then((saveId) => {
state.workshop.saving = false;
state.workshop.lastSave = saveId;
state.workshop.saveCount += 1;
ctx.room.log(`checkpoint ${saveId}`);
})
.catch((err: unknown) => {
// Nothing was written and nothing was lost. Tell the room and move on.
state.workshop.saving = false;
ctx.room.log('checkpoint failed', err);
});
},
},
}); Throwing inside checkpoint rejects the caller’s promise with your message and leaves the room
running, which is how the host-only check above reaches the client as an error.
The client
// src/main.ts
import { joinRoom } from '@irtio/client';
import { schema } from '../irtio/schema.js';
const room = await joinRoom(schema, { role: 'host', name: 'you' });
const status = document.querySelector('#status') as HTMLElement;
document.querySelector('#checkpoint')!.addEventListener('click', () => {
room.call.checkpoint().catch((err: unknown) => console.error('checkpoint failed:', err));
});
document.querySelector('#canvas')!.addEventListener('click', (ev) => {
const e = ev as MouseEvent;
room.call.place({ x: e.offsetX, y: e.offsetY, kind: 0 }).catch(() => {});
});
function frame() {
for (const [, block] of room.render.blocks) drawBlock(block);
const w = room.state.workshop;
status.textContent = w.saving
? 'saving'
: w.lastSave
? `${w.saveCount} saves, latest ${w.lastSave}`
: 'no saves yet';
requestAnimationFrame(frame);
}
frame(); Listing and restoring from the CLI
Saves are listed and applied with irtio rooms. Run it from a directory with an irtio.json, or
pass the project id yourself. You need to be logged in (irtio login).
irtio rooms # every room in the project
irtio rooms saves ABCD # that room's generations, newest first
irtio rooms restore ABCD --save 00001756209600000-a3f1 irtio rooms saves prints one row per generation:
SAVE ID AGE SIZE DEPLOY
00001756209600000-a3f1 3m ago 4.2 KB v3
00001756209000000-77b2 13m ago 4.1 KB v3
00001756121400000-0d9e 1d ago 3.8 KB v2 The DEPLOY column is the deployment version the generation was written under. A room that slept
across a deploy has generations under two versions, and both are listed.
The pre-migration generation
One generation shows up on its own, and it will not show up in the listing above. When a deploy
run with --strategy migrate moves a live room onto a new schema version, the room’s state from
just before the migration is written first, as its own retained generation — under the reserved id premigrate, not a minted save id. Because premigrate does not parse as a save id, it never
appears in irtio rooms saves, and it is exempt from the newest-10 pruning that ordinary
generations get. The only way back to it is irtio rollback, not irtio rooms restore. See /docs/deploy/deploying.
| Flag | Applies to | Meaning |
|---|---|---|
--save <id> | restore | The generation to restore. Required, and refused with E_NOT_FOUND if that id is not there. |
--project <id> | all | The project id. Defaults to project in irtio.json. |
--url <url> | all | Control plane URL. You will rarely set this. |
A save id is a fixed-width timestamp plus a short random suffix, which is why sorting the ids sorts them by age.
What restore actually does
Read this section before you run restore on anything you care about.
- It discards the room’s current state. That is the whole point of the command, not a side effect. Whatever the room holds right now is gone, replaced by the generation you named.
- If the room is live, the project’s tenant is stopped to apply it. Stopping is per project
and
restoreis per room, so every other room in the same project stops too. They come straight back from their own snapshots on the next join, unchanged. It is the same disruption a redeploy causes. See /docs/deploy/deploying. - If the room is not running, nothing happens yet. The restore is recorded and applied at the room’s next placement, which the next join triggers. The CLI tells you which of the two cases you got.
Limits
| Limit | Value |
|---|---|
| Generations retained | 10 per room, per deployment version |
| Where a save lives | Object storage, one level under the room’s live snapshot key |
| Lifetime | Deleted with the room. A save is not a backup of a room you deleted. |
| Contents | The room’s whole state, including the physics world when the room runs physics |
Retention is scoped per deployment version, so a deploy temporarily doubles the generations a room has stored until the old version’s prefix ages out.
There is no way to download a save, diff two of them, or restore one room’s save into a different room. A save carries no schema of its own, so it is readable by the deployment that wrote it and by later ones through the same migration chain a wake goes through.
A physics room’s saves are much larger than a non-physics room’s, because the Rapier world travels inside the same blob. Size your expectations from your own rooms rather than from a small example.
Reach for this when
- You want a checkpoint a human can go back to: an end-of-round state, a level a group built, a board before a rules change.
- You are about to deploy something risky and want a known-good generation to fall back to.
- Your game has a natural “save point” and you want the player-facing version of that idea.
Do not reach for this when
- You want per-player progress that outlives the room. That is player storage, which lives somewhere else entirely and survives the room being deleted.
- You want crash safety. Hibernation already covers that, and it runs without you calling anything.
- You want to save every tick. A save is a full copy of the room. Save on events that mean something, not on a timer.
- You want an undo stack inside the game. Model that in your own state. Restore stops the tenant and is not a per-action operation.
Next steps
- Player storage for state that outlives the room
- Durable alarms for timers that outlive hibernation
- Room file API and CLI