Scripted NPCs
An NPC in irtio is not a special kind of entity. It is a client session 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 only 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.
That is the same runtime irtio simulate uses to drive bots. One runtime, two hats: a bot is a
player you are testing with, an NPC is a player you are shipping. Anything you can write in a
simulation script, you can write as a brain.
Declare the 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 whole feature. Everything below explains why it is shaped that way and what the sharp edges are.
Why the brain is a name and not a function
Your room code runs inside a sandbox with a fixed import list, on the authoritative side of the
server. The NPC’s session runs on the other side of that boundary, beside your room rather than
inside it, so that an NPC has to go 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 makes it move; writing one it does not own is ignored, as it is for a player.npc.room.tickis the last server tick this session saw.
And the loop helpers come from the bot runtime:
npc.wait(ms)sleeps, and returns immediately once the NPC has been asked to stop.npc.until(predicate, { label, timeoutMs })polls instead of guessing at a sleep.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, without the vector maths
@irtio/server exports the small set of pure functions a brain actually 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. “Flee if hurt, else chase if close, else patrol”
is what brains actually write, and the value of the helper is the name: you can log or publish
which behaviour is running without keeping a parallel string.
The handle
spawnNPC returns { clientId, despawn() }. The id is available immediately, so you can record it
the moment you spawn. The session 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 session; 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 sessions nor their frames count as activity. An NPC that pinned a machine awake forever would be a cost bug every NPC room shipped.
That means their sessions are not in the snapshot. The entities they owned are, like any state,
so the pattern is to 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 session with a new client id, which is why the old row has to go rather than be adopted: ownership follows the session.
Limits, errors and the things it will not do
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 exists so the shape is reserved, not because there is a second kind yet.- A brain that throws takes its own NPC down and logs; the room keeps running.
Telling an NPC from a player
Presence carries it: room.clients entries have an npc flag, so a room can read ctx.room.clients.find(c => c.clientId === ctx.clientId)?.npc in onJoin and record it in its own
state, which is what a client needs to draw them differently. The tenant also reports npcConnections beside connections, so a dashboard can tell twelve players from eleven hunters
and one player.
What one costs
An NPC is not free. On a 2026 laptop, a room of 32 NPCs at 20 Hz costs roughly half a millisecond of CPU per NPC per tick, counting both sides: the room paying for another client’s writes and another entity in every delta, and the host paying for a session and a script loop. That is a real number to budget against, not “free beyond room compute”. See the part 5 report for the measurement it came from. Eight NPCs in a small room is comfortable; a hundred is a design decision.
See also
- 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.