The Rusty Barnacle: Inkbeard's NPC behaviour state machine

In this lesson you'll learn to

  • βœ“Student can author npc_behavior Verse with an internal state machine (idle/patrol/chase) and attach it to an NPC spawner.

Student can author npc_behavior Verse with an internal state machine (idle/patrol/chase) and attach it to an NPC spawner.

πŸ“– Reference & full walkthrough: The Rusty Barnacle: Inkbeard's NPC behaviour state machine

πŸ” Builds on: west-coves L19 state machine + north-jungle classes

🧩 Your capstone piece: The Kraken hazard NPC: patrols the channel, chases and eliminates straggler racers (support: npc-behavior, npc-spawner-device, verse-npc-custom-behavior, verse-npc-guard-patrol all PASS; spawn-npc FAILS 1 err)


Overview

A state machine is the backbone of believable NPC behavior. Instead of one monolithic script, you divide the NPC's life into discrete states (Idle, Alert, Reset) and define the transitions between them. In UEFN, you wire this up by:

  1. Subclassing npc_behavior β€” Epic's abstract base class for custom NPC logic. Attach it to an NPC Spawner device via a Character Definition asset.
  2. Subscribing to trigger_device.TriggeredEvent β€” so the NPC reacts when a player steps on a pressure plate near the clifftop.
  3. Driving a timer_device β€” to count down the alert window and fire SuccessEvent when time is up, transitioning the NPC back to idle.

When to reach for this pattern:

  • You want an NPC that reacts to player proximity or actions.
  • You need timed state windows (alert lasts 15 seconds, then resets).
  • You want to chain multiple device events without Blueprint spaghetti.

API Reference

npc_behavior

Inherit from this to create a custom NPC behavior. The npc_behavior can be defined for a character in a CharacterDefinition asset, or in a npc_spawner_device.

Full public surface, resolved verbatim from the live Epic digest (Fortnite.digest.verse).

npc_behavior<native><public> := class<abstract>:

trigger_device

Used to relay events to other linked devices.

Full public surface, resolved verbatim from the live Epic digest (Fortnite.digest.verse). Inherited members are merged from trigger_base_device.

trigger_device<public> := class<concrete><final>(trigger_base_device):

Events (subscribe a handler to react):

Event Signature Description
TriggeredEvent TriggeredEvent<public>:listenable(?agent) Signaled when an agent triggers this device. Sends the agent that used this device. Returns false if no agent triggered the action (ex: it was triggered through code).

Methods (call these to make the device act):

