One world, several rooms
Split a world into rooms for zones such as a town, dungeon, and arena. Each room has its own state and client cap. Save the player’s inventory before leaving, then join the next zone with the same identity. Rooms can share a server.
The excerpts below are from a world with two zones. They are not a complete game; see player storage for the full storage pattern and messages between rooms for the room-to-room half.
The handoff
Store carry-over values under ctx.playerId in project-wide room.kv. Wait for the write to
resolve before telling the client to leave.
// irtio/rooms/town.ts (server)
enterDungeon(state, _params, ctx) {
const me = state.players.get(ctx.clientId);
if (!me) return;
const carry = JSON.stringify({ inventory: me.inventory, health: me.health });
void ctx.room.kv.set(ctx.playerId, 'carry', carry).then(() => {
// Durable now. Only after this does the client learn where to go.
return ctx.room.call(ctx.clientId).travel({ room: state.world.dungeonRoom });
}).catch((error) => ctx.room.log('travel failed', String(error)));
} If the client leaves before the write completes, the next room can read stale or missing inventory.
On the client, leave and rejoin. room.leave() closes the socket for good, and joinRoom with an
explicit room option opens the next one.
// main.ts (client)
const travelHandlers = {
async travel({ room: target }: { room: string }) {
await room.leave();
room = await joinRoom(schema, { room: target, identity, rpc: travelHandlers });
startRendering(room);
},
};
let room = await joinRoom(schema, { identity, rpc: travelHandlers }); The target room reads the carry-over as the player arrives, in onJoin or in the first RPC the
client makes. The read is asynchronous like every room.kv call, so write the result into state
from the continuation and give the client a flag that says the load has landed.
// irtio/rooms/dungeon.ts (server)
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(ctx.clientId, { health: 0, inventory: '', loaded: false });
void ctx.room.kv.get(ctx.playerId, 'carry').then((raw) => {
const me = state.players.get(ctx.clientId);
if (!me) return;
const carry = raw ? (JSON.parse(raw) as { inventory: string; health: number }) : undefined;
me.inventory = carry?.inventory ?? '';
me.health = carry?.health ?? 100;
me.loaded = true;
}).catch((error) => ctx.room.log('travel failed', String(error)));
} Room codes
room.id on the server is the room’s code: the same string room.link() puts in ?room= on a
share link, and the same value a client passes as the room option to joinRoom. So a room that
wants to send players somewhere passes a code, and anywhere you can read one you can travel to it.
One identity everywhere
ctx.playerId is what keys the storage, and it is stable across rooms only when the player joins
every room under the same identity. Pass identity: true on every join, or build one Identity and reuse that instance for every join in the session.
// main.ts (client)
import { Identity, joinRoom } from '@irtio/client';
const identity = new Identity({ project: 'p_your_project_id' });
const town = await joinRoom(schema, { identity });
const dungeon = await joinRoom(schema, { room: code, identity }); A join that forgets it is an anonymous join, and an anonymous join gets a different ctx.playerId.
The player arrives in the dungeon as a stranger with an empty key, and the inventory they left in
town is still sitting under the old id. See Identity.
A zone registry
Codes have to come from somewhere, and rooms need to agree on them. room.kv ids do not have to be
real players, so a synthetic id works as a small registry that outlives every room in the world.
// irtio/rooms/town.ts (server)
// A room that owns a zone publishes its own code.
void room.kv.set('world', 'zone:dungeon', room.id);
// Any room reads it back.
void room.kv.get('world', 'zone:dungeon').then((code) => {
if (code) state.world.dungeonRoom = code;
}); Keep the writer to one room per zone so two rooms do not claim the same zone. A code read back for a room that has since gone away is a dead code: joining it makes a fresh room with that id rather than failing, which for a zone is usually what you want.
Talking between zones
Rooms still need to tell each other things a player is not carrying: a gate opened, a boss died, a
raid is forming. That is room.bus. Address one room by its code with send, which is durable and
wakes a sleeping target, and declare the handlers in config so they exist when the room wakes.
// irtio/rooms/dungeon.ts (server)
await room.bus.send(townCode, JSON.stringify({ kind: 'boss-down', by: ctx.playerId })); // irtio/rooms/town.ts (server)
export default defineRoom(townSchema, {
bus: {
channels: {
'world.gate'(state, event) {
state.world.gateOpen = true;
},
},
onMessage(state, message) {
const news = JSON.parse(message.payload) as { kind: string; by: string };
state.news.add(news.kind, { by: news.by });
},
},
}); Handlers can run twice for one send, so key what they write rather than appending to it.
See also
- Messages between rooms for the two bus verbs, delivery limits and errors
- Identity for what
ctx.playerIdis and how long it lasts