Rooms

How rooms hibernate and wake

When a room hibernates, what is saved, what happens when a player connects to a hibernated room, and what it costs.

Rooms on IRT don’t need to run when nobody is in them. An empty room hibernates: its room state goes to object storage and it leaves its room host. When a player connects again, the room wakes with its state as it was. Your code doesn’t save or load anything for this to work.

When a room hibernates

  • 30 seconds after its last connection closes, unless its alarm is due within 2 minutes; then it stays active until the alarm has run.
  • When the room asks to, with room.requestHibernate(). Its open connections run onLeave and close with 4011, and their clients don’t reconnect by themselves; the game can offer to rejoin.
  • When its room host drains, for example to move rooms to a new build. A room whose definition has sleep goes to sleep instead, so its players stay connected.
  • When it goes to sleep, with its players still connected; see Sleeping rooms.

Hibernating runs the room’s onHibernate hook, serializes the room and writes its room state to object storage. A hibernated room costs no room-hours. A room can stay hibernated indefinitely; only deleting it removes its room state.

What is saved

Everything the SDK tracks is room state:

  • the room’s entities, room.state and each player’s private state,
  • the room’s tick,
  • its alarm, from room.setAlarm,
  • the state of a stepper that declares save and load.

Writes don’t wait for hibernation. As a room runs, its changes stream out of the VM to IRT’s host daemon after each tick, and the host daemon writes the whole room state to object storage every 30 seconds when it has changed, and again on hibernate. Because the changes are already outside the VM:

  • a room host that crashes, or that the watchdog kills for blocking its event loop, restarts its rooms with nothing lost;
  • a host, or IRT’s host daemon on it, that fails loses the changes since the last write to object storage: at most 30 seconds, unless object storage can’t be reached.

Anything you keep outside the SDK, in plain variables, isn’t saved. Rebuild it in onWake. Room state and the managed store has the details: what’s saved, the log and base it’s written as, and its 32 MB limit.

How a room wakes

A room wakes when a client connects to it, when the API sends it a message or reads its state, or when its alarm is due. A room woken for its alarm runs onAlarm straight after onWake, and with no one connected hibernates again as soon as it’s done. See Alarms.

A room’s first start goes the same way. A room created through the API, with irt rooms create or by connecting to a new ID, is hibernated until its first connection or API call, and its client sees waking then too. The difference is only that it has no room state, so onCreate runs instead of onWake.

irt rooms list shows only active rooms; irt rooms list --all shows hibernated ones too.

  1. The router sees a connection for a room that isn’t active. It holds the connection and tells the client the room is waking. The client SDK emits a waking event, so you can show it.
  2. The router picks where the room runs: a VM of your tenant that already runs the room’s build and has room to spare, or a new VM.
  3. The room’s state is read from object storage and the room starts in a room host. onWake runs.
  4. The router sends the client its welcome, and every connection held while the room woke joins. connect resolves once the client holds the full state; a client that was reconnecting emits restored.

However many players connect at once, a room wakes once.

How fast

What a wake costs depends on where it lands:

  • In a room host that is already running. Most wakes. Starting a room inside a running room host takes microseconds; what remains is reading the room state.
  • In a VM that has to start. VMs restore from a base snapshot. In our measurements, a VM restores and its room host answers in about 35 ms.
  • In the first room host of a build. The first room to start or wake on a build, such as the first after a deploy, starts that build’s room host, which loads the room runtime and your bundle before the room starts. In our measurements that added about 70 to 200 ms, and more for a large bundle, for code that does work when its module loads, or on a busy host. Later rooms of the build reuse the room host.

End-to-end wake times, including reading room state from object storage, haven’t been measured yet and will be published when they are.

If a room hasn’t woken after 10 seconds, the router closes held connections with close code 4010 and the client SDK reconnects after a backoff. The wake isn’t cancelled, so a slow wake loses nothing.

Sleeping rooms

A room whose players stay connected but rarely act, such as a board game, a chat or a lobby waiting for players, can sleep: it hibernates while the router holds its players’ connections, and the next thing one of them does wakes it. Nobody leaves, and time asleep costs no room-hours.

import { defineCalls, defineRoom, defineSchema, entity } from "@irt/room";

const Board = entity("board", { turn: "u32" });
const schema = defineSchema({ state: Board, calls: defineCalls<{ move(): number }>() });