Method Signature Description
Trigger Trigger<public>(Agent:agent):void Triggers this device with Agent being passed as the agent that triggered the action. Use an agent reference when this device is setup to require one (for instance, you want to trigger the device only with a particular agent.
Trigger Trigger<public>():void Triggers this device, causing it to activate its TriggeredEvent event.
Enable Enable<public>():void Enables this device.
Disable Disable<public>():void Disables this device.
SetMaxTriggerCount SetMaxTriggerCount<public>(MaxCount:int):void Sets the maximum amount of times this device can trigger. * 0 can be used to indicate no limit on trigger count. * MaxCount is clamped between [0,20].
GetMaxTriggerCount GetMaxTriggerCount<public>()<transacts>:int Gets the maximum amount of times this device can trigger. * 0 indicates no limit on trigger count.
GetTriggerCountRemaining GetTriggerCountRemaining<public>()<transacts>:int Returns the number of times that this device can still be triggered before hitting GetMaxTriggerCount. Returns 0 if GetMaxTriggerCount is unlimited.
SetResetDelay SetResetDelay<public>(Time:float):void Sets the time (in seconds) after triggering, before the device can be triggered again (if MaxTrigger count allows).
GetResetDelay GetResetDelay<public>()<transacts>:float Gets the time (in seconds) before the device can be triggered again (if MaxTrigger count allows).
SetTransmitDelay SetTransmitDelay<public>(Time:float):void Sets the time (in seconds) which must pass after triggering, before this device informs other external devices that it has been triggered.
GetTransmitDelay GetTransmitDelay<public>()<transacts>:float Gets the time (in seconds) which must pass after triggering, before this device informs other external devices that it has been triggered.

timer_device

Provides a way to keep track of the time something has taken, either for scoreboard purposes, or to trigger actions. It can be configured in several ways, either acting as a countdown to an event that is triggered at the end, or as a stopwatch for an action that needs to be completed before a set time runs out.

Full public surface, resolved verbatim from the live Epic digest (Fortnite.digest.verse).

timer_device<public> := class<concrete><final>(creative_device_base):

Events (subscribe a handler to react):

Event Signature Description
SuccessEvent SuccessEvent<public>:listenable(?agent) Signaled when the timer completes or ends with success. Sends the agent that activated the timer, if any.
FailureEvent FailureEvent<public>:listenable(?agent) Signaled when the timer completes or ends with failure. Sends the agent that activated the timer, if any.
StartUrgencyModeEvent StartUrgencyModeEvent<public>:listenable(?agent) Signaled when the timer enters Urgency Mode. Sends the agent that activated the timer, if any.

Methods (call these to make the device act):

Method Signature Description
Enable Enable<public>(Agent:agent):void Enables this device for Agent.
Enable Enable<public>():void Enables this device.
Disable Disable<public>(Agent:agent):void Disables this device for Agent. While disabled this device will not receive signals.
Disable Disable<public>():void Disables this device. While disabled this device will not receive signals.
ResetForAll ResetForAll<public>(Agent:agent):void Resets the timer back to its base time and stops it for all agents.
ResetForAll ResetForAll<public>():void Resets the timer back to its base time and stops it for all agents.
Start Start<public>(Agent:agent):void Starts the timer for Agent.
Start Start<public>():void Starts the timer.
Pause Pause<public>(Agent:agent):void Pauses the timer for Agent.
Pause Pause<public>():void Pauses the timer.
Resume Resume<public>(Agent:agent):void Resumes the timer for Agent.
Resume Resume<public>():void Resumes the timer.
Complete Complete<public>(Agent:agent):void Completes the timer for Agent.
Complete Complete<public>():void Completes the timer.
StartForAll StartForAll<public>(Agent:agent):void Starts the timer for all agents.
StartForAll StartForAll<public>():void Starts the timer for all agents.
PauseForAll PauseForAll<public>(Agent:agent):void Pauses the timer for all agents.
PauseForAll PauseForAll<public>():void Pauses the timer for all agents.
ResumeForAll ResumeForAll<public>(Agent:agent):void Resumes the timer for all agents.
ResumeForAll ResumeForAll<public>():void Resumes the timer for all agents.
CompleteForAll CompleteForAll<public>(Agent:agent):void Completes the timer for all agents.
CompleteForAll CompleteForAll<public>():void Completes the timer for all agents.
Save Save<public>(Agent:agent):void Saves this device's data for Agent.
Load Load<public>(Agent:agent):void Loads this device's saved data for Agent.
ClearPersistenceData ClearPersistenceData<public>(Agent:agent):void Clears this device's saved data for Agent.
ClearPersistenceDataForAll ClearPersistenceDataForAll<public>(Agent:agent):void Clears this device's saved data for all agents.
ClearPersistenceDataForAll ClearPersistenceDataForAll<public>():void Clears this device's saved data for all agents.
SetActiveDuration SetActiveDuration<public>(Time:float, Agent:agent):void Sets the remaining time (in seconds) on the timer, if active, on Agent.
SetActiveDuration SetActiveDuration<public>(Time:float):void Sets the remaining time (in seconds) on the timer, if active. Use this function if the timer is set to use the same time for all agent's.
GetActiveDuration GetActiveDuration<public>(Agent:agent)<transacts>:float Returns the remaining time (in seconds) on the timer for Agent.
GetActiveDuration GetActiveDuration<public>()<transacts>:float Returns the remaining time (in seconds) on the timer if it is set to be global.
SetLapTime SetLapTime<public>(Agent:agent):void Sets the lap time indicator for Agent.
SetLapTimeForAll SetLapTimeForAll<public>(Agent:agent):void Sets the lap time indicator for all agents.
SetLapTimeForAll SetLapTimeForAll<public>():void Sets the lap time indicator for all agents.
SetMaxDuration SetMaxDuration<public>(Time:float):void Sets the maximum duration of the timer (in seconds).
GetMaxDuration GetMaxDuration<public>()<transacts>:float Returns the maximum duration of the timer (in seconds).
IsStatePerAgent IsStatePerAgent<public>()<transacts><decides>:void Succeeds if this device is tracking timer state for each individual agent independently. Fails if state is being tracked globally for all agent's.

Walkthrough

The Scene

You have a sun-drenched pirate island. On the clifftop overlooking the lagoon sits Captain Crow, a cel-shaded NPC guard. A pressure-plate trigger_device is hidden under the dock planks. When a player steps on it, Captain Crow enters Alert state and a 15-second timer_device starts counting down. When the timer succeeds (time runs out), Crow returns to Idle. A second trigger fires a cannon-blast VFX to punctuate the alert.

The Verse Device

This is a creative_device (not the npc_behavior subclass itself β€” see Common Patterns for the behavior class). It lives in your level and wires the trigger + timer together, then calls methods on those devices to drive the state machine.

using { /Fortnite.com/Devices }
using { /Fortnite.com/AI }
using { /Fortnite.com/Characters }
using { /Verse.org/Simulation }

# Pirate lookout state machine β€” clifftop dock
# Attach this device to your level. Wire:
#   DockTrigger  -> a trigger_device under the dock planks
#   AlertTimer   -> a timer_device set to Count Down, 15 s
#   CannonTrigger -> a trigger_device that fires cannon VFX
piratelookout_device := class(creative_device):

    # The pressure plate hidden under the dock planks
    @editable
    DockTrigger : trigger_device = trigger_device{}

    # 15-second countdown β€” configure in UEFN as Count Down, 15 s
    @editable
    AlertTimer : timer_device = timer_device{}

    # Fires the cannon-blast VFX device when alert begins
    @editable
    CannonTrigger : trigger_device = trigger_device{}

    # Track alert state so we don't double-start the timer
    var IsAlerted : logic = false

    OnBegin<override>()<suspends> : void =
        # Start with the timer disabled β€” we enable it on alert
        AlertTimer.Disable()

        # Allow the dock trigger to fire unlimited times
        DockTrigger.SetMaxTriggerCount(0)

        # Subscribe: player steps on dock -> enter Alert state
        DockTrigger.TriggeredEvent.Subscribe(OnDockTriggered)

        # Subscribe: timer runs out -> return to Idle state
        AlertTimer.SuccessEvent.Subscribe(OnAlertExpired)

        # Subscribe: timer enters urgency mode -> flash cannon again
        AlertTimer.StartUrgencyModeEvent.Subscribe(OnUrgencyMode)

    # Called when a player steps on the dock trigger
    OnDockTriggered(MaybeAgent : ?agent) : void =
        if (IsAlerted?):
            # Already alerted, ignore duplicate triggers
        else:
            set IsAlerted = true
            # Fire the cannon VFX trigger (no agent needed)
            CannonTrigger.Trigger()
            # Enable and start the alert countdown
            AlertTimer.Enable()
            AlertTimer.Start()

    # Called when the 15-second alert window expires (SuccessEvent)
    OnAlertExpired(MaybeAgent : ?agent) : void =
        set IsAlerted = false
        # Stop and reset the timer, disable until next alert
        AlertTimer.ResetForAll()
        AlertTimer.Disable()
        # Re-enable the dock trigger for the next patrol cycle
        DockTrigger.Enable()

    # Called when the timer enters urgency mode (last few seconds)
    OnUrgencyMode(MaybeAgent : ?agent) : void =
        # Fire cannon trigger again as a "last warning" visual
        CannonTrigger.Trigger()```

### Line-by-Line Explanation

| Lines | What's happening |
|---|---|
| `@editable` fields | Expose `DockTrigger`, `AlertTimer`, and `CannonTrigger` to the UEFN editor so you can drag-assign placed devices. |
| `var IsAlerted` | A simple boolean gate β€” prevents re-entering Alert if the player keeps stepping on the trigger. |
| `AlertTimer.Disable()` | Timer starts disabled; we only enable it when the alert fires, preventing accidental auto-start. |
| `DockTrigger.SetMaxTriggerCount(0)` | `0` = unlimited triggers, so the patrol cycle can repeat forever. |
| `.Subscribe(OnDockTriggered)` | Hooks the `listenable(?agent)` event. The handler receives `?agent` (optional). |
| `CannonTrigger.Trigger()` | Fires the cannon VFX trigger with no agent β€” the no-arg overload is perfect here. |
| `AlertTimer.Enable()` then `AlertTimer.Start()` | Enable must come before Start or the timer ignores the call. |
| `AlertTimer.ResetForAll()` | Resets the countdown back to 15 s for all agents, ready for the next cycle. |
| `OnUrgencyMode` | `StartUrgencyModeEvent` fires when the timer enters its urgency window (configured in UEFN). We fire the cannon again as a dramatic "last warning". |

---

## Common Patterns

### Pattern 1 β€” Custom `npc_behavior` Subclass (the NPC's own brain)

This is the class you assign in the Character Definition asset. It runs *inside* the NPC and can use `focus_interface` to make Captain Crow stare at the lagoon.

```verse
using { /Fortnite.com/AI }
using { /Fortnite.com/Characters }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/SpatialMath }

