Scripted NPCs
An NPC is not a special kind of entity. It is a client your room opened for itself: it joins,
it owns entities, its writes go through your validator, it appears in room.clients, and it
counts against maxClients. The one thing it skips on the way in is the per-IP connection limit,
which exists to stop strangers and would otherwise make an NPC room rate-limit itself.
NPCs run on the same runtime irtio simulate uses to drive bots. A bot is a player you test
with, an NPC is a player you ship. Anything you can write in a simulation script, you can write
as a brain.
Declare a brain, then spawn it
Brains live in the room definition, by name, and spawnNPC names one:
// irtio/room.ts
import { arrive, choose, defineRoom, newWander, seek, wander } from '@irtio/server';
import type { Npc } from '@irtio/server';
import { schema } from './schema.js';
type S = typeof schema;
async function hunter(npc: Npc<S>) {
const drift = newWander(() => npc.random());
while (!npc.stopped) {
const me = npc.room.state.runners.get(npc.room.me);
if (me) {
let prey: { x: number; y: number } | undefined;
let best = Number.POSITIVE_INFINITY;
for (const [id, r] of npc.room.state.runners) {
if (id === npc.room.me || r.npc === 1) continue;
const d = Math.hypot(r.x - me.x, r.y - me.y);
if (d < best) {
best = d;
prey = { x: r.x, y: r.y };
}
}
const behaviour = choose([
{ name: 'close-in', when: () => prey !== undefined && best <= 60 },
{ name: 'chase', when: () => prey !== undefined && best <= 260 },
{ name: 'wander', when: () => true },
]);
const v =
behaviour?.name === 'close-in' && prey
? arrive(me, prey, 1, 60)
: behaviour?.name === 'chase' && prey
? seek(me, prey, 1)
: wander(drift, 1, 0.4, () => npc.random());
me.ax = Math.max(-1, Math.min(1, v.x));
me.ay = Math.max(-1, Math.min(1, v.y));
}
await npc.wait(80);
}
}
export default defineRoom(schema, {
mode: 'tick',
tickRate: 60,
npcs: { hunter },
onCreate(_state, room) {
room.spawnNPC({ brain: { kind: 'script', script: 'hunter' }, name: 'hunter-1' });
},
onWake(state, room) {
for (const [id, r] of [...state.runners]) if (r.npc === 1) state.runners.remove(id);
room.spawnNPC({ brain: { kind: 'script', script: 'hunter' }, name: 'hunter-1' });
},
// …onJoin, validate, tick
}); That is the feature. The rest of this page covers the shape and the sharp edges.
Named brains
Your room code runs inside a sandbox with a fixed import list. An NPC connects from outside that
sandbox, so it goes through the same join, the same validator and the same ownership rules a
browser does. A callback cannot cross that boundary. A name can, and the npcs map is where the
function stays.
The npcs map is also the one place a room definition may hold an async function, because a
brain is a loop that awaits.
What an NPC sees and writes
npc.room is the client-side room, the same object a browser gets:
npc.room.meis this NPC’s client id.npc.room.statereads and writes exactly as a browser’s does. Writing a field on an entity this NPC owns is what moves it. Writing one it does not own is ignored, as it is for a player.npc.room.tickis the last server tick this client saw.
The loop helpers come from the bot runtime:
npc.wait(ms)waits, and returns immediately once the NPC has been asked to stop.npc.until(predicate, { label, timeoutMs })polls instead of guessing at a wait.npc.random()is seeded per NPC, so a room that spawns its NPCs the same way twice gets the same NPCs twice.npc.stoppedgoes true when the NPC is despawned. Every loop should check it.
Steering helpers
@irtio/server exports the pure functions a brain needs. Each returns a velocity, so a brain’s
whole movement step is one assignment:
| function | what it gives you |
|---|---|
seek(from, to, speed) | straight at the target, at full speed |
flee(from, threat, speed) | straight away from it |
arrive(from, to, speed, slowRadius) | seek, easing to a stop inside slowRadius |
wander(state, speed, turn, random) | a random walk that curves, with newWander(random) for the state |
patrol(from, waypoints, index, speed, reachedRadius) | walks a waypoint ring; feed back the index it returns |
choose(behaviours) | the first named behaviour whose when() holds |
choose is a helper, not a behaviour-tree engine. It covers “flee if hurt, else chase if close,
else patrol”. Its value is the name it returns: you can log or publish which behaviour is
running without keeping a parallel string.
The spawn handle
spawnNPC returns { clientId, despawn() }. The id is available immediately, so you can record
it the moment you spawn. The client is not: it settles a tick or two later, exactly as a player’s
join does, so an NPC is not in room.clients on the line after the call.
handle.despawn() stops the script and closes the client. room.despawnNPC(clientId) does the
same by id. Both are safe to call twice.
NPCs and hibernation
NPCs do not keep a room awake. A room whose only occupants are scripted NPCs hibernates exactly as an empty one does. Neither their clients nor their frames count as activity.
Their clients are therefore not in the snapshot. The entities they owned are, like any state,
so clear the stale ones and spawn fresh NPCs in onWake:
onWake(state, room) {
for (const [id, r] of [...state.runners]) if (r.npc === 1) state.runners.remove(id);
room.spawnNPC({ brain: { kind: 'script', script: 'hunter' }, name: 'hunter-1' });
}, A woken NPC is a new client with a new client id, so the old row has to go rather than be adopted. Ownership follows the client.
See Hibernation for an excerpt that removes restored player rows whose clients are no longer present.
Limits and errors
maxClientscounts NPCs. A spawn into a full room is refused the way a player’s join is, withE_ROOM_FULLon the room’s log. Budget seats for your NPCs.- Validation applies. An NPC’s writes are judged by
validatelike anybody’s. If your validator clamps a stick to [-1, 1], your NPC’s stick is clamped too. brain.kindaccepts only'script'. Anything else is refused with a sentence naming it. The field reserves the shape. There is no second kind yet.- A brain that throws takes its own NPC down and logs. The room keeps running.
Telling an NPC from a player
ctx.npc is true in any handler acting for an NPC session, so onAdmit, onJoin, onLeave,
an RPC handler and a validator can all branch on it:
onJoin(state, ctx) {
state.players.add(ctx.clientId, { name: ctx.name, bot: ctx.npc ? 1 : 0 });
if (!ctx.npc) state.lobby.humans += 1; // a "waiting for players" gate counts people
}, It is read from the seat the room allocated, never from anything the session says about itself, so a connecting client cannot claim to be an NPC or hide that it is one.
room.clients entries carry the same npc flag, which is how a room reads it for someone other
than the client in hand. Usage reports npcConnections beside connections, so the dashboard can tell twelve players from
eleven hunters and one player.
Testing NPCs
testRoom runs your NPC scripts. The script starts on a real in-process client on the harness’s
fake clock, so a test can spawn one and assert on what it does:
const t = await testRoom(arena);
const a = await t.join({ role: 'player' });
await a.call.addBot();
await t.run(200); // the NPC joins
expect(t.state.players.size).toBe(2);
await t.tick(20); // it plays A long t.run(ms) drives a script parked on npc.wait(ms) step by step, and t.tick(n) and t.until(...) work too. See testRoom for the details.
Use irtio simulate when you want the same room over real sockets in a real tenant, and for
measuring what your NPC count costs.
What one costs
An NPC is not free. Measured on a 2026 laptop, a room of 32 NPCs at 20 Hz costs roughly half a
millisecond of CPU per NPC per tick. That counts both sides: the room paying for another client’s
writes and another entity in every delta, plus a client and a script loop. Eight NPCs in a small
room is comfortable, a hundred is a design decision. Run irtio simulate against your own room at
your NPC count before you ship it — it is the measurement testRoom cannot give you, because a
fake clock has no wall time to spend.
See also
- testRoom: spawning and asserting on NPCs in a unit test.
- Simulated players: the same runtime, wearing its other hat.
- Scenarios: asserting about a room that has NPCs in it.
- matter.js rooms: the
matter-chaseexample pairs both features.