Start here

Quickstart

Build, test and deploy a small multiplayer room and its web client from scratch.

This guide builds “dots”: every player is a dot on a shared board, a click moves your dot, the room counts everyone’s moves, and each player sees a count of their own. It uses the four packages a game needs:

Package Used in What it is
@irt/room room code defineRoom, entities, interest, steppers
@irt/client the web client connect, the entity store, prediction
@irt/testing tests rooms in process with test clients and a test clock
@irt/cli your shell the irt command: init, dev, test, deploy

You need Node 24 or later. The CLI is @irt/cli on npm; the name irt there belongs to someone else, so run it with npx @irt/cli.

1. Create the project

npx @irt/cli login
npx @irt/cli init dots --project dots
cd dots
npm install
npx @irt/cli projects create dots

irt login opens https://app.irt.io/device and stores a token in ~/.config/irt/credentials.json. irt init writes the files below for the project slug dots, and irt projects create dots creates the project in your account. A slug is 3 to 40 lowercase letters, digits and single hyphens, starting with a letter, and is never reused, so if dots is taken, pick another and use it in both commands.

File Contents
irt.config.ts The project’s slug, room module, site and settings
package.json The four packages, esbuild, and scripts for irt dev, irt test, tsc --noEmit (typecheck), irt deploy and build:web
build-web.mjs Bundles and minifies web/main.ts into dist/ with esbuild, splitting each dynamic import() into a chunk, with hashed file names that it writes into dist/index.html; site.build runs it as npm run build:web
src/schema.ts The schema the room and the client share
src/room.ts The room code
src/room.test.ts A test with @irt/testing
web/index.html, web/main.ts A web client with no framework
// irt.config.ts
import { defineConfig } from "@irt/cli/config";

export default defineConfig({
  project: "dots",
  room: "src/room.ts",
  site: { dir: "dist", build: "npm run build:web", crossOriginIsolated: false },
});

The slug lives only here: the web client imports it from this file. The rest of this guide replaces the files in src/ and web/main.ts with the dots game.

2. Declare the schema

The schema lists the room’s entity kinds and its shared and private state. The room and every client import the same module, so their schema hashes match; a client built against a different schema is refused with close code 4004.

// src/schema.ts
import { defineSchema, entity } from "@irt/room";

export const Player = entity("player", {
  owner: "string",
  x: "f32",
  y: "f32",
});

/** Shared state: every connection sees it. */
export const Board = entity("board", { moves: "u32" });

/** Private state: only its own player sees it, and it outlasts a visit. */
export const Mine = entity("mine", { moves: "u32" });

export const schema = defineSchema({ state: Board, private: Mine, entities: [Player] });

@irt/client exports the same defineSchema and entity, so the module can import from either package.

3. Write the room

// src/room.ts
import { defineRoom, type RoomEntity } from "@irt/room";
import { Player, schema } from "./schema.js";

const SIZE = 100;
const clamp = (v: number) => Math.min(SIZE, Math.max(0, v));

export default defineRoom({
  schema,
  tickRate: 20,
  onJoin(room, conn) {
    // After a room host restart, the player's dot from before is still here.
    const existing = [...room.entities.all(Player)].find((p) => p.owner === conn.player);
    conn.data.player =
      existing ??
      room.entities.create(Player, {
        owner: conn.player,
        x: Math.random() * SIZE,
        y: Math.random() * SIZE,
      });
  },
  onMessage(room, conn, message) {
    const m = message as { type?: unknown; x?: unknown; y?: unknown };
    if (m.type !== "move" || typeof m.x !== "number" || typeof m.y !== "number") return;
    const player = conn.data.player as RoomEntity<typeof Player>;
    player.x = clamp(m.x);
    player.y = clamp(m.y);
    room.state.moves += 1;
    room.private(conn).moves += 1;
  },
  onPlayerLeave(room, player) {
    for (const e of room.entities.all(Player)) if (e.owner === player) room.entities.delete(e);
  },
});

The room module’s default export is the result of defineRoom. Setting an entity’s field records the change; at the end of each tick the room sends every client a state update with what changed.

Each piece of data here lasts as long as it should:

  • A player’s dot is an entity created in onJoin and deleted in onLeave, so it’s on the board only while they’re connected. onLeave runs whenever a connection closes, and for every open connection before the room hibernates.
  • The board’s move count is room.state, which every player sees.
  • Each player’s own count is private state, kept by player ID: it comes back when they rejoin, for up to 24 hours after they left by default.

Entities, room.state and private state are room state: they’re saved when the room hibernates and restored when it wakes, with no code of yours. If the room host crashes, the room restarts from its room state without running onLeave, and onJoin runs again as players reconnect, which is why it looks for an existing dot. See Where player data lives.

4. Test it

// src/room.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./room.js";
import { Player } from "./schema.js";

