# 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](/docs/concepts#rooms), 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](/docs/cli#irt-build---out-dir). 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 |

```json
{
  "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](#the-schema-check-at-deploy).
- `stateVersion` is the type's `version`, the
  [room state version](#room-state-versions) its code writes.
- `settings` is what `irt.config.ts` declares; it becomes the project's
  when the build goes live. See [Settings](/docs/cli#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:

```ts
// 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](/docs/guides/node-client).

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).

## 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](/docs/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](#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:

```ts
// 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](/docs/reference/room-sdk#room-state-and-migrations):
`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](/docs/reference/errors#in-the-room). 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](/docs/guides/testing#migrations).

### 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](#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:

```text
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).

## 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](/docs/webhooks). 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`](/docs/webhooks) 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.

```yaml
# .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](/docs/reference/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.