# Assign this class in the NPC's CharacterDefinition asset
captain_crow_behavior := class(npc_behavior):

    # Called by the engine when the NPC spawns
    OnBegin<override>()<suspends> : void =
        # Get the fort_character for this NPC
        if (Char := GetCharacter[]):
            # Get the focus interface and make Crow stare at the lagoon
            if (Focus := Char.GetFocusInterface[]):
                # MaintainFocus never returns on its own β€” use race to cancel
                race:
                    # Stare at a fixed lagoon landmark (world coords)
                    Focus.MaintainFocus(vector3{X := 12000.0, Y := -3400.0, Z := 200.0})
                    # After 8 seconds, break the focus (NPC resumes default)
                    block:
                        Sleep(8.0)

Key point: MaintainFocus has the <suspends> effect β€” it never returns unless canceled. Wrap it in a race block alongside a Sleep or another event so you can break out of it.


Pattern 2 β€” Timed Alert Window with SetActiveDuration

Dynamically shorten or lengthen the alert window at runtime β€” useful if the player is carrying a "wanted" item that makes guards more persistent.

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }

# Adjusts the alert timer duration based on a "heat level" variable
alert_duration_device := class(creative_device):

    @editable
    AlertTimer : timer_device = timer_device{}

    @editable
    HeatTrigger : trigger_device = trigger_device{}

    # Heat level: 1 = normal (15 s), 2 = hot (30 s)
    var HeatLevel : int = 1

    OnBegin<override>()<suspends> : void =
        HeatTrigger.TriggeredEvent.Subscribe(OnHeatTriggered)
        AlertTimer.SuccessEvent.Subscribe(OnTimerDone)

    OnHeatTriggered(MaybeAgent : ?agent) : void =
        # Unwrap the optional agent
        if (A := MaybeAgent?):
            # High heat: give the player 30 s of alert time
            set HeatLevel = 2
            AlertTimer.SetActiveDuration(30.0, A)
            AlertTimer.Start(A)
        else:
            # No agent (triggered by code): use default 15 s
            AlertTimer.Start()

    OnTimerDone(MaybeAgent : ?agent) : void =
        set HeatLevel = 1
        AlertTimer.ResetForAll()

