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
enumnames every possible state. - A companion
struct(or aclasshierarchy) bundles the data that belongs to that specific state. - A
caseexpression 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
Lockeddoor that also stores aWinnerAgent).
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.