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
onJoinand deleted inonLeave, so it’s on the board only while they’re connected.onLeaveruns 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.
onMessagereceives 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 orroom.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
sendbeforeconnectresolves. It throws. Awaitconnectfirst. - Advancing nothing in a test. State updates go out at the end of a
tick, so assert after
await t.host.tick().