Build and deploy

Builds and deploys

What a build holds, why clients must match its schema hash, how rooms move to a new build, and how room state versions and migrations work.

A build is one deployable version of a project: the bundled room code of each of its room types, its site if it has one, and the settings irt.config.ts declares. irt deploy makes one and makes it live; the commands themselves are in Deploying with the CLI. This page is about what happens to clients and rooms when a build goes live.

What’s in a build

irt build writes .irt/build/build.tar.gz:

Path Contents
irt-build.json The manifest, below
room/{type}.mjs Each standard room type’s module and everything it imports, bundled with esbuild for Node 24
site/ The output of site.build, copied from site.dir, when the project has a site
{
  "format": 2,
  "sdk": "0.1.0",
  "site": { "crossOriginIsolated": false },
  "settings": { "players": { "guests": true } },
  "types": {
    "default": {
      "entry": "default.mjs",
      "schemaHash": "0x1f2e3d4c",
      "schema": [
        { "name": "board", "fields": [{ "name": "moves", "type": "u32", "writable": false }] },
        { "name": "mine", "fields": [{ "name": "moves", "type": "u32", "writable": false }] },
        { "name": "player", "fields": [{ "name": "owner", "type": "string", "writable": false }] }
      ],
      "stateVersion": 1
    },
    "match": { "entry": "match.mjs", "schemaHash": "0x5a6b7c8d", "schema": [], "stateVersion": 3, "size": "large", "maxPlayers": 8, "retention": "1d" },
    "chat": { "native": true, "schemaHash": "0x9437de34", "schema": [], "stateVersion": 1, "tickRate": 20, "size": "small" }
  }
}
  • types has one entry per room type in irt.config.ts: 1 to 32, named with 1 to 32 characters of a-z, 0-9 and -, starting with a letter. A project with room in its config has one type, default.
  • A standard type has an entry in room/. A native type has native: true and no entry, its schema’s hash, its schema with each entity kind’s rules, state version 1 and its tickRate; the schema module isn’t in the build. Without a schema module it has the empty schema’s hash and kinds.
  • size, maxPlayers and retention are the type’s own, when its config sets them, over the project’s rooms.size, no cap and rooms.retention.
  • schemaHash is the hash clients of that type send when they connect, 0x and 8 hex digits. irt build gets it by loading each bundled room in a child process, so a room module that throws at import fails the build.
  • schema lists every kind with its fields, types and writable fields: the shared state first, then the private state, then the entity kinds in declared order. The control server compares it with the live build’s when the build goes live; see The schema check at deploy.
  • stateVersion is the type’s version, the room state version its code writes.
  • settings is what irt.config.ts declares; it becomes the project’s when the build goes live. See Settings.

An upload is refused when the tarball is over 100 MB, unpacks to more than 250 MB or 5,000 files, or room/ is over 50 MB.

The schema hash

A client connects with the hash of the schema it was built with, and is closed with 4004 unless the hash is exactly that of its room’s type in its room’s build: the live build’s, or an older build’s for a room that still runs one. There is no compatibility between schemas: adding a field, renaming one, changing a type, a writable list or the order of entities all change the hash.

That’s safe for a web client because a build’s site goes live with its room code. A page loaded before a deploy has the old client; when it next connects, or reconnects, it gets 4004, and reloading loads the new site, which matches. So a web client handles a deploy like this:

// deploy-reload.ts
import { CloseCode, connect, defineSchema, IrtError, reloadForNewBuild } from "@irt/client";

const schema = defineSchema({});

async function join(room: string) {
  try {
    const client = await connect({ router: "wss://eu.irt.io", project: "my-game", room, schema });
    client.on("closed", (e) => {
      if (e.code === CloseCode.SchemaMismatch) reloadForNewBuild();
    });
    return client;
  } catch (e) {
    if (e instanceof IrtError && e.code === CloseCode.SchemaMismatch) reloadForNewBuild();
    throw e;
  }
}

await join("main");

A build that doesn’t change the schema keeps its hash, so connected clients carry on through it.

Clients that don’t come from the site, such as bots, load tests and native clients, must be rebuilt against the new schema and deployed with it. Import the room’s own schema module in them; see Node clients and bots.

A room still running an older build, under buildUpdate: "hibernate", keeps accepting the old site’s clients and refuses the new site’s with 4004, naming both hashes, until it moves to the live build. Rooms move once they hibernate, 30 seconds after their last player leaves. Reloading doesn’t help a new client in that window, since it loads the same site, so reload once for 4004 rather than in a loop: reloadForNewBuild(), as above, reloads at most once until a connection succeeds. If a build changes the schema, have the room call room.requestHibernate() between rounds to move sooner; see When a build goes live.

