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.me is this NPC’s client id.
  • npc.room.state reads 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.tick is 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.stopped goes 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:

functionwhat 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

  • maxClients counts NPCs. A spawn into a full room is refused the way a player’s join is, with E_ROOM_FULL on the room’s log. Budget seats for your NPCs.
  • Validation applies. An NPC’s writes are judged by validate like anybody’s. If your validator clamps a stick to [-1, 1], your NPC’s stick is clamped too.
  • brain.kind accepts 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