
NPC State Machines in Verse: Concurrency IS the Machine
Overview
Every NPC you've ever fought, followed, or bought a potion from is running some flavor of the same thing: a finite-state machine. The npc-state-machines module is a reusable NPC "brain" framework for UEFN — a generic FSM that any NPC domain (zombies, guards, companions, shopkeepers) can copy and adapt — shipped with one concrete, teachable example: a guard that patrols a route, investigates what it half-sees, chases what it confirms, and returns home when it loses the trail. It's digest-grounded on live UEFN Release-41.20 and compile-verified as a set on the real UEFN compiler.
The interesting part isn't the guard. It's the shape of the machine.
Most state machines you've written elsewhere are a switch plus a state variable that some event handler mutates from the outside. Verse doesn't need that shape, because it has structured concurrency. In this module, concurrency IS the state machine: every state races its own running body against its transition triggers. The body is a <suspends> function that just does the state — walk the route, chase the target — forever, never once asking "should I transition?". The triggers are block: branches that each Await() a single event. The instant any branch completes, race cancels the others, the blackboard's mood flips, and the outer loop re-enters the case on the very next iteration. Nobody polls anything. There is no Sleep(0.1) anywhere in the FSM file.
The second design decision worth stealing is the mood vs. reaction split. A MOOD is the persistent state (guard_state — Patrol, Investigate, Chase, Return), only ever written by the FSM runner itself. A REACTION (npc_reaction — Damaged, LostInterest) is a one-shot interrupt raced alongside every state's own triggers: it gets handled, logged, and then control returns to whichever state was already running, because handling a reaction never touches the mood. One design decision, and your NPC can flinch without forgetting what it was doing.
The module, file by file
Three files, one responsibility each, and the dependency arrow points one way — device → flow → types. The FSM engine never reaches back into the device layer: swap mode_device.verse for a zombie's device file and mode_flow.verse doesn't change.
mode_types.verse — the shared type surface
Types only, zero logic. Both other files import these; nothing redefines them. The mood enum is deliberately small — four states, each with exactly one meaning:
guard_state<public> := enum:
Patrol
Investigate
Chase
Return
The blackboard is the per-NPC brain data — one instance per guard, passed into every state function, never global and never shared across NPCs:
guard_blackboard<public> := class:
# The FSM's current MOOD. Owned by mode_flow.verse's RunGuardFSM;
# state bodies read it, only the FSM runner writes it.
var CurrentState<public> : guard_state = guard_state.Patrol
# The agent currently being investigated/chased, if any.
var TargetAgent<public> : ?agent = false
# World position (NEW /Verse.org/SpatialMath) where TargetAgent was
# last seen — where Investigate walks to and Chase re-aims toward.
var LastSeenPosition<public> : vector3 = vector3{}
# Where this guard returns to once it gives up the chase. Set once
# at spawn from the guard's own starting position.
HomeAnchor<public> : vector3 = vector3{}
# Waypoints this guard patrols, in order, looping. Empty = stand and
# wait in place.
PatrolRoute<public> : []vector3 = array{}
Note who owns what: CurrentState is owned by the FSM runner in mode_flow.verse — state bodies read it, only RunGuardFSM writes it. And every position field (LastSeenPosition, HomeAnchor, PatrolRoute) is the NEW /Verse.org/SpatialMath vector3 — that choice matters more than it looks, and it gets its own gotcha below.
mode_flow.verse — the engine
Pure Verse, no devices, so the logic reads without editor context and stays reusable. The cross-module contract between perception and the FSM is a single class of events:
guard_triggers<public> := class:
# A player entered sight range. Un-confirmed the first time this
# fires (Patrol -> Investigate); a second sighting while
# Investigating confirms it (Investigate -> Chase). Can also pull a
# Returning guard back into a Chase.
SightedEvent<public> : event(agent) = event(agent){}
# No player was found in range on the most recent perception tick.
# Only meaningful to whichever state is actively racing against it
# (Chase) — everywhere else it is a harmless, unheard signal.
LostSightEvent<public> : event() = event(){}
# One-shot REACTION interrupt (damage, etc.) — see npc_reaction.
ReactionEvent<public> : event(npc_reaction) = event(npc_reaction){}
SightedEvent<public> : event(agent) = event(agent){} — SightedEvent is an event(agent), set to a fresh event instance. Verse's event(t) is both signalable and awaitable: the perception side calls Signal(), the FSM side calls Await(), and that one line is the entire contract between the two files.
RunGuardFSM is the runner: a loop that dispatches on the blackboard's mood with case, and every arm has the same anatomy — the state's body raced against its triggers. Here's the Patrol arm:
RunGuardFSM<public>(Blackboard : guard_blackboard, Triggers : guard_triggers, Nav : navigatable, Focus : focus_interface)<suspends> : void =
loop:
case (Blackboard.CurrentState):
guard_state.Patrol =>
race:
RunPatrol(Blackboard, Nav)
block:
Target := Triggers.SightedEvent.Await()
set Blackboard.TargetAgent = option{Target}
set Blackboard.CurrentState = guard_state.Investigate
block:
Reaction := Triggers.ReactionEvent.Await()
LogReaction(Reaction)
Read the race carefully, because this single race: IS the whole "is a transition ready?" check for the state. RunPatrol walks the waypoints forever, doing nothing else. Triggers.SightedEvent.Await() suspends at zero cost until perception signals it — and wakes on the exact frame the signal fires. When a player is spotted, that branch wins, RunPatrol is canceled mid-stride, the mood flips to Investigate, and the outer loop re-enters the case.
The third branch in every arm is the reaction leg, and its handler is where the mood/reaction split becomes concrete:
LogReaction(Reaction : npc_reaction) : void =
case (Reaction):
npc_reaction.Damaged =>
Print("guard: reacted to damage — mood unchanged, resuming its current state")
npc_reaction.LostInterest =>
Print("guard: lost interest — mood unchanged, resuming its current state")
LogReaction deliberately never writes Blackboard.CurrentState. Because the mood is untouched, the next loop iteration re-enters the exact same case arm — the guard resumes whatever it was doing.
The four state bodies (RunPatrol, RunInvestigate, RunChase, RunReturn) are built entirely on navigatable and focus_interface, the digest-verified 41.20 API surfaces. One detail worth knowing before you extend: focus_interface.MaintainFocus never completes on its own, so RunInvestigate and RunChase race it against their movement — that's what keeps the guard's eyes locked on the target the whole time without a second concurrency mechanism.
mode_device.verse — the wiring
guard_brain is the npc_behavior subclass that connects the engine to a real NPC. On begin, it resolves the character's navigation and focus interfaces, builds the blackboard (anchoring HomeAnchor to the guard's own spawn position), and races the FSM against perception:
OnBegin<override>()<suspends> : void =
if:
Agent := GetAgent[]
Entity := GetEntity[]
Character := Agent.GetFortCharacter[]
Nav := Character.GetNavigatable[]
Focus := Character.GetFocusInterface[]
then:
Blackboard : guard_blackboard = guard_blackboard{
HomeAnchor := Entity.GetGlobalTransform().Translation,
PatrolRoute := PatrolRoute
}
NewTriggers : npc_fsm_flow.guard_triggers = npc_fsm_flow.guard_triggers{}
set LiveTriggers = option{NewTriggers}
race:
npc_fsm_flow.RunGuardFSM(Blackboard, NewTriggers, Nav, Focus)
RunPerception(Entity, NewTriggers, Blackboard)
else:
Print("guard_brain: could not resolve agent/navigation — behavior idle.")
It also exposes the one public hook the framework reserves for the future:
ReportReaction<public>(Reaction : npc_reaction) : void =
if (Live := LiveTriggers?):
Live.ReactionEvent.Signal(Reaction)
Firing ReportReaction never changes guard_state on its own — it just signals the reaction leg of whatever state is running. It's the intended wiring point for a damage device's damaged/eliminated event once one exists (out of scope for the NPC state-machine module; tracked for the zombie-survival module).
Walkthrough
Dropping it in. guard_brain is an npc_behavior, not a creative_device — you don't drop it into the level directly. Assign it to an NPC via a Character Definition asset (or an npc_spawner_device's character definition) in the UEFN editor, and set PatrolRoute, SightRadius, and PerceptionInterval on that asset. Drop patrol-marker props at the waypoints you want and feed their world positions into PatrolRoute at spawn time. The engine instantiates and drives one guard_brain per NPC.
What you get out of the box. The guard walks its route. A player entering SightRadius fires SightedEvent — un-confirmed the first time, so Patrol becomes Investigate: the guard walks to LastSeenPosition and looks around, focus locked on the candidate. If the investigate body finishes without a second sighting, the FSM falls back to Return on its own — no separate "give up" trigger needed. A second sighting while investigating confirms it: Investigate becomes Chase, and the guard runs the target down until a perception tick finds nobody in range and LostSightEvent sends it home. Even on the way home, a fresh SightedEvent can pull a returning guard straight back into a Chase.
Where perception fits. The FSM never polls, but something has to notice a player is nearby in the first place, and the NPC state-machine module's digest-verified API list has no native "player entered radius" event. So RunPerception samples:
Each tick it measures distance to every player and Signal()s SightedEvent or LostSightEvent. A Signal() with no active Await() is a harmless no-op — only the state currently racing against a trigger is listening — so the perception loop needs no "did anyone already know" bookkeeping.
Gotchas
The two-SpatialMaths landmine. UEFN 41.20 ships two incompatible vector3 types: the NEW /Verse.org/SpatialMath and the OLD /UnrealEngine.com/Temporary/SpatialMath. Several AI APIs return one or the other, and mixing them needs explicit reconstruction. This module set sidesteps the landmine by staying in the NEW world everywhere: every blackboard position is NEW-SpatialMath; positions come from entity.GetGlobalTransform().Translation and fort_character.GetGlobalTransform().Translation (both on the positional interface, both NEW) — never from navigatable.GetCurrentDestination() or fort_character.GetViewLocation(), which are the OLD-SpatialMath members on those same types; and every navigation_target is built with the NEW-typed MakeNavigationTarget(Position:(/Verse.org/SpatialMath:)vector3) overload. If you extend this framework and need GetCurrentDestination() or GetViewLocation(), you're back in OLD territory — convert explicitly, don't mix the two vector3 types in one call.
Blackboard ownership. One blackboard per NPC — never global, never shared across NPCs ("no cross-NPC statics"). And within that blackboard, CurrentState has exactly one writer: RunGuardFSM. State bodies read it; reactions never touch it. Break either rule and two guards start sharing a mood, or a reaction handler starts fighting the runner for control.
The one-sanctioned-timer rule. RunPerception is the ONE place in the entire module set that samples on a timer, and it's a deliberate, isolated exception, commented at its call site — raw sensing has to sample because there's no radius event to await. Everything else is event-driven. If you find yourself adding a second Sleep() polling loop, you're probably reimplementing something a race leg should be awaiting.
Get the complete code — free
You've read the full walkthrough. The complete, copy-paste-ready Verse solution is free for members — sign in to unlock it.
Free with your BrainDead.TV / BrainDeadGuild Discord account. The walkthrough above is always free.
Check your understanding
Test yourself with an interactive quiz and track your progress + earn XP — free for members.
Turn this into a guided course
Add A reusable three-file NPC finite-state-machine module where race-per-state structured concurrency replaces polling — with a patrol/investigate/chase/return guard as the worked example to your free study plan — we'll suggest related pages and stitch the lot into one compile-checked, self-guided lesson with worked examples and quizzes.
Original guide generated by Verse Island from the Verse/UEFN knowledge base, with references to the Epic Games sources above. Code is validated against the knowledge base.