NPCs: thinking characters as data
An NPC type is one TypeScript file of data.
An NPC type is one TypeScript file of data. You describe what the character knows (facts), what it can be tuned by (props), and a short ordered list of rules. The engine compiles that once and runs it for thousands of characters at a time. No code of yours runs per character per tick. There are no behaviour trees and no blackboards.
What an NPC type is
- One file in
behaviors/, for examplebehaviors/guard.ts. One NPC type per file. - It ends with
export default npc({ ... }). - A scene attaches it with a
scriptcomponent. The component'spropsoverride the type's props for that one character:
{"type": "script", "module": "behaviors/guard", "props": {"walk": 1.0, "route": [[0,0,0],[10,0,0],[10,0,10]]}}
- A number prop takes a number. A route prop takes a list of
[x, y, z]points. An unknown prop name or a wrong type is an error that names the prop. - Every NPC with the same module is the same type and shares the same rules. They differ only by props and by their own fact values.
- There are no per-character hooks (
init,update). The file only describes data.
import { npc, t } from "@argus/gameplay"
How a character decides
Every time it thinks, an NPC reads what it senses, its facts and its props, then walks its rules in order. The first rule whose when is true wins. Its action becomes the NPC's current action, and its sets change facts. If no rule matches, otherwise runs. Put the most urgent rules first.
A rule can hold(ticks): once it wins, its decision stays for at least that many ticks, even if a higher rule would now win. Use it to stop flicker (a villager that keeps switching between fleeing and standing still).
Count time with hold and me.ticksSince, not with fact counters that you add to every tick.
Time, moments and memory
The world runs 60 ticks a second: hold(60) and wait(60) are one second, hold(300) five.
Senses are moments. sensed.seen is true only on the ticks a target is in view. An NPC that turns its back to run away stops seeing what it runs from at once. sensed.heard is true only on the one tick after a noise is made, because a noise does not last. So anything that should carry on after the moment needs a fact. Running until far enough away, going to where an alarm came from, searching where a target was last seen: remember the point when you sense it, then act on the fact until you are done.
<!-- ease:moments -->
facts: { alarm: t.point(), alarmed: t.bool(false) },
rules: ({ fact, sensed, me, act, rule }) => [
// the moment: remember where the noise came from (a point costs 3 writes, the flag 1)
rule("hear").when(sensed.heard).do(act.moveTo(sensed.noisePos, 4), { alarm: sensed.noisePos, alarmed: true }),
// afterwards: keep going until there
rule("go to alarm").when(fact.alarmed.and(me.pos.distXZ(fact.alarm).gt(1.5))).do(act.moveTo(fact.alarm, 4)),
rule("arrived").when(fact.alarmed).do(act.idle(), { alarmed: false }),
],
<!-- /ease:moments -->
The same shape works for fear: remember where the threat was (sensed.targetPos) and flee from the fact until me.pos.distXZ(fact.threat) is large enough, then let the next rule take the NPC home.
More precise rules:
- An NPC never hears its own noise. Every other NPC within
loudness * hearing.scalemetres hears it, whatever it is doing; whether it reacts is up to its rules (a rule abovehearwins first). - When a rule stops winning, the next winner's action takes over on that tick; nothing finishes first.
me.ticksSincecounts from the tick the current rule took over. It does not reset while the same rule keeps winning. On a rule's first tick it still belongs to the rule before it, so a rule that times itself ("stay here for 600 ticks") also testsme.doing(...):me.doing("idle").not().or(me.ticksSince.lt(600)).- A rule's
setsapply on the tick it wins, including forwait. - Go somewhere, then stay a while: give it two stages. A stay timed with
me.ticksSincecounts from the tick its rule took over, so if one rule both walks there and stays, the walk eats the stay (a 35 m walk at 1.5 m/s is about 1,400 ticks). Use an enum fact with a walking stage and a staying stage per place, and start the stay on arrival:ts rule("to the pier").when(fact.stage.is("toPier").and(me.pos.distXZ(prop.pier.at(0)).gt(1.5))).do(act.moveTo(prop.pier.at(0), 2)), rule("at the pier").when(fact.stage.is("toPier")).do(act.idle(), { stage: "loading" }), // arrived rule("loading").when(fact.stage.is("loading").and(me.doing("wait").not().or(me.ticksSince.lt(prop.loadTicks)))).do(act.wait(1)), rule("loaded").when(fact.stage.is("loading")).do(act.idle(), { stage: "toPier2" }),For a day keyed to a clock rather than to tick counts, use aschedule(see "Factions, shared facts, groups, schedules and events"). sensed.blockedlooks along the direction the NPC is trying to move (where its current action is taking it), about one step ahead.- To keep walking in a direction,
moveToa point ahead of yourself, such asme.pos.offset(fact.dir.mul(10), 0, 0). The point moves with you, so it is never reached. otherwiseis a function that gets the same names asrules, so it can use facts and props:otherwise: ({ fact, me, prop, act }) => act.moveTo(...).
The builder
npc({
name: string, // the type's name (shown in the decision log and in errors)
facts?: { [name]: FactType }, // per-character state, with defaults
props?: { [name]: PropType }, // tunables; the scene can override them per character
senses?: Senses, // what it perceives
movement?: Movement, // speed limits
rules: (api) => RuleB[], // priority order, first match wins (per action slot)
otherwise: Action | ((api) => Action | OtherwiseB), // when no rule matches
})
npc() returns an object { __npcIr, init() {} }. Do not touch it. The engine reads __npcIr, which is plain JSON ("npcIr": 1). The builder throws a plain Error for mistakes that the types cannot catch.
Facts
A fact is a typed field that belongs to one character and that rules can change. Declare them with t:
| Declaration | Holds | Read in a rule as |
|---|---|---|
t.num(0) | a number | number |
t.int(0) | a whole number, 0 or more (a counter or an index) | number |
t.bool(false) | true or false | boolean |
t.enum(["calm", "fleeing"], "calm") | one of the named values (default: the first) | enum: fact.mode.is("calm"), fact.mode.isNot("calm") |
t.entity() | another entity, or nothing | entity: fact.buddy.isNone(), isSome(), is(other) |
t.point([0, 0, 0]) | a position | point (see below) |
Props
A prop is a read-only tunable with a default. Rules read it. Only the scene changes it. Sense and movement settings (senses, movement) are numbers in the type, not props, so they are the same for every character of the type.
| Declaration | Read in a rule as |
|---|---|
t.num(1.5) | prop.speed (a number) |
t.route([[0,0,0],[5,0,0]]) | prop.path.at(i) (a point, the index wraps round) and prop.path.len (a number) |
Senses
What the NPC perceives. Numbers only (a sense value cannot be a prop).
senses: {
sight: { range: 15, fov: 120, eyeHeight: 1.6 }, // metres, degrees (full cone), metres
hearing: { scale: 1 }, // heard when distance <= loudness * scale
wallAhead: 0.6, // metres to probe for a wall; sets sensed.blocked
awareness: { sightRate: 0.1, hearBump: 0.3, decay: 0.01, suspiciousAt: 0.3, alertedAt: 0.8 },
targets: "players", // or "players+others"
maxRays: 1, // line-of-sight checks per tick
}
- Leave
sensesout and you get sight and hearing with the defaults above. - Write
sensesand only the senses you name are on.senses: { wallAhead: 0.6 }has no sight and no hearing. Usehearing: {}to turn hearing on with its defaults. - Every number inside is optional. The values shown are the defaults.
targets: "players+others"also lets the type see the NPCs of every OTHER NPC type (never its own type). Positions are the ones from before this tick, so the result does not depend on the order types run in. The default,"players", ignores NPCs entirely.- Deeper perception (memory, a list of seen entities, noise kinds, angle weighting, hysteresis) is opt-in: see Perception depth. A type that names none of it behaves exactly as before.
- Checks:
range> 0,0 < fov <= 360,scale,wallAhead,eyeHeight>= 0,suspiciousAt <= alertedAt(both 0 to 1),maxRaysa whole number.
Perception depth
Four opt-in extras on top of the basic senses. Each is off until the type's senses names it, costs nothing when off, and leaves the NPC's behaviour and replay hashes exactly as they were. A rule that reads memory.* or the seen list without the matching sense is a compile error that names the setting to add.
Awareness: angle and hysteresis
Awareness is a meter from 0 to 1. It builds while the NPC sees a target, faster the nearer the target is, and now (optionally) the more central it is in the cone. A noise bumps it, and it decays when nothing is sensed. Two states sit on top: suspicious (at suspiciousAt) and alerted (at alertedAt).
senses: {
sight: { range: 20, fov: 110 },
awareness: {
angleBias: 0.6, // 0 (default): the angle does not matter; 1: nothing is gained at the very edge of the cone
suspiciousExit: 0.15, // once suspicious, stay so until awareness falls below this (default = suspiciousAt)
alertedExit: 0.5, // once alerted, stay so until it falls below this (default = alertedAt)
},
}
// rules: sensed.alerted, sensed.suspicious, sensed.state.is("alerted"), sensed.awareness
rule("search").when(sensed.state.is("alerted").and(sensed.seen.not())).do(...)
Entering a state always takes the *At value; the *Exit values only keep it. That stops a guard flickering between alert and calm while the player hides at the edge of its sight. Checks: each exit is at most its *At, and suspiciousExit <= alertedExit.
Memory: where it last saw someone
sensed.seen and sensed.heard are moments. With memory the engine also keeps the last place the target was seen and the last noise for forget ticks (60 a second), with no facts to write:
senses: { sight: {}, hearing: {}, memory: { forget: 600 } }, // ten seconds
rules: ({ me, memory, prop, act, rule }) => [
rule("search where last seen")
.when(memory.has.and(me.pos.distXZ(memory.lastSeenPos).gt(1.5)))
.do(act.moveTo(memory.lastSeenPos, prop.walk)),
rule("investigate the noise").when(memory.lastNoiseAgo.lt(300)).do(act.moveTo(memory.lastNoisePos, 2)),
],
memory.has: a sighting is remembered.memory.lastSeenPos(point) andmemory.lastSeenAgo(ticks; 0 while the target is in view, 1e9 when nothing is remembered).memory.lastNoisePos,memory.lastNoiseAgo,memory.lastNoiseKindare the same for the last noise heard.- The sighting and the noise each expire
forgetticks after they happened (forgetis a whole number above 0). - The remembered position is the target's last seen position, so a target that moved on is not tracked.
Noise kinds
A noise is one of "noise", "footstep", "gunshot", "alarm", "damage". An NPC makes one with act.emitNoise(30, "alarm"); the engine can also raise one from outside (NpcSim::raise_stimulus). Hearing scales the loudness per kind (1 unless named, 0 is deaf to that kind), and a rule can tell what it heard:
senses: { hearing: { scale: 1, kinds: { footstep: 0.4, gunshot: 2, damage: 0 } } },
rule("check the shot").when(sensed.heard.and(sensed.heardKind.is("gunshot"))).do(act.moveTo(sensed.noisePos, 4)),
With these numbers a footstep of loudness 10 is heard within 4 m, a gunshot of loudness 10 within 20 m, and damage never. heardKind is the kind of the loudest noise heard this tick (loudness after scaling).
A list of seen entities
By default an NPC sees one target, the nearest. seenMax: N (1 to 8) tracks up to N at once, so a guard with several players in view can pick one:
senses: { sight: { range: 22 }, seenMax: 4, maxRays: 4 },
rule("chase the most aware").when(sensed.seenCount.gt(0)).do(act.moveTo(sensed.mostAware, 4)),
rule("shout for backup").when(sensed.seenCount.gt(1)).do(act.emitNoise(30, "alarm")),
sensed.seenCount: how many are in view.sensed.nearest/nearestDist: the nearest one in view.sensed.mostAware/mostAwareLevel: the tracked entity with the highest awareness, which can be one that just left view.sensed.seenAt(i)gives entryias.entity,.awarenessand.dist. Entry 0 is the most salient (close and central count most; equal salience goes to the lower entity id), a total order, so it does not depend on the thread count.- Each entry has its own awareness: it builds while that entity is in view and decays at the type's
decayonce it is not, and the entry is dropped when it reaches 0.sensed.awarenessstays the overall meter. sensed.targetis entry 0 when a list is on, andmemory.lastSeenPosfollows it.- Line of sight is tested with
maxRaysrays a tick. With fewer rays than candidates the rays rotate over them, so each is checked everycandidates / maxRaysticks and one seen last tick is kept until then. SetmaxRaystoseenMaxfor an exact list every tick. - Entities past the 16 nearest candidates are not considered.
Movement
movement: { speed: 3.5, accel: 8, turnRate: 360, radius: 0.35 } // m/s, m/s^2, degrees/s, metres
All optional, plus controller (see Controllers). speed is the most the NPC can ever move, whatever speed an action asks for: an action asking for more is quietly capped, so set speed at least as high as your fastest action.
Movement animations. Name the animator states the NPC should be in while it moves:
movement: { speed: 4, anims: { idle: "stand", walk: "walk", run: "run", runAbove: 2.5 } }
Every action except playAnim and custom picks one by the speed it asks for: run from runAbove m/s (default 0.6 of speed), walk while moving slower, idle when still. A state you leave out falls back to the next slower one (no idle and standing still: nothing is set). The entity needs an animator component with states of those names; the engine switches state when the pick changes, and a script that set the animator itself that tick wins. Leave anims out and movement sets no animation (as before).
Animator state names used by playAnim("name") and movement.anims need no declaration. Only numeric playAnim(n) needs anims: ["salute", "wave"] on the type (anims sits next to movement).
Rules
rules: ({ fact, prop, sensed, me, act, rule }) => [
rule("flee") // a unique name; it appears in the decision log
.when(sensed.seen) // a boolean expression
.do(act.fleeFrom(...), { mode: "fleeing" }) // an action, and optional fact writes
.hold(30), // optional: keep this decision at least 30 ticks
]
.when(true)is a catch-all: put it last for "otherwise in this slot".sets(the second argument ofdo) writes facts after the rule is chosen. Writes read the facts as they were before this tick.- A set value is an expression of the fact's type. An enum fact takes a plain string:
{ mode: "calm" }. A point fact takes a point expression or an[x, y, z]list. - At most 4 fact columns per rule. A point counts as 3.
- After
.do(...)a rule can chain.hold(ticks),.slot("aim" | "upper" | "voice"),.priority(n)and.cooldown(ticks)(see Action slots). otherwise: ({ act }) => act.idle(). To write facts there too:otherwise: ({ act, otherwise }) => otherwise(act.idle(), { mode: "calm" }).
Expressions
TypeScript has no operator overloading, so expressions are values with methods. Plain numbers and booleans are accepted wherever an expression is.
| On | Methods |
|---|---|
| number | add sub mul div min max (number); neg() abs() clamp(lo, hi) (number); lt le gt ge eq ne (boolean) |
| boolean | and(b) or(b) not() eq(b) |
| point | .x .y .z (numbers); distTo(p) (3D distance); distXZ(p) (ignores height); offset(dx, dy?, dz?) (a new point) |
| entity | isNone() isSome() is(other) |
| enum | is("value") isNot("value") |
Division by zero gives 0. Not-a-number never gets stored (it becomes 0).
Free functions in the callback: point(x, y, z), none (no entity), random() (0 up to 1, different for each character and each tick, but always the same on a replay), min(a, b), max(a, b), abs(a), clamp(a, lo, hi), dist(p, q), distXZ(p, q), not(b), and(...), or(...), select(cond, a, b) (a if cond, else b; both are always computed).
What a rule can read
| Read | Type | Meaning |
|---|---|---|
fact.<name> | by declaration | this character's fact |
prop.<name> | number or route | this character's prop |
sensed.seen | boolean | a target is in view this tick |
sensed.target | entity | the most noticeable target in view (none if unseen) |
sensed.targetPos | point | where it is |
sensed.targetDist | number | distance to it, 1e9 if unseen. It is a 3D distance to the target's chest, 1 m above its position, so use me.pos.distXZ(sensed.targetPos) to judge touching |
sensed.awareness | number | 0 (unaware) to 1 (alerted) |
sensed.suspicious | boolean | awareness reached suspiciousAt (true when alerted too; see hysteresis) |
sensed.alerted | boolean | awareness reached alertedAt |
sensed.state | enum | sensed.state.is("unaware"), "suspicious" or "alerted" |
sensed.heard | boolean | a noise was heard this tick (noises last one tick; remember them in a fact) |
sensed.heardCount | number | how many |
sensed.heardKind | enum | what the loudest noise was: .is("noise"), "footstep", "gunshot", "alarm", "damage" |
sensed.noisePos | point | where the loudest came from |
sensed.noiseDist | number | distance to it, 1e9 if none |
sensed.blocked | boolean | a wall or a ledge is just ahead in the direction it is trying to move (needs senses.wallAhead). A drop counts: a cell with no walkable ground is blocked, so a character walking a platform turns at its edges with no extra sense |
sensed.seenCount, sensed.nearest, sensed.nearestDist, sensed.mostAware, sensed.mostAwareLevel, sensed.seenAt(i) | the list of seen entities (needs senses.seenMax) | |
memory.has, memory.lastSeenPos, memory.lastSeenAgo, memory.lastNoisePos, memory.lastNoiseAgo, memory.lastNoiseKind | what it still remembers (needs senses.memory) | |
me.pos | point | its position |
me.home | point | where it was placed (its spawn position; never changes) |
me.yaw | number | its heading, radians (0 faces +Z) |
me.id | number | its entity number |
me.ticksSince | number | ticks since the current rule took over (60 a second) |
me.doing("moveTo") | boolean | is its current action this one? (idle moveTo face wait fleeFrom attack findPoint playAnim patrol emitNoise custom drive) |
me.doingIn("aim", "face") | boolean | the same for another slot (aim, upper, voice; see Action slots) |
random() | number | see above |
sensed.targetPos and sensed.noisePos are only meaningful when seen or heard is true. Guard the rule with that.
Actions
Pass one to .do(...). Speeds are in metres per second. Movement happens on the ground plane. "Target" means a point expression or an entity expression (such as sensed.target, or a player).
| Action | What it does |
|---|---|
act.idle() | Stand still. |
act.moveTo(target, speed) | Walk to the target at speed. Stops on arrival. An entity target is followed as it moves (round obstacles, by a flow field); a point is walked to round obstacles by a flow field too, and in a straight line when nothing blocks it. A point farther than the field's reach (1.5x sight, at least 20 m) is walked straight until the NPC is within reach; when many NPCs want different points, a few fields are built per tick and the rest walk straight meanwhile. |
act.face(target) | Turn to look at the target. Stand still. |
act.wait(ticks) | Stand still. A constant number of ticks also holds the rule that long. |
act.fleeFrom(target, distance, speed) | Run directly away until distance metres from the target. Then stand. Flee from a remembered point fact if it must go on after the target is out of sight. |
act.attack(entity, range, cooldownTicks) | Face the entity. When it is within range and the cooldown boundary comes round (every cooldownTicks ticks while this rule keeps winning), the engine emits an npc.attack event. It does not walk: win a moveTo rule when the target is far and this rule when it is near. |
act.findPoint.randomAround(center, radius, into) | Pick a random point within radius of center and write it to the point fact into (pass fact.<name>). Costs 3 of the 4 writes. The NPC stands still this tick. |
act.findPoint.nextWaypoint(route, index, into) | Write waypoint number index of the route prop to the point fact into, and advance the int fact index (wrapping). Costs all 4 writes. |
act.playAnim(state) | Stand still and put the entity's animator in state state (a name, e.g. act.playAnim("salute")). A number also works: it picks the n-th name of the anims list (1 is the first; numbers outside the list do nothing). The entity needs an animator component with a state of that name. |
act.patrol(route, index, speed) | Walk the route prop in a loop. The int fact index remembers which waypoint is next. An empty route means idle. Each leg goes round obstacles like act.moveTo a point does (straight when the line to the waypoint is clear). |
act.emitNoise(loudness, kind?) | Make a noise once, on the tick the rule takes over; it does not repeat while the same rule keeps winning (it sounds again only after another rule has won in between). loudness is in metres at hearing.scale 1: emitNoise(30) reaches NPCs within 30 m. A number or a number prop. Anything that can hear it notices it next tick as sensed.heard / sensed.noisePos. kind says what it is ("noise" by default, or "footstep", "gunshot", "alarm", "damage"); a hearer can scale each kind. Also emits an npc.noise event. |
act.custom(name, arg?) | The slow path: every tick this rule wins, the engine emits an npc.custom event with name and the number. See Events and Limits. |
act.drive(speed, opts?) | Hand the move slot to the type's movement.controller, which steers every tick at up to speed (a cap: the controller may go slower, e.g. in bends or when arriving). opts: route and index (follow-path), to (crowd goal; optional for flock). See Controllers. |
Arguments that must be a fact (into, index) have to be written as fact.<name>, not a computed value.
Action slots, priority and cooldowns
An NPC can do several things at once: walk, look at something, swing, shout. A rule fills one of four slots:
| Slot | What it is | Actions it takes |
|---|---|---|
move (default) | the legs: where it goes | every action (so an old type is unchanged) |
aim | where the body points | face, idle |
upper | the upper body: attacks, animation | attack, playAnim, custom, idle |
voice | sound | emitNoise, custom, idle |
Each slot has one action at a time and picks it the same way: the first rule of that slot, in list order, whose when is true. A rule says which slot it fills with .slot("aim"). The slots do not compete: in the same tick the move slot's winner and the aim slot's winner both act. A slot with no matching rule is idle (only move has an otherwise). aim wins over move for the heading: the NPC walks one way and looks another. The decision log records the slot of each change.
.priority(n): a held rule (hold) is normally never interrupted. A matching rule of higher priority in the same slot interrupts it (priority is a whole number, default 0; list order still decides among rules of equal priority)..cooldown(ticks): after the rule takes over, it cannot take over again forticksticks (60 a second). A rule that keeps winning is not stopped by its own cooldown; it limits how often it can start over, so a shout does not fire every time the target steps in and out of view.- Fact writes from different slots all apply, move first, then aim, upper, voice; a later slot's write to the same fact wins.
// A rule in another slot reads like any other. `me.doingIn("aim", "face")` is true while the aim slot is facing.
rule("shout").when(sensed.seen).do(act.emitNoise(30)).slot("voice").cooldown(300)
A type that uses only the move slot pays nothing for any of this and behaves exactly as before.
<!-- ease:gunner --> Worked example, the gunner: patrols, keeps its eyes on the intruder, fires, and calls out at most every five seconds.
import { npc, t } from "@argus/gameplay"
/** Moves, aims, shoots and shouts independently: walks a route, keeps its eyes on the intruder, fires every half second, calls out at most every 5 s. */
export default npc({
name: "gunner",
facts: { leg: t.int() },
props: { route: t.route(), walk: t.num(1.5) },
rules: ({ fact, prop, sensed, act, rule }) => [
rule("fire").when(sensed.seen.and(sensed.targetDist.lt(12))).do(act.attack(sensed.target, 12, 30)).slot("upper"),
rule("shout").when(sensed.seen).do(act.emitNoise(30)).slot("voice").cooldown(300),
rule("aim").when(sensed.seen).do(act.face(sensed.target)).slot("aim"),
rule("patrol").when(prop.route.len.gt(0)).do(act.patrol(prop.route, fact.leg, prop.walk)),
],
senses: { sight: { range: 15, fov: 120 } },
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:gunner -->
Controllers: racing, flocking, crowds
Some movement is not a list of discrete actions but continuous control every tick: a car following a road, a bird in a flock, a shopper in a crowd. A type picks one controller in movement, and a rule hands the move slot to it with act.drive(speed, ...). The controller steers every tick (the rules still think on their own schedule); any other rule that wins takes over (flee, attack, stand still), and a drive rule wins it back. Everything is deterministic and allocation-free; flock and crowd look only at the 8 nearest neighbours at most.
All parameters are numbers in the type (not props), every one optional; the defaults are shown. An unknown parameter is a compile error that lists the right ones.
follow-path (racing, trains, patrol cars): follows a route prop. It aims at a point lookahead + lookaheadGain * speed metres ahead of the nearest point on the route, and holds the speed that lateral grip allows in the bends ahead (sqrt(grip / curvature), braking early, never below minSpeed). act.drive(speed, { route, index }): index is an int fact that remembers which segment it is on, so a route that crosses itself works. The route closes into a loop unless loop: false (then it slows to a stop over arrive metres at the end). If it is knocked more than recover metres off, it finds its place again.
{ kind: "follow-path", lookahead: 6, lookaheadGain: 0.3, grip: 12, minSpeed: 1.5, loop: true, recover: 20, window: 6, arrive: 2 }
<!-- ease:racer -->
import { npc, t } from "@argus/gameplay"
/** A racer: follows a looped track prop at a speed set by the bends, in a pack. */
export default npc({
name: "racer",
facts: { seg: t.int() },
props: { track: t.route(), pace: t.num(14) },
rules: ({ fact, prop, act, rule }) => [rule("race").when(prop.track.len.gt(1)).do(act.drive(prop.pace, { route: prop.track, index: fact.seg }))],
movement: { speed: 16, accel: 10, turnRate: 540, controller: { kind: "follow-path", lookahead: 6, grip: 14 } },
senses: {},
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:racer -->
flock (birds, fish, herds): separation, alignment, cohesion, optionally leaning toward a goal. act.drive(speed) or act.drive(speed, { to: point }). With no to, the flock keeps the heading it has and turns only to follow its neighbours. With to, the goal is one more pull, weighted by goal: separation, alignment and cohesion still act, so a goal point does not reliably steer a flock round something. To keep a flock away from an entity (a predator, a player), put a rule above the drive rule that wins when it is near, and flee:
rule("scatter").when(sensed.seen.and(sensed.targetDist.lt(6))).do(act.fleeFrom(sensed.target, 8, 7)),
rule("flock").when(true).do(act.drive(prop.cruise)),
{ kind: "flock", neighbourRadius: 6, maxNeighbours: 8, separationRadius: 1.6, separation: 1.5, alignment: 1, cohesion: 1, goal: 0.6, arrive: 3 }
<!-- ease:flocker -->
import { npc, t } from "@argus/gameplay"
/** A bird in a flock: stays with its neighbours, and runs from anything it sees. */
export default npc({
name: "flocker",
props: { cruise: t.num(3), panic: t.num(5) },
rules: ({ prop, sensed, act, rule }) => [
rule("scatter").when(sensed.seen).do(act.fleeFrom(sensed.targetPos, 15, prop.panic)),
rule("flock").when(true).do(act.drive(prop.cruise)),
],
movement: { speed: 5, controller: { kind: "flock", neighbourRadius: 6, cohesion: 1, alignment: 1, separation: 1.5 } },
senses: { sight: { range: 12 } },
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:flocker -->
crowd (pedestrians, queues, evacuations): goal seeking with avoidance. Each neighbour is predicted horizon seconds ahead; a pass closer than separationRadius is steered around, and two walkers meeting head on pick opposite sides. act.drive(speed, { to: point }) (the goal is required).
{ kind: "crowd", neighbourRadius: 6, maxNeighbours: 8, separationRadius: 1.6, avoid: 2, horizon: 1.5, arrive: 1 }
<!-- ease:pedestrian -->
import { npc, t } from "@argus/gameplay"
/** A pedestrian: walks to a goal point (a prop) and steers around other walkers on the way. */
export default npc({
name: "pedestrian",
props: { goal: t.route(), pace: t.num(1.4) },
rules: ({ prop, act, rule }) => [rule("walk").when(prop.goal.len.gt(0)).do(act.drive(prop.pace, { to: prop.goal.at(0) }))],
movement: { speed: 2, controller: { kind: "crowd", separationRadius: 1.4, avoid: 2 } },
senses: {},
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:pedestrian -->
drive without a movement.controller is a compile error. One controller per type; a rule can still switch to any other action. Do not hold a follow-path rule for long (the segment fact is updated when the NPC thinks).
Bosses: phases and a timeline
A boss fight is rules and two facts; there is no boss concept in the engine. api.boss({ phases }) returns ordinary rules you spread into your list (put it after the rules that should always win, such as "dead"), and bossFacts([...phase names]) gives the two facts it needs. Phases are tried in order: the first whose when is true is the current one (leave when out on the last as the fallback), so health thresholds are just conditions. Inside a phase, a timeline of step(name, ticks, action, sets?) runs in order and loops: telegraph, attack, recover. A step lasts exactly ticks ticks. Entering a phase starts its timeline at step 0. Rule names are "<phase>: <step>" in the decision log.
<!-- ease:warden -->
import { bossFacts, npc, step, t } from "@argus/gameplay"
/** A boss in two phases: calm above half health, enraged below. Each phase loops telegraph, attack, recover. */
export default npc({
name: "warden",
facts: { hp: t.num(100), ...bossFacts(["calm", "enraged"]) },
rules: ({ fact, sensed, act, rule, boss }) => [
rule("dead").when(fact.hp.le(0)).do(act.idle()),
...boss({
phases: [
{
name: "calm",
when: fact.hp.gt(50),
timeline: [
step("telegraph", 60, act.face(sensed.targetPos)),
step("attack", 20, act.attack(sensed.target, 3, 10)),
step("recover", 90, act.idle()),
],
},
{
name: "enraged",
timeline: [step("telegraph", 30, act.face(sensed.targetPos)), step("attack", 40, act.attack(sensed.target, 4, 8)), step("recover", 30, act.idle())],
},
],
}),
],
senses: { sight: { range: 25, fov: 360 } },
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:warden -->
The timeline clock is fact.bossClock (ticks into the loop) and the phase is fact.bossPhase, if another rule needs them. A step's sets are written every tick that step runs: at most 2 fact columns for the first step, 3 for the others.
Position queries: cover, flank, flee, search, ambush
A query answers "where" with a point. One query per kind of place, written as data: generate candidate points, drop the ones that fail the filters, add up the score terms, and the best one is the answer. Rules read the answer as a point, query.cover, anywhere a point goes (act.moveTo, sets, act.findPoint.fromQuery). Reading query.x in a rule's action asks the query while that rule wins; .asks(query.x) asks without reading it.
The engine answers asks in batches, in a fixed order (oldest request first, then query, then slot), up to queryBudget per tick for the whole type (default 32; a count, never a time). So an answer can arrive a tick or more after the ask. Test query.x.found (has an answer), query.x.pending (awaited) or query.x.age (ticks since). Until it is found, query.x reads as the NPC's own position.
Parts: generators q.ring(r, count), q.around(r0, r1, count), q.grid(half, step), q.arc({ from, to, r0, r1, count, mirror }); filters q.clear(), q.reachable(metres), q.hidden(of), q.visible(of), q.range(min, max, of), q.unreserved(); scores q.near, q.far, q.dist(of, metres), q.path(), q.jitter(). of is "self", "target" (the query's from, default sensed.targetPos) or "center". hidden/visible cast rays against the same static world as sight, at most rays (default 16) per answer, tried best score first.
Reservation. reserve: { radius, ttl } holds the answer for ttl ticks. With q.unreserved() in the filter, no other NPC of the type is given a point within radius of a held one. Contested spots go to the earlier grant (slot order). A reservation is released when the NPC stops asking, when it expires, or when the NPC is removed.
queries: ({ memory, sensed }) => ({
cover: { // take cover: hidden from the threat, near, unclaimed
from: sensed.targetPos,
gen: q.ring(12, 16),
filter: [q.reachable(30), q.hidden("target"), q.unreserved()],
score: [q.path()],
reserve: { radius: 1.5, ttl: 300 },
},
flank: { // to the side of the threat, 60 to 100 degrees off its facing
center: sensed.targetPos,
gen: q.arc({ from: 60, to: 100, r0: 9, r1: 9, count: 8, mirror: true }),
filter: [q.reachable(40)],
score: [q.path()],
},
flee: { // the farthest reachable point from the threat
from: sensed.targetPos,
gen: q.ring(14, 16),
filter: [q.reachable(30)],
score: [q.far("target")],
},
ambush: { // out of sight, about 9 m from the target
center: sensed.targetPos,
gen: q.around(8, 14, 24),
filter: [q.reachable(40), q.hidden("target")],
score: [q.dist("target", 9)],
},
search: { // spread searchers around where it was last seen
center: memory.lastSeenPos,
gen: q.around(0, 10, 12),
filter: [q.reachable(25), q.unreserved()],
score: [q.jitter()],
reserve: { radius: 3, ttl: 600 },
},
}),
rules: ({ prop, sensed, memory, query, act, rule }) => [
rule("take cover").when(sensed.seen).do(act.moveTo(query.cover, 5)),
rule("search").when(memory.has).do(act.moveTo(query.search, 2)),
],
center can be any point expression. A full example is packages/gameplay/src/npc/examples/tactician.ts. Other options: facing (heading for arc), maxAge (re-ask after this many ticks, 120), retry (re-ask after finding nothing, 30), eyeHeight, height. A type with no queries carries no query state and its hashes do not change.
Factions, shared facts, groups, schedules and events
Everything here is optional. A type that declares none of it runs, hashes and costs exactly as before.
export default npc({
name: "sentry",
factions: {
names: ["guard", "bandit", "town"], default: "town", player: "town",
relations: [{ a: "guard", b: "bandit", v: -1 }, { a: "guard", b: "town", v: 1 }], // negative hostile, 0 neutral, positive friendly
},
facts: { alarm: t.faction(t.num(0)), kills: t.world(t.int(0)) },
props: { post: t.place([0, 0, 0]), tavern: t.place([10, 0, 5]) },
senses: { sight: { range: 20 }, only: "hostile" }, // perception filters targets by relation
schedule: { dayTicks: 2400, startHour: 8, entries: [{ from: 6, state: "work", at: "post" }, { from: 18, state: "relax", at: "tavern" }, { from: 23, state: "sleep" }] },
group: ({ group }) => ({
orders: ["patrol", "left", "right"],
rules: [{ name: "engage", when: group.seeing.gt(0), orders: ["left", "right"], by: "rank", hold: 20 }],
otherwise: { orders: ["patrol"] },
}),
rules: ({ act, rule, event, group, schedule, merge, isHostile, sensed }) => [
rule("hurt").when(event.damaged).do(act.face(event.damagedBy), { alarm: merge.max(1), kills: merge.add(event.damage) }),
rule("flank").when(group.order.is("left").and(isHostile(sensed.target))).do(act.moveTo(group.hostilePos, 4)),
rule("work").when(schedule.now.is("work")).do(act.moveTo(schedule.at, 1.5)),
],
otherwise: ({ act }) => act.idle(),
})
Scene props. A type with factions takes the scene prop faction (a name from factions.names, or its index); a type with a group block takes group (any whole number: NPCs of the type with the same number form a group) and role (a whole number, default 0). They are reserved: do not declare props with these names.
Shared facts (t.faction(..), t.world(..)). One value per faction, or one for the world, instead of one per character. Every character reads the value as of the start of the tick. A rule changes it with merge.set/add/max/min; the writes of all characters are collected and merged between ticks in slot order, so a tick's result never depends on thread timing (set: the last writer in slot order wins; add, max, min are order-free except for float rounding of add, which is also in slot order). A plain value means set. Shared facts are numbers (a bool, int or enum is stored as a number); points and entities cannot be shared. Own facts take plain values only.
Factions and relations. relation(entity) is the number between this character's faction and the entity's (players are in factions.player; NPCs of other types are matched by faction name; unknown = 0). isHostile(e) is relation < 0, isFriendly(e) is > 0. sensed.targetRelation, sensed.hostile, sensed.friendly and me.faction / me.role read the same. A faction is friendly to itself unless a relation says otherwise. senses.only: "hostile" | "friendly" | "not-friendly" makes perception skip the other targets, so a nearer friendly does not hide a hostile. At most 32 factions.
Groups. The same rule contract one level up. Once per tick, per group, in ascending group-id order, before the characters think, the group rules run on the group's aggregates and the first match picks what the members do (hold keeps a pick at least that many ticks). Group rule expressions may read group.size, group.seeing (members that see a hostile), group.hostileDist, group.hostilePos (the seen hostile nearest to the centroid), group.centroid and the shared facts, not a character's own facts. A rule gives its members orders (names, at least one) dealt out by by: "rank" (their place in the group, the default) or by: "role" (their role prop), cycling. Members read the result as group.order (compare with .is("name")) and group.rank. The group's faction is its first member's. At most 64 orders. A rule over a large group costs one pass over the members per tick, serial; thousands of groups are fine.
Schedules. schedule: { dayTicks, startHour, entries }. A world clock turns ticks into an hour of the day (0 to 24; dayTicks ticks per day, at least 24, default 86400, startHour the hour at tick 0). The entry with the latest from not after the hour is in force (before the first entry, the last of the day). schedule.now is that entry's state (compare with .is("work")), schedule.at its place (a t.place prop, else the character's home) and schedule.hour the hour. Scripts can move the clock with npc.clock (below).
Events. event.damaged, event.damage, event.damagedBy, event.talked, event.talkedBy, event.witnessed, event.witnessedWho, event.witnessedPos are readable by the target for exactly one tick, and wake a character that is between its think turns (think_every), so it reacts on the tick the event is consumed. Two damage events before one tick sum their damage and keep the later source; a second talk or witness replaces the first.
What scripts call. All go through ctx.events.emit, and take effect on the next tick, in call order. An entity is its scene id (or an entity index). Bad data is ignored.
ctx.events.emit("npc.damaged", { npc: "guard1", by: "player", amount: 4 }) // onDamaged
ctx.events.emit("npc.talk", { npc: "guard1", by: "player" }) // onTalk
ctx.events.emit("npc.witnessed", { npc: "guard2", who: "player", pos: [1, 0, 2] }) // onWitnessed
ctx.events.emit("npc.reputation", { fact: "alarm", faction: "guard", op: "add", value: 5 }) // op: set (default), add, max, min
ctx.events.emit("npc.reputation", { fact: "alarm", faction: "guard", op: "get" }) // answered by npc.reputation.value
ctx.events.emit("npc.relation", { a: "guard", b: "town", value: -1 }) // change a relation (both ways)
ctx.events.emit("npc.clock", { hour: 12, dayTicks: 7200 }) // either field is optional
ctx.events.on("npc.reputation.value", (ev) => { /* ev.data: { fact, faction, value (null if unknown) } */ })
Reputation is a faction fact, so any rule can read fact.alarm for its own faction. npc.reputation and npc.relation apply to every NPC type that declares that fact or those faction names. Rust callers read the same values with World::npc_reputation(fact, faction). A get is answered after the tick that applied the changes queued with it.
Events your scripts receive
The engine turns three actions into game events. A script listens with ctx.events.on. ev.by is the id of the NPC entity that did it:
ctx.events.on("npc.attack", (ev) => { /* ev.data.target: the scene id of the entity that was attacked (null if it is gone); ev.by: the attacker */ })
ctx.events.on("npc.noise", (ev) => { /* ev.data.loudness, ev.data.pos: [x, y, z], ev.data.kind: "noise", "footstep", ...; ev.by: the maker */ })
ctx.events.on("npc.custom", (ev) => { /* ev.data.name, ev.data.arg: the number, ev.data.npc: the NPC's scene id, ev.data.target: the seen target's scene id or null; ev.by: the NPC */ })
Use npc.attack to apply damage. The NPC type itself never changes health.
A script can make a noise every NPC type hears on the next tick (an explosion, a thrown bottle, an alarm bell):
ctx.events.emit("npc.stimulus", { pos: [x, y, z], loudness: 30, kind: "alarm" }) // kind is optional, default "noise"
A bad pos, a negative loudness or an unknown kind is ignored.
<!-- ease:guard -->
Worked example: the guard
Patrols a route. Attacks an intruder who is close. Otherwise shouts once, then chases. Guards that hear the shout run to where it came from. When the intruder is gone, it stands down and goes back to patrolling.
import { npc, t } from "@argus/gameplay"
/** A guard on patrol that shouts when it sees an intruder; guards that hear it run to the noise. */
export default npc({
name: "guard",
facts: {
leg: t.int(),
shouted: t.bool(false),
alarm: t.point(),
alarmed: t.bool(false),
},
props: { route: t.route(), walk: t.num(1.5), run: t.num(4), attackRange: t.num(1.5) },
rules: ({ fact, prop, sensed, me, act, rule }) => [
rule("attack")
.when(sensed.seen.and(sensed.targetDist.lt(prop.attackRange)))
.do(act.attack(sensed.target, prop.attackRange, 30)),
rule("shout")
.when(sensed.seen.and(fact.shouted.not()))
.do(act.emitNoise(40), { shouted: true })
.hold(10),
rule("chase").when(sensed.seen).do(act.moveTo(sensed.target, prop.run), { alarmed: false }),
rule("hear alarm")
.when(sensed.heard)
.do(act.moveTo(sensed.noisePos, prop.run), { alarm: sensed.noisePos, alarmed: true }),
rule("go to alarm")
.when(fact.alarmed.and(me.pos.distXZ(fact.alarm).gt(1.5)))
.do(act.moveTo(fact.alarm, prop.run)),
rule("search done").when(fact.alarmed).do(act.idle(), { alarmed: false }),
rule("stand down").when(sensed.seen.not().and(fact.shouted)).do(act.idle(), { shouted: false }),
rule("patrol").when(prop.route.len.gt(0)).do(act.patrol(prop.route, fact.leg, prop.walk)),
],
senses: { sight: { range: 18, fov: 110 }, hearing: {} },
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:guard -->
<!-- ease:villager -->
Worked example: the villager
Flees when it sees a threat (holding the decision for 30 ticks), walks back to where it was placed (me.home), then calms down.
import { npc, t } from "@argus/gameplay"
/** Flees when it sees a threat, then walks home and calms down. */
export default npc({
name: "villager",
facts: {
mode: t.enum(["calm", "fleeing"]),
},
props: { runSpeed: t.num(4), walkSpeed: t.num(1.5), fearDistance: t.num(14) },
rules: ({ fact, prop, sensed, me, act, rule }) => [
rule("flee")
.when(sensed.seen)
.do(act.fleeFrom(sensed.targetPos, prop.fearDistance, prop.runSpeed), { mode: "fleeing" })
.hold(30),
rule("go home")
.when(fact.mode.is("fleeing").and(me.pos.distXZ(me.home).gt(1)))
.do(act.moveTo(me.home, prop.walkSpeed)),
rule("calm down").when(fact.mode.is("fleeing")).do(act.idle(), { mode: "calm" }),
],
senses: { sight: { range: 12 } },
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:villager -->
<!-- ease:walker -->
Worked example: the walker
A platformer enemy. Walks along x. When a wall is ahead it turns round.
import { npc, t } from "@argus/gameplay"
/** A platformer enemy: walks along x, turns around when blocked. */
export default npc({
name: "walker",
facts: { dir: t.num(1) },
props: { speed: t.num(2) },
rules: ({ fact, prop, sensed, me, act, rule }) => [
rule("turn around")
.when(sensed.blocked)
.do(act.moveTo(me.pos.offset(fact.dir.neg().mul(100), 0, 0), prop.speed), { dir: fact.dir.neg() })
.hold(20),
],
senses: { wallAhead: 0.6 },
otherwise: ({ fact, me, prop, act }) => act.moveTo(me.pos.offset(fact.dir.mul(100), 0, 0), prop.speed),
})
<!-- /ease:walker -->
<!-- ease:watcher -->
Worked example: the watcher
Uses the perception depth: it weighs what it sees by angle (with hysteresis on its alert state), tells a gunshot from other noises, shouts an alarm when it has several intruders in view, chases whoever it is most aware of, and searches where it last saw someone.
import { npc, t } from "@argus/gameplay"
/** A sentry with a long memory: weighs what it sees by angle, tells shots from footsteps, goes to where it last saw someone. */
export default npc({
name: "watcher",
facts: { shots: t.int() },
props: { walk: t.num(1.5), run: t.num(4) },
senses: {
sight: { range: 22, fov: 110 },
hearing: { kinds: { footstep: 0.4, gunshot: 2 } },
awareness: { angleBias: 0.6, suspiciousExit: 0.15, alertedExit: 0.5 },
memory: { forget: 600 },
seenMax: 4,
maxRays: 4,
},
rules: ({ fact, prop, sensed, memory, me, act, rule }) => [
rule("sound the alarm").when(sensed.alerted.and(sensed.seenCount.gt(1))).do(act.emitNoise(30, "alarm")).hold(120),
rule("chase the most aware").when(sensed.alerted.and(sensed.mostAware.isSome())).do(act.moveTo(sensed.mostAware, prop.run)),
rule("check the shot").when(sensed.heard.and(sensed.heardKind.is("gunshot"))).do(act.moveTo(sensed.noisePos, prop.run), { shots: fact.shots.add(1) }),
rule("search where last seen")
.when(memory.has.and(me.pos.distXZ(memory.lastSeenPos).gt(1.5)))
.do(act.moveTo(memory.lastSeenPos, prop.walk)),
rule("watch the suspect").when(sensed.suspicious.and(sensed.seen)).do(act.face(sensed.target)),
],
otherwise: ({ act }) => act.idle(),
})
<!-- /ease:watcher -->
<!-- ease:packs -->
Starter packs
Ready-made NPC types in packages/gameplay/src/npc/packs/. Copy one into behaviors/, change the props, or read it as a worked example.
platformer-enemy.ts: walks along x, turns at walls and ledges (a drop is blocked ground), hurts what it touches.patrol-guard.ts: walks a route, shouts once and chases an intruder, attacks up close, runs to a shout, goes back on patrol.stealth-guard.ts: suspicious before alerted, watches, raises an alarm, searches where it last saw the intruder, answers an alarm.racer.ts: follows a looped track with the follow-path controller, slower in the bends.flock.ts: a flock, herd or school: separation, alignment, cohesion, an optional goal, scatters from a threat.squad-soldier.ts: four slots at once (move, aim, upper, voice) and orders from its group (suppress, flank, hold): fires while aiming, flanks with a position query, takes reserved cover, searches. The scene gives squadmates the samegroupnumber.schedule-villager.ts: a timetable on the world clock (work, market, home), flees from a threat in between. The scene sets theworkplaceandmarketplaces.
<!-- /ease:packs -->
Snapshots and rollback
A world snapshot (GET /snapshot, World::snapshot_state) holds every NPC type's simulation, and a restore resumes it exactly: the same positions, decisions, facts, reservations and events as a run that never restored, at any thread count. It also holds what scripts asked of the NPCs since their last tick (npc.damaged, npc.stimulus and the rest), which the next tick applies. See docs/API.md, "NPC types in a snapshot". A snapshot from before this restores the NPCs as a fresh load starts them.
Common mistakes
The compiler runs when npc({...}) is evaluated (in node for your tests, in the engine when it loads the module), so a mistake throws right there: an Error whose message is every problem it found, joined by ; , each naming the rule and the field.
| Message (shortened) | What went wrong | Fix |
|---|---|---|
rule r field when: unknown fact x (declared: a, b) | A fact or prop name is misspelt or not declared. | Declare it in facts or props. |
field when: value: expected bool, got num | A when must be true or false, but you gave a number. | Compare it: fact.fear.gt(0.5). |
rule r field do.a: unknown action primitive x | The action name is not one of the list. | Use an act.<name> from the table. |
rule r field do.speed: moveTo needs speed | An action argument is missing. | Give every argument. |
rule r field sets: a rule can write at most 4 fact columns ... | Too many writes, counting a point as 3 and counting an action's own writes (findPoint, patrol). | Split the work across two rules. |
field sets.mode: bored is not a member of enum mode (calm, afraid) | The enum has no such value. | Use one of the listed values. |
field sets.flag: value: expected bool, got num | A set value has the wrong type. | Give an expression of the fact's type. |
eq cannot compare points | Points are never equal-tested. | Compare distTo(...) with a small number. |
prop path is a route: use .at(i) or .len | A route prop was used as a number. | prop.path.len, prop.path.at(0). |
symbol "x" has no meaning here | A string was used where it is not a name from an enum. | Only use strings to compare or set an enum fact. |
field senses.sight.fov: must be a number > 0 and <= 360 | A sense or movement number is out of range. | Fix the number. |
expression is too deep | One expression nests more than 48 values. | Store part of it in a fact. |
rule names must be non-empty and unique | Two rules share a name. | Rename one. |
rule r field slot: unknown slot legs (move, aim, upper, voice) | A misspelt slot. | Use one of the four. |
rule r field do.a: slot aim takes idle, face (got moveTo) | An action in a slot that cannot do it. | Put the rule in the move slot, or use an action the slot takes. |
rule r field do.a: drive needs movement.controller | drive with no controller declared. | Add movement: { controller: { kind: "flock" } } (or follow-path, crowd). |
field movement.controller.lookahead: lookahead is not a parameter of flock (...) | A parameter the controller does not have. | Use one from the list it prints. |
npc: sets: unknown fact zzz (thrown by the builder) | A sets key is not a declared fact. | Use a declared fact name. |
npc: number literal must be finite | NaN or infinity in a number. | Use a real number. |
Mistakes that do not give an error:
- A rule above the one you wanted always matches. Order matters. Put the narrow rule first, the broad one later.
senseswritten but sight left out. The NPC is blind. Addsight: {}.- Hold too long. A held rule ignores everything else until it ends.
attackdoes not move the NPC. Pair it with amoveTorule below it.- A rule in the
aimslot does not walk.facein the aim slot only turns the body; the move slot keeps moving. - A
priorityon the held rule itself. A hold is interrupted only by a rule of strictly higher priority; with every rule at the default 0 nothing interrupts a hold. drivewith a crowd controller and noto. The goal point is required.
Limits
- At most 32 factions and 64 group orders. Shared facts hold numbers. A group rule cannot read a character's own facts or senses.
- Four action slots; each takes only the actions listed under Action slots. One controller per type.
- One NPC type per module (one
export default npc(...)). - The static world (what blocks sight and movement) is rebuilt when a
meta.nav.livenavmesh rebuild is installed, so a wall that appears at runtime blocks sight afterwards. NPC state is kept. - At most 4 fact columns written per rule. A point is 3.
- At most 65534 rules, and an expression can hold 48 values at once.
- A custom action is the slow path. At most 64 run per tick across the whole world (in character order); the rest wait for the next tick. Use it only for something the primitives cannot do. Everything else stays on the fast path.
- Sense values are numbers, not props.
- The seen list holds at most 8 entities (
seenMax), chosen from the 16 nearest candidates in view range and cone. - The decision log keeps the last 16 changes per NPC.
- A query generates at most QUERY_CAP candidates, spends at most
raysline-of-sight rays per answer and is answered at mostqueryBudgettimes per tick per type.