Key point: SetActiveDuration(Time, Agent) takes a float and an agent β€” you must unwrap ?agent first. The no-agent Start() overload is used as a fallback when the trigger fires from code.


Pattern 3 β€” One-Shot Alarm with SetMaxTriggerCount and GetTriggerCountRemaining

The cannon alarm should only fire once per round. After it fires, disable the trigger and show remaining count in debug logic.

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }

# One-shot alarm: fires once, then locks out until round reset
oneshot_alarm_device := class(creative_device):

    @editable
    AlarmTrigger : trigger_device = trigger_device{}

    @editable
    ResetTrigger : trigger_device = trigger_device{}

    OnBegin<override>()<suspends> : void =
        # Clamp to exactly 1 trigger per round
        AlarmTrigger.SetMaxTriggerCount(1)
        # Set a 2-second reset delay before it could theoretically re-fire
        AlarmTrigger.SetResetDelay(2.0)
        # Add a 0.5-second transmit delay before linked devices receive the signal
        AlarmTrigger.SetTransmitDelay(0.5)

        AlarmTrigger.TriggeredEvent.Subscribe(OnAlarmFired)
        ResetTrigger.TriggeredEvent.Subscribe(OnRoundReset)

    OnAlarmFired(MaybeAgent : ?agent) : void =
        # Check how many triggers remain (0 = at limit)
        Remaining := AlarmTrigger.GetTriggerCountRemaining()
        if (Remaining = 0):
            # Lock out the alarm for the rest of the round
            AlarmTrigger.Disable()

    OnRoundReset(MaybeAgent : ?agent) : void =
        # Re-arm the alarm for the next round
        AlarmTrigger.SetMaxTriggerCount(1)
        AlarmTrigger.Enable()

