Rooms

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

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:

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 room, whose schema is:

// 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] });
// 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. To keep a guest’s identity across runs with no backend, see 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):

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:

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

View this page as Markdown