# 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

```sh
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 |

```ts
// 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.

```ts
// 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

```ts
// 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](/docs/guides/state-sync#where-player-data-lives).

## 4. Test it

```ts
// 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 }]);
});
```

```sh
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](/docs/reference/testing).

## 5. Write the client

```ts
// 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

```sh
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

```sh
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](/docs/no-backend). See
[Builds and deploys](/docs/builds) 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](/docs/guides/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()`.