When a build goes live

Making a build live, with irt deploy, the dashboard or POST /projects/{p}/builds/{id}/live, does three things:

  1. The site at <slug>.irt-serve.com serves the new build’s files. Every edge server picks it up within 10 seconds; see Sites.
  2. The settings the build declares become the project’s.
  3. Every room that wakes from then on wakes on the new build.

Running rooms follow rooms.buildUpdate:

Value Running rooms
"hibernate" (default) Stay on their build until they next hibernate, then wake on the new one
"restart" Hibernate and wake on the new build straight away; clients reconnect with 4008

With "restart", each room saves its room state as it hibernates, as it would when idle, so nothing is lost. Rooms move a few at a time, 20 a second on each host, so a deploy doesn’t stall every room at once. A room that is waking or hibernating as the build goes live still ends up on the new build. A client loaded from the old site reconnects to the room on the new build; if the schema changed it gets 4004 and reloads.

A room on an old build keeps running it as long as players stay. A game that needs everyone on the new build soon after a deploy can have the room call room.requestHibernate() when it’s between rounds; its clients close with 4011 and the next connection wakes it on the new build.

To move running rooms onto the live build whenever you choose, run irt restart, use Restart rooms in the dashboard, or call POST /projects/{p}/restart with {} or {"room": "<id>"}. It works like "restart" above whatever rooms.buildUpdate says, and also restarts rooms already on the live build.

Room state versions

Room state carries a version, version in defineRoom, default 1. A build that changes what’s saved in a way old room state doesn’t fit increments it and adds an up migration:

Change Migration needed
Add a field to a kind No; old room state loads with the field’s default
Add a kind No
Widen a number type: u8 to u16, u32, i16, i32, f32 or f64; u16 to u32, i32, f32 or f64; i8 to i16, i32, f32 or f64; i16 to i32, f32 or f64; u32, i32 or f32 to f64 No; every old value fits
Change which fields are writable or owner-only No
Rename the shared or private state’s kind No; room state stores them by position
Remove or rename a field Yes: old room state has a field the build doesn’t declare
Remove or rename an entity kind Yes; convert or drop those entities
Any other type change, such as u16 to u8 or string to json Yes

| Change what the stepper’s save returns | Yes; convert data.stepper |

The control server enforces the rows that say yes, except the stepper’s, which it can’t see; see The schema check at deploy. Any schema change still changes the hash, migration or not.

A migration takes the room state as plain data at one version and returns it at the next. migrations[2] turns version 1 into version 2:

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

// v1: player { hp: "u8" }. v2: hp renamed to health, a u16.
// v3: the shared state's scores, a json map, gains a round counter.
const Player = entity("player", { owner: "string", health: "u16" });
const Match = entity("match", { scores: json<Record<string, number>>(), round: "u16" });

export default defineRoom({
  schema: defineSchema({ state: Match, entities: [Player] }),
  version: 3,
  migrations: {
    2: (data) => ({
      ...data,
      entities: data.entities.map((e) =>
        e.kind === "player" ? { ...e, values: { owner: e.values.owner, health: e.values.hp } } : e,
      ),
    }),
    3: (data) => ({ ...data, state: { ...data.state, round: 1 } }),
  },
});

RoomData is in the Room SDK reference: state, private by player ID, entities with id, kind, values and owner, stepper, and the tick and IDs. Room state records the declarations it was written with, so a migration sees the old field names and values.

When a room wakes on a build whose version is higher than its room state’s, each migration runs in turn, before onWake. A migration that throws, a missing step, or a result that doesn’t fit the build’s declarations stops the room with a reason saying which; see Errors. Migrations run only on wake, so a room that never wakes again is never migrated.

Test a migration by waking a room from old room state; see Testing rooms.

The schema check at deploy

When a build goes live, from irt deploy, irt rollback, the dashboard or the API, the control server compares each room type’s schema and stateVersion with the live build’s type of the same name. Shared and private state are compared by position, entity kinds and fields by name, so reordering them changes nothing. The result is one of:

Result When Goes live
unchanged Same version, and nothing room state depends on changed Yes
compatible Same version, and only changes the table above needs no migration for Yes
migrated A higher version; the build’s migrations convert room state on wake Yes
rollback A lower version; see Rolling back Yes
unchecked One of the builds was uploaded before builds carried their schema Yes
no_room_state Same version, and a change that needs a migration, but no room the project keeps holds room state at that version: every room is new, deleted, or hibernated at an older version, which the build’s migrations convert Yes
refused Same version, a change that needs a migration, and a room that holds room state at that version or may be running No: 409, code schema_change

