# Node clients and bots

> Run @irt/client from Node scripts, bots and load tests, with the schema the room uses.

`@irt/client` runs in Node 24 or later as well as in browsers, so a
script can play a room: an external bot, a smoke test against `irt dev`
or a deployed project, or a load test. To fill a seat with a bot the
room drives itself, with no connection, see [Bots](/docs/guides/bots). Node has no
WebTransport, so the client connects over WebSocket; everything else
works as in a browser. On an older Node, which may lack a global
`WebSocket`, `connect` rejects with an `IrtError` naming the Node version
it needs and the one it found.

## Running TypeScript with the room's schema

A script needs the same schema module as the room, or the router refuses
it with 4004. The schema is TypeScript, so either bundle the script or
run it with a TypeScript loader.

**Bundle with esbuild**, which a scaffolded project already has:

```sh
npx esbuild scripts/bot.ts --bundle --platform=node --format=esm --packages=external --outfile=.irt/bot.mjs
node .irt/bot.mjs
```

`--packages=external` leaves `@irt/client` and its dependencies in
`node_modules` instead of bundling them, and the schema, imported by a
relative path, is bundled into the script.

**Or run it with a loader** such as [tsx](https://tsx.is):

```sh
npx tsx scripts/bot.ts
```

Node 24's own type stripping (`node scripts/bot.ts`) doesn't work here:
it doesn't rewrite the `.js` extensions in imports such as
`"../src/schema.js"` to the `.ts` files they name.

## A bot

This bot plays the [quickstart's](/docs/guides/quickstart) room, whose
schema is:

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

export const Player = entity("player", { owner: "string", x: "f32", y: "f32" });
export const Board = entity("board", { moves: "u32" });
export const Mine = entity("mine", { moves: "u32" });
export const schema = defineSchema({ state: Board, private: Mine, entities: [Player] });
```

```ts
// scripts/bot.ts
import { connect, createRoom, joinByCode } from "@irt/client";
import { Player, schema } from "../src/schema.js";

const router = process.argv[2] ?? "http://127.0.0.1:7350";
const project = "dots";

// Create a room with a join code, then resolve the code as a second player would.
const { joinCode } = await createRoom({ router, project, joinCode: true });
const id = await joinByCode({ router, project, code: joinCode!.toLowerCase() });

const room = await connect({ router, project, room: id, schema });
console.log(`joined ${id} as ${room.player}`);
room.on("message", (m) => console.log("message", m));
room.on("closed", (e) => {
  console.log("closed", e.code, e.message);
  process.exit(e.code === 1000 ? 0 : 1);
});

const timer = setInterval(() => {
  room.send({ type: "move", x: Math.random() * 100, y: Math.random() * 100 });
  const others = [...room.entities.all(Player)].filter((p) => p.owner !== room.player);
  console.log(`tick ${room.tick}: ${others.length} others, ${room.state.moves} moves`);
}, 1000);

setTimeout(() => {
  clearInterval(timer);
  room.leave();
}, 10_000);
```

- **The router address** is the same as a browser client's:
  `http://127.0.0.1:7350`, or the `--port` you gave `irt dev`, locally,
  and the one `irt deploy` prints, such as `wss://eu.irt.io`, for a
  deployed project. An `http://` or `https://` address works as well as
  `ws://` and `wss://`.
- **Guests.** Without a `token`, the client joins as a guest when the
  project allows guests. Node has no `localStorage` by default, so the
  guest token is kept in memory: each process is a new guest with a new
  player ID, and private state doesn't carry over between runs. Pass a
  player token from your backend to play as a known player; see
  [Player tokens](/docs/player-tokens). To keep a guest's identity
  across runs with no backend, see [A guest that returns](#a-guest-that-returns).
- **Join codes** are uppercase, but `joinByCode` accepts them in any case
  and with spaces around them, so codes typed by people or passed on a
  command line need no cleaning.
- **Exiting.** An open client keeps the process alive, as it reconnects
  on its own. Call `room.leave()` to end it, and exit on `closed`.

## A guest that returns

The client keeps its guest token in `localStorage` when there is one,
and Node has one behind a flag. Run the script with Node's web storage
turned on and a file to keep it in, and every run is the same guest,
with the same player ID and private state, for as long as the guest
token lasts (30 days):

```sh
node --experimental-webstorage --localstorage-file=.irt/guest.db .irt/bot.mjs
```

That's the simplest way to test "a guest comes back" from a script. Each
file is one guest per project; use another file for a second player. On
Node 24, `--localstorage-file` alone is accepted silently and does
nothing: `localStorage` stays undefined, and each run is a new guest
again. It needs `--experimental-webstorage` with it. Node prints an
experimental warning when the flag is on.

## Predicting from a script

`predict` works in Node too. Its `sample` runs once per room tick from
the next tick on, and receives the prediction, so a bot can steer by
where it predicts itself:

```ts
// scripts/runner.ts
import { connect } from "@irt/client";
import { Runner, schema, stepper } from "../src/shared.js";

const room = await connect({ router: "http://127.0.0.1:7350", project: "runners", room: "track-1", schema });

const me = room.predict(stepper, {
  find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
  sample: (p) => ({ dx: (p.entity?.x ?? 0) < 40 ? 1 : 0, dy: 0 }),
});

const check = setInterval(() => {
  if ((me.entity?.x ?? 0) < 40 || !me.settled) return;
  console.log(`reached ${me.entity!.x} after ${me.mispredictions} mispredictions`);
  clearInterval(check);
  room.leave();
}, 100);
```

```ts
// src/shared.ts
import { defineSchema, defineStepper, entity } from "@irt/client";

export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32" });
export const schema = defineSchema({ entities: [Runner] });

export const stepper = defineStepper<{ dx: number; dy: number }>({
  step(world, inputs, dt) {
    for (const r of world.all(Runner)) {
      const input = inputs.get(r.owner);
      if (input) r.x += input.dx * 6 * dt;
    }
  },
});
```

## In tests

To drive a room from a test, use [`@irt/testing`](/docs/reference/testing)
instead: it runs the room in process with the real client and a clock
the test controls, so nothing waits on timers. Scripts like the ones here
are for checking a room as it really runs, under `irt dev` or deployed.

## Common mistakes

- **A copy of the schema in the script.** Import the room's own schema
  module; a copy that drifts gives 4004.
- **Running TypeScript with plain `node`.** Bundle it, or use a loader
  that resolves `.js` imports to `.ts` files.
- **Expecting a bot to keep its player ID across runs.** A guest in Node
  is new each run unless web storage is on; pass a player token, or run
  with `--experimental-webstorage --localstorage-file`.
- **Leaving the process running.** Call `leave()`; the client otherwise
  reconnects for as long as the process lives.
