Reference Verse compiles

Game Phases: Enums as a Simple State Machine

Every round-based game is secretly a state machine, and the friendliest state machine in Verse is an enum in a var. On the South Shores, Coral the flamingo runs her Shell Hunt in three phases - Lobby, Hunting, Celebration - and in this lesson you build the phase controller that the whole capstone minigame hangs off: define `shell_hunt_phase := enum{Lobby, Hunting, Celebration}`, store it in a `var`, gate every button press on the current phase (illegal presses are ignored, by design), and attach per-phase data like banner text and timing with tiny `case` helpers. One variable, one doorway for changes, zero magic numbers.

Updated Examples verified on the live UEFN compiler
Watch the Knotenum_with_data_pattern in ~90 seconds.

Overview

Fortnite islands built in UEFN constantly juggle state: a dock gate is locked, then unlocking, then open; a fishing cove is idle, then a player is reeling in a catch, then the catch is being scored. Naïve approaches store a raw integer or a fistful of booleans and pray every handler updates them all consistently.

The enum-with-data pattern solves this by making the state itself carry its payload:

  • A plain enum names every possible state.
  • A companion struct (or a class hierarchy) bundles the data that belongs to that specific state.
  • A case expression forces you to handle every branch — the compiler rejects incomplete switches.

When to reach for it:

  • Any system with 3+ mutually exclusive states where each state has different associated data.
  • Replacing parallel arrays/maps that all have to stay in sync.
  • Making illegal states unrepresentable (e.g. you can't accidentally have a Locked door that also stores a WinnerAgent).

API Reference

(API surface could not be resolved for this device.)

Walkthrough

Scenario: A cel-shaded island dock has a gate that starts Locked. When a player steps on a pressure plate, the gate enters Unlocking (storing which agent triggered it and a countdown). When the countdown expires the gate swings Open (storing the agent who opened it for the scoreboard). A second plate re-locks the gate.

We model this with a plain enum for the state tag, a struct per state's payload, and a var holding the current state. The case expression in every handler guarantees we never forget a branch.

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

# ── State payloads ──────────────────────────────────────────────────────────
dock_gate_state := enum:
    Locked
    Unlocking
    Open

# Data carried when the gate is in the Unlocking phase
unlocking_payload := struct:
    Instigator : agent
    CountdownSec : float

# Data carried when the gate is fully Open
open_payload := struct:
    Opener : agent
    OpenedAtSec : float

# ── Device ──────────────────────────────────────────────────────────────────
dock_gate_controller := class(creative_device):

    # Wire these up in the UEFN editor
    @editable
    UnlockPlate : trigger_device = trigger_device{}

    @editable
    LockPlate : trigger_device = trigger_device{}

    @editable
    GateDoor : conditional_button_device = conditional_button_device{}

    # Current state — starts Locked, no payload needed
    var GateState : dock_gate_state = dock_gate_state.Locked

    # Optional payloads — only valid when GateState matches
    var MaybeUnlocking : ?unlocking_payload = false
    var MaybeOpen : ?open_payload = false

    # Localised log helper
    LogMsg<localizes>(S : string) : message = "{S}"

    OnBegin<override>()<suspends> : void =
        UnlockPlate.TriggeredEvent.Subscribe(OnUnlockPlateTriggered)
        LockPlate.TriggeredEvent.Subscribe(OnLockPlateTriggered)
        Print("Dock gate controller ready — gate is LOCKED")

    # ── Handler: player steps on the unlock plate ──────────────────────────
    OnUnlockPlateTriggered(MaybeAgent : ?agent) : void =
        # Only transition from Locked → Unlocking
        if (GateState = dock_gate_state.Locked):
            if (A := MaybeAgent?):
                set GateState = dock_gate_state.Unlocking
                set MaybeUnlocking = option:
                    unlocking_payload:
                        Instigator := A
                        CountdownSec := 3.0
                Print("Gate UNLOCKING — 3-second countdown started")
                # Kick off the async countdown
                spawn { RunUnlockCountdown() }

    # ── Async countdown — runs concurrently ───────────────────────────────
    RunUnlockCountdown()<suspends> : void =
        Sleep(3.0)
        # After the sleep, check we're still in Unlocking (player may have re-locked)
        if (GateState = dock_gate_state.Unlocking):
            if (Payload := MaybeUnlocking?):
                set GateState = dock_gate_state.Open
                set MaybeOpen = option:
                    open_payload:
                        Opener := Payload.Instigator
                        OpenedAtSec := 3.0
                set MaybeUnlocking = false
                Print("Gate OPEN — opened by a player")

    # ── Handler: player steps on the lock plate ────────────────────────────
    OnLockPlateTriggered(MaybeAgent : ?agent) : void =
        # Describe what we're interrupting using the current state's payload
        case (GateState):
            dock_gate_state.Locked =>
                Print("Gate already locked — nothing to do")
            dock_gate_state.Unlocking =>
                if (Payload := MaybeUnlocking?):
                    Print("Countdown interrupted — gate re-locked")
                set GateState = dock_gate_state.Locked
                set MaybeUnlocking = false
            dock_gate_state.Open =>
                if (Payload := MaybeOpen?):
                    Print("Gate closed again after being opened")
                set GateState = dock_gate_state.Locked
                set MaybeOpen = false

Line-by-line explanation

Lines What's happening
dock_gate_state := enum Names every possible state — the compiler will error if a case misses one.
unlocking_payload / open_payload Structs that carry only the data relevant to that state. A Locked gate has no payload at all.
var MaybeUnlocking : ?unlocking_payload Option type — false when not in that state, option{…} when active.
OnUnlockPlateTriggered Guards with if (GateState = dock_gate_state.Locked) before transitioning — prevents double-triggering.
spawn { RunUnlockCountdown() } Launches the countdown as a concurrent task so the handler returns immediately.
case (GateState) in OnLockPlateTriggered Exhaustive switch — every branch reads the matching payload, so no branch can accidentally access stale data from another state.

Common patterns

Pattern 1 — Fishing cove: enum drives a scoring case with numeric payload

A cove minigame tracks whether a player is Idle, Reeling (with a difficulty float), or Scored (with a fish-size int). The case expression computes points differently per state.

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

cove_state := enum:
    Idle
    Reeling
    Scored

reeling_data := struct:
    Difficulty : float   # 0.0 easy … 1.0 hard

scored_data := struct:
    FishSizePoints : int

cove_fishing_scorer := class(creative_device):

    @editable
    CastButton : button_device = button_device{}

    @editable
    ScoreManager : score_manager_device = score_manager_device{}

    var State : cove_state = cove_state.Idle
    var MaybeReeling : ?reeling_data = false
    var MaybeScored : ?scored_data = false

    OnBegin<override>()<suspends> : void =
        CastButton.InteractedWithEvent.Subscribe(OnCast)

    OnCast(MaybeAgent : ?agent) : void =
        if (State = cove_state.Idle):
            set State = cove_state.Reeling
            set MaybeReeling = option:
                reeling_data{ Difficulty := 0.75 }
            spawn { FinishReel(MaybeAgent) }

    FinishReel(MaybeAgent : ?agent)<suspends> : void =
        Sleep(2.0)
        if (Payload := MaybeReeling?):
            # Difficulty drives the points — harder cast = bigger fish
            Points : int = if (Payload.Difficulty > 0.5) then 10 else 5
            set State = cove_state.Scored
            set MaybeScored = option:
                scored_data{ FishSizePoints := Points }
            set MaybeReeling = false
            # Award points to the agent who cast
            if (A := MaybeAgent?):
                ScoreManager.Activate(A)
            # Log the scored state's payload
            case (State):
                cove_state.Scored =>
                    if (S := MaybeScored?):
                        Print("Fish scored — {S.FishSizePoints} pts")
                cove_state.Idle => Print("Idle")
                cove_state.Reeling => Print("Still reeling")
            Sleep(1.0)
            set State = cove_state.Idle
            set MaybeScored = false

Pattern 2 — Clifftop: enum-with-data tracks team control and elapsed hold time

A clifftop capture point cycles through Neutral, Contested (two teams fighting — store both team names), and Controlled (one team owns it — store team name + hold start time).

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

clifftop_control := enum:
    Neutral
    Contested
    Controlled

contested_data := struct:
    TeamA : string
    TeamB : string

controlled_data := struct:
    OwnerTeam : string
    HoldStartSec : float

clifftop_manager := class(creative_device):

    @editable
    CaptureArea : capture_area_device = capture_area_device{}

    var ControlState : clifftop_control = clifftop_control.Neutral
    var MaybeContested : ?contested_data = false
    var MaybeControlled : ?controlled_data = false

    OnBegin<override>()<suspends> : void =
        CaptureArea.TeamAStartedCapturingEvent.Subscribe(OnTeamACaptures)
        CaptureArea.TeamACapturedEvent.Subscribe(OnTeamAControls)

    OnTeamACaptures(MaybeAgent : ?agent) : void =
        case (ControlState):
            clifftop_control.Neutral =>
                set ControlState = clifftop_control.Contested
                set MaybeContested = option:
                    contested_data{ TeamA := "Sharks", TeamB := "Crabs" }
                Print("Clifftop CONTESTED between Sharks and Crabs")
            clifftop_control.Contested =>
                Print("Already contested")
            clifftop_control.Controlled =>
                if (Prev := MaybeControlled?):
                    Print("{Prev.OwnerTeam} losing control — now contested")
                set ControlState = clifftop_control.Contested
                set MaybeContested = option:
                    contested_data{ TeamA := "Sharks", TeamB := "Crabs" }
                set MaybeControlled = false

    OnTeamAControls(MaybeAgent : ?agent) : void =
        set ControlState = clifftop_control.Controlled
        set MaybeControlled = option:
            controlled_data{ OwnerTeam := "Sharks", HoldStartSec := 0.0 }
        set MaybeContested = false
        Print("Sharks CONTROL the clifftop!")

Pattern 3 — Shore: using case return value to compute a display string

case in Verse is an expression, not just a statement — it can return a value. This pattern uses that to build a HUD-ready status string from the current state without any if/else chains.

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

shore_event := enum:
    Calm
    StormWarning
    Evacuating

storm_data := struct:
    WindSpeedKnots : float

evacuating_data := struct:
    PlayersEvacuated : int
    TotalPlayers : int

shore_hud_controller := class(creative_device):

    @editable
    HudDisplay : hud_message_device = hud_message_device{}

    var ShoreState : shore_event = shore_event.Calm
    var MaybeStorm : ?storm_data = false
    var MaybeEvac : ?evacuating_data = false

    # Localised message helper
    StatusMsg<localizes>(S : string) : message = "{S}"

    OnBegin<override>()<suspends> : void =
        # Simulate a storm rolling in after 5 seconds
        Sleep(5.0)
        set ShoreState = shore_event.StormWarning
        set MaybeStorm = option:
            storm_data{ WindSpeedKnots := 42.0 }
        BroadcastStatus()
        Sleep(5.0)
        set ShoreState = shore_event.Evacuating
        set MaybeEvac = option:
            evacuating_data{ PlayersEvacuated := 0, TotalPlayers := 8 }
        set MaybeStorm = false
        BroadcastStatus()

    # case as an EXPRESSION — returns the string directly
    BroadcastStatus() : void =
        StatusString : string = case (ShoreState):
            shore_event.Calm => "Shore is calm — all clear!"
            shore_event.StormWarning =>
                if (S := MaybeStorm?) then "Storm warning: {S.WindSpeedKnots} knots!"
                else "Storm warning!"
            shore_event.Evacuating =>
                if (E := MaybeEvac?) then "Evacuating: {E.PlayersEvacuated}/{E.TotalPlayers} safe"
                else "Evacuating!"
        HudDisplay.SetText(StatusMsg(StatusString))
        HudDisplay.Show()

Gotchas

1. case must be exhaustive — and that's a feature

Verse's case expression requires a branch for every enum value. If you add a new state (e.g. Jammed) and forget to handle it, the compiler errors immediately. Never use a catch-all _ => unless you genuinely want to ignore new states — it silently swallows future values.

2. Option payloads can be stale if you forget to clear them

When you transition out of a state, always set its payload back to false. If you go Unlocking → Locked without clearing MaybeUnlocking, the next handler that checks MaybeUnlocking? will find old data from the previous cycle. The pattern: set the new state, clear the old payload, set the new payload — in that order.

3. case is an expression, not a statement — mind the return type

Every branch of a case expression must produce the same type. If one branch returns a string and another returns void, the compiler rejects the whole expression. When mixing payload reads (if (P := Maybe?) then … else …) inside case, make sure both the then and else arms return the same type.

4. Structs are value types — mutation needs set

Payload structs are copied on assignment. To update a field you must rebuild the whole struct and set the option variable:

# WRONG — mutates a copy, original unchanged
if (P := MaybeReeling?):
    P.Difficulty = 0.9   # compile error: P is immutable

# CORRECT — rebuild and reassign
if (P := MaybeReeling?):
    set MaybeReeling = option:
        reeling_data{ Difficulty := 0.9 }

5. spawn for async state transitions — don't block the event handler

Event handlers (subscribed via .Subscribe(...)) must return void synchronously. If your state transition involves a Sleep, put the async work in a separate <suspends> method and launch it with spawn { MyAsyncMethod() }. Forgetting spawn and calling a <suspends> method directly from a handler is a compile error.

6. Localized text, not raw strings, for hud_message_device.SetText

The SetText method takes a message, not a string. Declare a helper:

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

and pass MyMsg("your text"). There is no StringToMessage function in Verse.

Build your own lesson with enum_with_data_pattern

Generate a personalized, step-by-step lesson plan built around this object — grounded in this exact reference and our compile-verified knowledge base.

Build a lesson →