irt deploy prints the result for each type and each change:

deployed build b_01J9Z3 to space-race; it's live
room state of type default: version 1 to 2; rooms run this build's migrations when they wake:
  removed player.hp (u8), so room state that has it won't load
  added player.health (u16); room state without it loads with its default value

A refused build stays uploaded and the live build stays live. The error names each breaking change and the fix: bump version in defineRoom and add the migration to it. A renamed field or kind shows as a removal plus an addition, so the migration moves the old values to the new name. A higher version with no schema change is fine, for a migration that changes values only.

The check can’t tell whether your migrations exist or are right; a room whose migration is missing stops on wake. Test them as above.

Adding and removing room types

A room’s type is set when it’s created and never changes, so each type’s room state is checked against its own earlier versions only. A build may add types freely. A build that drops a type is refused with 409, code type_removed, naming the type, while any room of that type has a record, since those rooms could never wake; delete them first, or keep the type. Renaming a type is dropping one and adding another.

What’s kept

When a room first saves room state after a migration, IRT copies the room state from before the migration aside and keeps the copy for 7 days. If you roll back to a build with a lower version, rooms restore it on their next wake, losing what happened in the room since; see Rolling back.

Rolling back

irt rollback makes the build before the live one live again, or the build you name; the dashboard can too. The site and the settings roll back with it, and rooms then wake on the earlier build.

Migrations only go up, so a room that already woke on a build with a higher version holds room state the earlier build can’t read. On its next wake it starts instead from the room state kept from before its 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 copy is over 7 days old, fails to wake with the reason in irt logs until a build that reads its version is live again.

To keep rooms’ progress across a rollback past a version change, deploy a new build with the old code and the new version and its migrations instead.

Rolling back to a build at the same version is safe when the schema check allows it. Rolling back past a build that added a field or kind, without a version bump, is refused: rooms that woke on it saved room state with that field, which the earlier build can’t load. Deploy a new build with the old code and the new schema instead.

Automatic rollback

A build that crashes over and over soon after it goes live is rolled back for you: the build it replaced goes live again, through the same make-live as irt rollback, with the same schema check and room state restore.

A crash is tenant code failing: a room stopped by an exception in room code, or a room host that exited, such as from a throw while the room code loads. Our own stops, such as a host draining for an upgrade or an idle VM stopping, don’t count. A build crash-loops when it has 5 crashes within 5 minutes, counted across the region, in its first hour live.

The rollback happens only when:

  • rooms.rollbackOnCrashLoop is on, as it is by default. Turn it off in the dashboard or with settings: { rooms: { rollbackOnCrashLoop: false } } in irt.config.ts.
  • The build it replaced ran clean: it never crash-looped itself, and it had fewer than 5 crashes in its last 5 minutes live.
  • The live build hasn’t been rolled back before. Each build rolls back at most once, so making it live again by hand sticks.

The build is marked rolled back, with the reason, in the dashboard’s builds list, in irt status and as rolledBack in GET /projects/{p}/builds, and your backend gets a build.rolled_back webhook. Running rooms move as rooms.buildUpdate says; rooms whose code crashes restart on the build that’s live when they wake again, so crashing rooms move straight away. Fix the bug and deploy a new build.

Deploying from CI

Run tests, then deploy with a project API key from the dashboard in IRT_TOKEN. The key acts for its one project.

# .github/workflows/deploy.yml
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx irt test
      - run: npx irt deploy
        env:
          IRT_TOKEN: ${{ secrets.IRT_TOKEN }}

irt deploy --no-live uploads without going live, so a later step, or a person in the dashboard, can make it live.

Common mistakes

  • Deploying the room without its site. A site client built against the old schema gets 4004 from every room on the new build. Keep the site in the same build: set site in irt.config.ts.
  • Reloading on every close. Only 4004 means a new build; see Errors.
  • Renaming or removing a field without a migration. The control server refuses the build. Bump version and convert the room state in a migration.
  • Editing an old migration. Rooms that already ran it won’t run it again. Add a new version instead.
  • Rolling back past a version change. Migrated rooms stop on the old build. Roll forward instead.
  • Expecting "restart" to be instant. Rooms move at 20 a second on each host; a host with 2,000 rooms of the project takes 100 seconds.

View this page as Markdown