Key point: GetTriggerCountRemaining() returns 0 when GetMaxTriggerCount() is unlimited and when the limit has been reached β€” check GetMaxTriggerCount() first if you need to distinguish the two cases.


Gotchas

1. npc_behavior has no events or methods of its own β€” override OnBegin

npc_behavior is an <abstract> class with no built-in events. Your entire NPC logic lives in OnBegin<override>()<suspends>:void. Don't try to call methods on npc_behavior directly from a creative_device β€” you can't. Use GetNPCBehavior() on an agent to retrieve the behavior instance, then cast it to your subclass.

2. MaintainFocus suspends forever β€” always wrap in race

focus_interface.MaintainFocus has <suspends> and never returns on its own. If you call it bare in OnBegin, your NPC's brain freezes at that line. Always pair it with a Sleep, an event await, or another race branch that can cancel it.

3. ?agent must be unwrapped before use

TriggeredEvent, SuccessEvent, and FailureEvent all deliver ?agent (optional). Calling .SetActiveDuration(Time, MaybeAgent?) directly won't compile. Unwrap first:

if (A := MaybeAgent?):
    AlertTimer.SetActiveDuration(30.0, A)

4. Enable() before Start() β€” order matters

Calling AlertTimer.Start() on a disabled timer silently does nothing. Always call AlertTimer.Enable() first, then AlertTimer.Start(). Conversely, call AlertTimer.ResetForAll() before AlertTimer.Disable() so the timer is clean when re-enabled.

5. SetMaxTriggerCount clamps to [0, 20]

Values above 20 are silently clamped. Use 0 for unlimited. If you need more than 20 discrete trigger events in a round, manage the count yourself in Verse with a var counter.

6. message parameters need <localizes> β€” not raw strings

If you ever pass text to a UI device from your state machine, you cannot pass a raw string. Declare a localizable wrapper:

StateLabel<localizes>(S : string) : message = "{S}"

Then pass StateLabel("Alert"). There is no StringToMessage function.

7. int and float don't auto-convert

SetActiveDuration takes a float. Passing an int literal like 30 will fail β€” write 30.0 explicitly.

πŸ”’

Keep going β€” free

You've read the intro. The rest of this lesson is free for members. Sign in to continue and track your progress.

Sign in free to continue this lesson
Pass the quiz above to chart this quest in your Journal.

Sources

/guides/state-machine-with-npc-behavior
Source

Biloxi Studios original lesson

Guided course

Add this lesson to your free study plan.

🧭 The Keeper’s log

Quest complete? Chart your next heading from the πŸ¦‘ West Coves expedition.

β›΅ Take me there