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--portyou gaveirt dev, locally, and the oneirt deployprints, such aswss://eu.irt.io, for a deployed project. Anhttp://orhttps://address works as well asws://andwss://. - Guests. Without a
token, the client joins as a guest when the project allows guests. Node has nolocalStorageby 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
joinByCodeaccepts 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 onclosed.
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.jsimports to.tsfiles. - 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.