export default defineRoom({
  schema,
  sleep: { after: 30_000 },
  calls: {
    // A call made while the room sleeps wakes it, and is answered once it has.
    move(room) {
      room.state.turn += 1;
      return room.state.turn;
    },
  },
});

With sleep.after, the room sleeps once it has been quiet that long: no message from any client but a ping, nothing sent or stored, no call or server API request in flight. room.sleep() sleeps it at once. Presence and each connection’s data are saved with the room state, so when a message, call or close from a held connection, a new connection, the alarm or an API call wakes it, onWake runs with slept: true and the connections are back, without onJoin. A held connection that closed meanwhile gets onLeave then. Clients see room.asleep and the sleep and wake events, and their calls are answered after the wake.

Opting in promises that the room keeps every deadline in its alarm rather than in timers or by counting ticks, since nothing ticks while it sleeps. See Sleeping rooms for the details and limits. A wake is billed at least 10 seconds, so set sleep.after long enough that a conversation doesn’t wake the room for every message.

A room whose definition has sleep also sleeps, rather than closing its players’ connections, when the server it runs on is taken out of service, and wakes on another one when a player next acts. Players whose connection drops while their room sleeps, such as when IRT restarts the router they’re connected to, reconnect to the sleeping room without waking it, as long as their client still holds the room’s state from before the sleep.

Timers across hibernation

Store deadlines in ticks, as endsAt = room.tick + seconds * tickRate in room state, and compare in onTick. Ticks keep running for the 30 seconds between the last connection closing and the room hibernating, then stop until it wakes, and room.tick carries on from where it stopped. Left alone, a turn or round timer therefore runs for those 30 seconds with nobody there, then freezes: a round left 20 seconds before its end expires unwatched, and one left 40 seconds before resumes with 10 left. Bots and AI turns driven from onTick play on in that window too. Pick one of two behaviours instead.

Freeze while empty. The room is empty when onLeave sees room.connections.size === 0, and occupied again when onJoin sees room.connections.size === 1: the connection map already lacks the leaving connection in onLeave and holds the joining one in onJoin. Move what’s left of the timer into room state there, and back on the first join. onLeave runs for every open connection before any hibernation but a sleep, so this covers requestHibernate and drains too. Gate bots the same way, or on room.connections.size > 0 in onTick.

// round-timer.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

export const Round = entity("round", { endsAt: "u32", pausedLeft: "u32" });
export const schema = defineSchema({ state: Round });

const ROUND_SECONDS = 60;

export default defineRoom({
  schema,
  onCreate(room) {
    room.state.endsAt = room.tick + ROUND_SECONDS * room.tickRate;
  },
  onJoin(room) {
    if (room.connections.size === 1 && room.state.pausedLeft > 0) {
      room.state.endsAt = room.tick + room.state.pausedLeft;
      room.state.pausedLeft = 0;
    }
  },
  onLeave(room) {
    if (room.connections.size === 0 && room.state.endsAt > room.tick) {
      room.state.pausedLeft = room.state.endsAt - room.tick;
      room.state.endsAt = 0;
    }
  },
  onTick(room) {
    if (room.state.endsAt !== 0 && room.tick >= room.state.endsAt) {
      room.state.endsAt = 0;
      room.broadcast({ type: "round-over" });
    }
  },
});
// round-timer.test.ts
import { testRoom } from "@irt/testing";
import { expect, it } from "vitest";
import room from "./round-timer.js";

it("freezes the round while the room is empty", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  await t.host.tick(50 * 20);
  await alice.leave();
  await t.host.advance(31_000);
  expect(t.status).toBe("hibernated");

  const back = await t.connect({ player: "alice" });
  await t.host.tick(5 * 20);
  expect(back.messages).toEqual([]);
  await t.host.tick(6 * 20);
  back.expectMessage({ type: "round-over" });
});

Catch up with wall time. Let the timer run through the idle window, and on wake move each deadline back by the time the room spent hibernated, which onWake gets as elapsed milliseconds. A long absence can reset the game instead:

// catch-up.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

const Round = entity("round", { endsAt: "u32", lobby: "bool" });

export default defineRoom({
  schema: defineSchema({ state: Round }),
  onWake(room, { elapsed }) {
    if (elapsed > 30 * 60_000) {
      room.state.lobby = true;
      room.state.endsAt = 0;
    } else if (room.state.endsAt !== 0) {
      room.state.endsAt = Math.max(room.tick, room.state.endsAt - Math.round((elapsed / 1000) * room.tickRate));
    }
  },
});

