Using irtio from Node
@irtio/client is not browser-only. It imports @irtio/protocol and @irtio/schema and reaches
for the global WebSocket, which Node 22 and later provide, so joinRoom and joinRelay work in
a plain Node process with no DOM and no bundler. That is what a headless bot, a load script, or an
end-to-end test against a running irtio dev is made of.
For room logic with no sockets at all, use testRoom instead.
This page is for the cases where you want a real connection.
What you need
| Requirement | Why |
|---|---|
| Node 22 or later | The client uses the global WebSocket. Without one it throws irtio: no global WebSocket and names the transport option |
tsx, or built JavaScript | Your schema and room files are TypeScript. Run them with npx tsx script.ts, or compile first |
| A running endpoint | irtio dev on ws://localhost:7070, or your deployed room |
The published packages ship prebuilt: every @irtio/* package publishes dist/ with .js and .d.ts beside it, so Node resolves them with no build step of your own. Only your own
TypeScript needs tsx.
A headless client
// bot.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRoom(schema, {
url: 'ws://localhost:7070',
role: 'player',
name: 'bot-1',
});
room.on('status', (status) => console.log('status', status));
await room.call.sit({ key: 'bot-1-key', name: 'bot-1' });
console.log(room.state.match.status);
room.leave(); npx tsx bot.ts joinRelay is the same shape for a room with no schema. Nothing on either path touches window, document, or requestAnimationFrame. The interpolated room.render view still works, but it
advances on the frames it receives rather than on a paint loop.
Note that this script joins a new room every time you run it. That is the next section.
Getting several clients into the same room
joinRoom takes a room option holding a room code. In a browser, leaving it out is fine: the
client reads ?room= from the page URL, and only when there is nothing there does it create a room
and write the code back into the address bar. Everyone who opens the share link lands together, and
you never think about it.
Node has no page URL, so that whole mechanism is absent. A joinRoom with no room option in
Node creates a fresh room, every call. Three bots started this way are three clients each alone in
their own room, and nothing reports it as a problem: each one joins successfully, each one sees a
healthy room.state, and each one has a player count of one. The symptom is that they never see
each other.
So one client joins first and the rest join the code it got:
// bots.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const url = 'ws://localhost:7070';
// The first bot creates the room, because it passes no `room`.
const host = await joinRoom(schema, { url, role: 'player', name: 'bot-1' });
console.log('room', host.id, host.link);
// Every other bot joins that code.
const others = await Promise.all(
[2, 3].map((n) => joinRoom(schema, { url, role: 'player', name: `bot-${n}`, room: host.id })),
);
const bots = [host, ...others];
console.log(bots.map((b) => b.me)); // three distinct client ids, one room
for (const bot of bots) bot.leave(); room.id is the room’s code, set from the server’s welcome message, so read it after the join
promise resolves rather than before. room.room does not exist: the option going in is room, the
field coming back is id.
The room option also accepts a full share link, not just a bare code, so room: host.link works
and so does pasting a link out of a browser to point a bot at a room you are already sitting in.
To join a room that already exists, name the code directly and skip the host step:
const room = await joinRoom(schema, { url, role: 'player', room: 'ABCD' }); matchRoom in Node needs both URLs
matchRoom is two calls: a POST to the control plane, then an ordinary joinRoom with the code it
answers. The two read different options. controlUrl points the queue at the hosted plane, and it
is the only one a browser usually needs, because the join half falls back to the page’s own origin.
Node has no page, so the join half falls back to ws://localhost:7070 and fails with E_CONNECT_FAILED against a project that is not running locally. The match succeeds and the join
does not, which reads as a matchmaking problem and is not one.
Pass both: controlUrl for the queue, and url for the room socket. url is the wss:// endpoint irtio deploy prints.
// bot.ts
const room = await matchRoom(schema, {
queue: '1v1',
controlUrl: 'https://irt.io',
url: 'wss://eu.irt.io',
role: 'player',
name: 'bot-1',
}); Setting IRT_URL in the environment does the same thing for every join in the process, since the
client reads it before falling back to localhost.
For many clients at once, spawnBots and defineScenario from @irtio/bots already do this threading for you, along with the process
management, the trace recording, and the invariants. Reach for them before hand-rolling a fleet.
Two things that differ from a browser
Identity has nowhere to live. identity: true keeps its credential in localStorage, which a
Node process does not have. The client falls back to keeping it for the life of the process, so
every run is a new player. Pass your own storage (any object with getItem, setItem and removeItem) to make a bot’s identity durable across runs. Identity also needs a control plane and
a deployed project. See Player identity.
Nothing keeps the process alive by itself. An open socket does, but a script that joins and
then falls off the end of its module still exits. Await something: a scripted sequence, a timer, or
a promise you resolve from a room.on handler.
Installing from a monorepo or a local path
A file: or workspace: install of @irtio/* resolves the same way as a registry install, since
the packages are plain ESM with exports maps. Keep the versions of @irtio/client and @irtio/schema in step: the schema object your script imports has to be the one the client was
built against, and two copies in one node_modules tree read as two different schemas.
Room code is the exception, and its rule is stricter. The bundler that builds your room accepts a fixed list of bare imports, and a workspace specifier is not on it. See what room code may import for the relative-path approach. Your Node script is not room code and has no such limit.
Related
- Simulated players for tests with no sockets.
- Built-in invariants for
spawnBotsand bot scripts. - Testing in CI for what to run on a push.