test("a move reaches every player and is counted", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });

  alice.send({ type: "move", x: 10, y: 20 });
  await t.host.tick();

  expect(bob.view(Player).find((p) => p.owner === "alice")).toMatchObject({ x: 10, y: 20 });
  expect(t.live.state.moves).toBe(1);
  expect(alice.private.moves).toBe(1);
  expect(bob.private.moves).toBe(0);
});

test("the counts survive hibernation, and the dot doesn't", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  alice.send({ type: "move", x: 1, y: 1 });
  await t.host.tick();
  await alice.leave();
  await t.hibernate();

  const back = await t.connect({ player: "alice" });
  expect(back.client.state.moves).toBe(1);
  expect(back.private.moves).toBe(1);
  expect(back.view(Player)).toHaveLength(1);
});

test("a crash keeps the player's dot", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  alice.send({ type: "move", x: 5, y: 5 });
  await t.host.tick();
  await t.host.crash();
  await alice.reconnected();
  expect(alice.view(Player)).toMatchObject([{ owner: "alice", x: 5, y: 5 }]);
});
npx @irt/cli test

Time in a test room moves only when the test moves it: t.host.tick() runs one tick. See @irt/testing.

5. Write the client

// web/main.ts
import { connect } from "@irt/client";
import config from "../irt.config.js";
import { Player, schema } from "../src/schema.js";

// irt dev serves the site and the room host on one origin; deployed sites use the region's router.
const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin;

const canvas = document.querySelector("canvas")!;
const ctx = canvas.getContext("2d")!;
const status = document.querySelector("#status")!;

const room = await connect({
  router,
  project: config.project,
  room: "main",
  schema,
  onWaking: () => (status.textContent = "Starting the room"),
});
status.textContent = `Connected as ${room.player}`;
room.on("disconnected", () => (status.textContent = "Reconnecting"));
room.on("restored", () => (status.textContent = `Connected as ${room.player}`));
room.on("closed", (error) => (status.textContent = error.message));

const positions = room.interpolate(Player, ["x", "y"]);

canvas.addEventListener("click", (ev) => {
  const scale = 100 / canvas.width;
  room.send({ type: "move", x: ev.offsetX * scale, y: ev.offsetY * scale });
});

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const scale = canvas.width / 100;
  for (const p of room.entities.all(Player)) {
    const { x, y } = positions.get(p);
    ctx.fillStyle = p.owner === room.player ? "crimson" : "steelblue";
    ctx.beginPath();
    ctx.arc(x * scale, y * scale, 6, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = "black";
  ctx.fillText(`moves: ${room.state.moves}, yours: ${room.private.moves}`, 8, 16);
  requestAnimationFrame(draw);
}
draw();

The router address is chosen the way the scaffolded client chooses it: irt dev serves the site and the room host on one origin, so locally the client connects to location.origin; on <slug>.irt-serve.com it connects to the region’s router, wss://eu.irt.io, which irt deploy prints.

await connect(...) resolves once the client holds the room’s full state, so room.state and room.private are set from then on. Without a token, the client joins as a guest; the guest token is kept in localStorage, so a reload, another tab or a browser restart keeps the same player ID and the same private state.

6. Run it locally

npx @irt/cli dev

irt dev builds the site with npm run build:web, runs a local room host, and serves both on http://127.0.0.1:7350 with the same headers the hosted site gets. Open it, and open it again in a private window or another browser, and click. Two tabs of one browser share a guest, so they’d be the same player, and the newer tab would close the older one with 4012. It rebuilds and restarts the room host when a file changes.

7. Deploy

npx @irt/cli deploy

This bundles the room and the site into a build, uploads it and makes it live, then prints the site’s address, https://dots.irt-serve.com, and the router its client connects to, wss://eu.irt.io. The first client to connect creates the room, as under irt dev, because players.createRooms is on in irt.config.ts. With it off, connecting to a room that was never created closes with 4002, and rooms are created with irt rooms create <id> or by your backend; see Games without a backend. See Builds and deploys for what a deploy does to clients and rooms.

Common mistakes

  • Importing the schema twice under different declarations. The room and client must build the schema from the same module. Two copies that differ in a field name, type or order give different hashes, and the client fails with 4004.
  • Trusting message contents. onMessage receives whatever the client sent. Check types and ranges as the room above does before writing them to entities.
  • Keeping a player’s score on their entity. The entity is deleted in onLeave, which also runs before the room hibernates. Keep it in private state or room.state.
  • Creating a second entity for a player in onJoin. After a room host crash the old one is still there; find it first.
  • Writing a client entity. Entities on the client are read-only; a write throws. Send a message, or use prediction for the local player.
  • Calling send before connect resolves. It throws. Await connect first.
  • Advancing nothing in a test. State updates go out at the end of a tick, so assert after await t.host.tick().

View this page as Markdown