Routines
routines() builds what an NPC does: a set of named routines, each a list of steps, and the events that start one. It compiles to a state machine: a routine is a compound state, each step a state inside it, and resume a deep history state. The machine declares tags: false, since a tag for each routine and step of every kind of NPC would slow the dump, so the routine system reads the step from the machine’s record. Unreal splits an NPC the same way, with StateTree deciding and the controller moving: the machine decides, and the routine system writes the NPC’s path as a click writes the player’s.
import { NpcEvent, route, routines } from "@spawnite/engine";
const market = route({ stall: [4, 0, -2], well: [8, 0, -6], herbs: [3, 0, -9],});const bench = route({ bench: [2, 0, -3] });
const mira = routines("mira") .routine("morning", (r) => r .walk(market, { pause: { well: 4 } }) .wait(3) .then("rest"), ) .routine("rest", (r) => r .walk(bench) .wait({ between: [20, 40] }) .say("*yawns*") .then("morning"), ) .routine("greet", (r) => r.face().play("jump").say("Hello!").resume()) .on(NpcEvent.Approached, "greet") .start("morning");Its name
Section titled “Its name”routines("mira") names the routines, and the machine is named for the name, miraRoutines: its trait dumps, streams and checkpoints under it. toMachineId(name, kind) makes that id, for a kit that builds machines of its own kind. Every NPC handed one build runs one machine, so a crowd of guards shares routines("guard") by intent, and two NPCs with one name over their heads and routines of their own each keep their own machine. A second build under a name, as a hot reload makes, takes the name over, so give each set of routines a name of its own. A name holds at least one letter or digit, and two names make two machines only when their letters and digits differ: routines("north-guard") after routines("north guard") throws, naming both. Unity’s Animator Controller, Unreal’s Behavior Tree and Godot’s resources work the same way: the named asset holds the logic, and every character that references it runs it.
walk(route, { repeat, yoyo, pause }): walks the route once, and the step ends at its last point, so the routine goes on to its next step.repeatis how many more passes follow the first, 0 by default;Infinitywalks the route until an event starts another routine.yoyowalks every second pass back down the points, so{ repeat: 1, yoyo: true }walks out and back.pauseis seconds at every point, or, on aroute(), seconds at each point by its name.wait(seconds)orwait({ between: [min, max] }): counted by the fixed step, as any machine’s wait. A roll between two numbers is drawn from the world’s seeded random each time the wait begins, so two NPCs from one set of routines wait different lengths, and a replay waits as the run did. The roll sits on the machine’s trait, so a checkpoint keeps it and a wait an event interrupts resumes with it.play(clip): a clip the avatar has, which the view crossfades to for two seconds and back, as it crossfades a change of pose. The avatars play their poses’ clips:idle,run,turnLeft,turnRight,jump,runJumpandland. A clip it lacks is skipped, and a dev world warns once naming it. An NPC that wears a model plays the model’s clip for the pose.say(line): a line right over its name for four seconds; the routine goes on at once. The line wraps when long and fades as the camera draws away. For a conversation with one player, give the NPC a Dialog.turn(degrees)andface(): a turn on the spot, left for a positive number, or a turn toward the nearest player’s character, the one who came close or pressed the prompt.then(name),resume(), or nothing: go to a routine, go back to the routine an event interrupted at the step it was on, or repeat this one. A routine an event starts that names neither ends inresume().resume()fits only a routine an event starts, andstartrefuses it on any other, naming the routine.
A walk takes the shape of Phaser’s PathFollower and GSAP’s tweens, repeat: 0 with yoyo, because a walk is one step in a sequence: it ends so that the next step can run. Unreal’s and Roblox’s MoveTo also move once and report arrival.
Routes
Section titled “Routes”A walk takes a route in one of three forms:
route({ stall: [3, 0, -2], well: [8, 0, -6] })names its points in the order the walk visits them, so apausecan name the well. A point is[x, y, z], or[x, z]standing on the ground. Name each point with a word: an object puts a number-like key first, soroute()refuses one."meadowToRollingField", the name of a path the map holds under itspaths, walked point to point with the ground giving each point’s height. Its points have no names, sopausetakes seconds for every point.- A list of points,
[[4, -2], [8, -6]], withpausein seconds for every point.
The types keep a pause by name to a route() and its names. A map path the map does not hold, or a paused point the route does not name, skips the walk, and a dev world warns once naming the NPC, the routine and the name.
Events
Section titled “Events”on(event, name) on the builder starts the routine name on the event from any routine an event did not start. Inside a routine it fires only there. NpcEvent.Approached fires when a player’s character comes within the NPC’s Interact radius, NpcEvent.Interacted when one presses its prompt, and NpcEvent.Stuck when a walk gives up on a point, as the fences below describe. An event routine takes no event, so a second event while it runs is dropped, and Approached fires again once the character leaves and comes back. A walk an event interrupts stops where the NPC stands and keeps its place on the route for a resume, a clip an event interrupts gives way to the pose, and a wait keeps the seconds it had counted.
While a player holds a conversation with the NPC, the NPC holds the TalkingTrait trait and its routine holds: the NPC stops, faces the player, and takes no event, while every other machine on it runs on. Once its last conversation ends, the trait comes off and the routine goes on at the step it was on: a walk heads back for the point it was walking to, and a wait keeps its seconds. A game holds the routine itself with MachinePausedTrait, which holds every machine on the NPC until the game takes it off.
Fences
Section titled “Fences”Four fences keep an NPC in the world:
- The navmesh is the world’s edge. A walk gives up on a route point
walkTorefuses, and a dev world warns once naming the NPC, the routine, the point and the refusal. - The
leashon the NPC, from its spawn. - A timeout per point: the distance over the walking speed, twice, plus 2 s, as Roblox’s
MoveTogives up after 8 s. On a timeout the NPC is markedStuckTrait, the walk gives up on the point, and a dev world warns once. - Every routine is finite: the builder makes lists, and
thenandresumeare the only jumps.startrefuses a routine name nobody declared, and aresume()in a routine no event starts.
A walk that gives up on a point sends the routine NpcEvent.Stuck, and emits the GaveUpEvent event, which the dump’s events lists as gaveUp with { point, reason }: the point’s place in the route from 0, and "timeout" or "noRoute". A routine answers it with .on(NpcEvent.Stuck, "lost"), and a resume() in lost heads for the next point. With no .on() for it, the walk goes on to the next point, and past the last one the step ends. Roblox’s MoveToFinished reports reached as false the same way, and Unreal’s path following ends with Blocked or Aborted rather than Success.
const guard = routines("guard") .routine("round", (r) => r.walk("gateToTower", { repeat: Infinity })) .routine("lost", (r) => r.say("Hm, blocked.").resume()) .on(NpcEvent.Stuck, "lost") .start("round");In words
Section titled “In words”describeRoutines gives each routine as a line, such as morning: walk stall to well to herbs, wait 3 s, then rest, which the devtools’ Wiki tab lists under the NPC.