Room code’s own timers, such as setTimeout, are neither: they don’t survive hibernation and don’t follow the test clock.

Offline progress

An idle or incremental game keeps producing while nobody is there, and the room hibernates then, so nothing ticks. Don’t add up production per tick. Store each amount with the wall time it’s valid at, and compute it in closed form at any later time:

// colony.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

const Colony = entity("colony", { food: "f64", rate: "f64", cap: "f64", at: "f64" });

const foodAt = (c: { food: number; rate: number; cap: number; at: number }, now: number) =>
  Math.min(c.cap, c.food + (c.rate * (now - c.at)) / 1000);

export default defineRoom({
  tickRate: 1,
  schema: defineSchema({ state: Colony }),
  onCreate(room) {
    Object.assign(room.state, { food: 0, rate: 0.5, cap: 1000, at: room.now() });
  },
  onMessage(room, _conn, msg) {
    if ((msg as { type?: string }).type !== "spend") return;
    // Settle the amount only when it changes, so nothing is written per tick.
    const now = room.now();
    room.state.food = foodAt(room.state, now) - 10;
    room.state.at = now;
  },
});
  • room.now() is wall-clock milliseconds, the same as Date.now() once deployed (each VM’s clock is set from its host’s when it boots), but it also follows @irt/testing’s clock, so a test can move it by days. Use it rather than Date.now() for anything timed in real time, and rather than a clock built from room.tick, which stops while the room is hibernated and changes meaning when a build changes tickRate.
  • Clients compute the same function from at to show live amounts, so nothing has to tick for the display either.
  • Events on a wall-clock schedule, such as something every 4 hours of UTC or a reset at midnight, are counted between the stored time and now: Math.floor(now / period) - Math.floor(at / period).
  • onWake gets { elapsed, savedAt, reason }: savedAt is the wall time room state was last written, at the room’s last tick with changes or its hibernation, and elapsed the milliseconds from then to the wake, never negative. A game that computes from at needs neither, but they suit a “while you were away” summary.

Retention

rooms.retention in irt.config.ts sets how long a room is kept after it hibernates:

Value Meaning
"none" The room is deleted, not hibernated, when its idle timeout passes with no connections. Its room state is still saved while it’s active, so a crashed host loses nothing.
"<n>h", "<n>d" The room is deleted after it has been hibernated that long without waking, up to "365d". A wake restarts the clock at its next hibernation.
"forever" The room is never deleted except through the API.

The default is "7d". A deleted room’s ID can be created again; a client connecting to a deleted room gets close code 4002, or creates it again as a new room when players.createRooms is on, and webhooks get room.deleted with reason expired.

A room created through the HTTP API or irt rooms create --retention can have a retention of its own, which wins over the project’s; rooms clients create use the project’s.

Generations

Each wake gives the room a new generation. A client drops all its entities and prediction state on a change of generation and on any reconnect, then receives a full state update. Your client code sees entities leave and enter again.

New builds

When a build is made live, the project’s rooms.buildUpdate setting decides what running rooms do:

  • hibernate (the default): rooms stay on their build until they next hibernate, and wake on the new one.
  • restart: rooms hibernate, saving their room state, and wake on the new build straight away. Clients reconnect automatically, with close code 4008. Each host moves 20 rooms a second, so a project with many rooms finishes over a minute or so rather than all at once. A room with no connections wakes on the new build when something next connects.

When a build changes the shape of room state, it provides an up migration, which runs when a room wakes on it. The room state from before the migration is kept for 7 days, so a rollback can restore it.

A rollback makes an older build live again. A room whose room state is at a newer version than the build’s wakes from its room state from before the migration, losing progress since then, and the project gets a room.restored webhook. A room with none kept, because it was created on the newer build or its kept state is over 7 days old, fails to wake, with the reason in irt logs, until a build at its version is live again. See irt rollback. See also Builds and deploys.

What it costs

A room is billed in room-hours by room size, from the start of a wake until hibernation completes. Many rooms share a room host, and many room hosts share a VM, so an idle room doesn’t hold a process or a VM of its own. A VM stops 5 minutes after its last room hibernates.

This is what makes per-room pricing cheap: you pay for rooms while players are in them, not for servers that wait for players.

View this page as Markdown