Coral the flamingo has seen a thousand first islands, and they all hit the same sandbar: one OnBegin (or one event handler) that keeps growing until nobody — including the person who wrote it — can say what any one part does. The fix is the very first habit of professional code: every function gets exactly one job. Today you take the tangled shell-hunt handler you have been building across South Shores and split it into three small, named, single-purpose functions: CollectShell(), UpdateHud(), and CheckWin().
What you will build
This is the refactor that turns one giant handler into the clean function set the Shell Hunt capstone runs on. You already have all the ingredients from earlier South Shores lessons: trigger_device zones and their ?agent payload (Invisible Tripwires), hud_message_device text (Talk to Players), var/set counters (Keeping Score), and "Shells: {Count}/{Total}" interpolation (Say It With Braces). What is new here is not an API — it is structure. By the end you will have a device where:
CollectShell(Agent)records a pickup — and nothing else,UpdateHud()paints the count on screen — and nothing else,CheckWin(Agent)decides whether the round is over — and nothing else.
Three names, three jobs, zero tangles. This is the first SOLID habit (the "S" — single responsibility), and every later zone assumes you have it.
Walkthrough
Step 1 — Meet the blob
Here is the shell hunt the way most first drafts look. It compiles. It even works. It is also where good islands go to sink:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
# The "before" picture: one handler doing every job on the beach.
messy_hunt_device := class(creative_device):
@editable
ShellTrigger : trigger_device = trigger_device{}
@editable
HuntHUD : hud_message_device = hud_message_device{}
@editable
EndGame : end_game_device = end_game_device{}
var ShellsFound : int = 0
CountText<localizes>(Found : string) : message = "Shells: {Found}/3"
WinText<localizes>() : message = "All shells found — you win!"
OnBegin<override>()<suspends> : void =
ShellTrigger.TriggeredEvent.Subscribe(OnShellTouched)
Sleep(Inf)
# One handler, THREE jobs: counting, HUD text, and win logic — all tangled.
OnShellTouched(Agent : ?agent) : void =
set ShellsFound += 1
FoundStr : string = "{ShellsFound}"
HuntHUD.SetText(CountText(FoundStr))
HuntHUD.SetDisplayTime(0.0)
HuntHUD.Show()
if (ShellsFound >= 3):
HuntHUD.SetText(WinText())
HuntHUD.Show()
if (Winner := Agent?):
EndGame.Activate(Winner)
Read OnShellTouched out loud: "add one to the counter AND build the HUD string AND set the text AND show it AND check the win AND maybe end the game." Every AND is a separate job hiding in one function. Want to reuse the HUD update when the round starts? You cannot — it is welded to the counting. Want to test the win check on its own? Same problem.
Step 2 — Find the jobs
Before touching code, name the responsibilities. In the blob there are exactly three:
| Job | Lines that do it | Future function |
|---|---|---|
| Record a pickup | set ShellsFound += 1 |
CollectShell() |
| Show the count | FoundStr…, SetText, SetDisplayTime, Show |
UpdateHud() |
| Decide the win | if (ShellsFound >= 3)…, EndGame.Activate |
CheckWin() |
A good smell test: if describing a function honestly requires the word "and", it has more than one job.
Step 3 — The refactor
Same behaviour, new shape. This is the complete device — drop it in, wire the three triggers, the HUD device, and the End Game device in the Details panel:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
# The "after" picture: the same shell hunt, split into one function per job.
shell_hunt_device := class(creative_device):
# Three shell pickup zones on the beach — wire each in the Details panel.
@editable
ShellTriggerA : trigger_device = trigger_device{}
@editable
ShellTriggerB : trigger_device = trigger_device{}
@editable
ShellTriggerC : trigger_device = trigger_device{}
# The HUD message device that shows the live count.
@editable
HuntHUD : hud_message_device = hud_message_device{}
# Ends the round when every shell is found.
@editable
EndGame : end_game_device = end_game_device{}
# How many shells are hidden this round.
@editable
TotalShells : int = 3
var ShellsFound : int = 0
# Verse HUD text must be a `message`; a <localizes> function is the bridge.
ShellCountText<localizes>(Found : string, Total : string) : message =
"Shells: {Found}/{Total}"
WinnerText<localizes>() : message =
"All shells found — the hunt is won!"
OnBegin<override>()<suspends> : void =
# Every shell zone reports to the SAME function.
ShellTriggerA.TriggeredEvent.Subscribe(CollectShell)
ShellTriggerB.TriggeredEvent.Subscribe(CollectShell)
ShellTriggerC.TriggeredEvent.Subscribe(CollectShell)
UpdateHud()
Sleep(Inf)
# Job 1 — record one collected shell, then hand off. Nothing else.
CollectShell(Agent : ?agent) : void =
set ShellsFound += 1
UpdateHud()
CheckWin(Agent)
# Job 2 — push the current count to the HUD. Nothing else.
UpdateHud() : void =
FoundStr : string = "{ShellsFound}"
TotalStr : string = "{TotalShells}"
HuntHUD.SetText(ShellCountText(FoundStr, TotalStr))
HuntHUD.SetDisplayTime(0.0)
HuntHUD.Show()
# A small PURE function with a return value: same inputs, same answer,
# no side effects. Easy to read, easy to reuse, easy to trust.
IsHuntComplete(Found : int, Total : int)<transacts> : logic =
if (Found >= Total) { true } else { false }
# Job 3 — decide whether the hunt is over, and end the round if so.
CheckWin(Agent : ?agent) : void =
if (IsHuntComplete(ShellsFound, TotalShells)?):
HuntHUD.SetText(WinnerText())
HuntHUD.Show()
if (Winner := Agent?):
EndGame.Activate(Winner)
What changed, line by line
| Piece | What it does now |
|---|---|
CollectShell(Agent : ?agent) |
The only place the counter changes. It does its one job, then delegates: UpdateHud() and CheckWin(Agent). |
UpdateHud() |
The only place HUD text is built and shown. OnBegin now reuses it to paint Shells: 0/3 at round start — for free, because it is no longer welded to the counting. |
IsHuntComplete(Found, Total) : logic |
A pure function with parameters and a return value. It reads no device state and touches no screen — give it two ints, get back a logic. Note the ? query when calling it: if (IsHuntComplete(...)?). |
CheckWin(Agent : ?agent) |
The only place the round can end. It asks IsHuntComplete for the verdict, then unwraps the optional agent (if (Winner := Agent?)) before EndGame.Activate(Winner) — the same ?agent unwrap you learned in Invisible Tripwires. |
TotalShells as @editable |
The win condition is no longer a magic 3 buried in an if. Designers can retune the hunt without opening Verse. |
Step 4 — Wire it in the editor
- Place three Trigger devices on your shell spots, a HUD Message device, and an End Game device.
- Select your
shell_hunt_device, and in the Details panel assignShellTriggerA/B/C,HuntHUD, andEndGame. - Launch Session, step on the zones, and watch the count climb to the winner banner.
Common patterns
Pattern 1 — Parameters over hidden state
UpdateHud() above reads the class counter directly, which is fine inside one device. But the moment a helper takes its inputs as parameters, it stops caring where the numbers came from — and becomes reusable anywhere:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
hud_helper_device := class(creative_device):
@editable
HuntHUD : hud_message_device = hud_message_device{}
CountText<localizes>(Found : string, Total : string) : message =
"Shells: {Found}/{Total}"
# Parameters instead of hidden state: this helper can show ANY count,
# for any game, without knowing where the numbers came from.
ShowCount(Found : int, Total : int) : void =
FoundStr : string = "{Found}"
TotalStr : string = "{Total}"
HuntHUD.SetText(CountText(FoundStr, TotalStr))
HuntHUD.SetDisplayTime(0.0)
HuntHUD.Show()
OnBegin<override>()<suspends> : void =
ShowCount(0, 3)
Sleep(2.0)
ShowCount(1, 3)
This parameterised ShowCount(Found, Total) is exactly the shape that graduates into the ShellHuntKit module's HUD helper two lessons from now.
Pattern 2 — Return values make decisions testable
Functions that compute an answer and hand it back (instead of acting on the world) are the easiest code you will ever debug — same inputs, same output, every time:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
bonus_score_device := class(creative_device):
# Pure function: rarer shells (3rd and beyond) are worth more.
ScoreForShell(ShellNumber : int) : int =
if (ShellNumber >= 3) { 25 } else { 10 }
IsHuntComplete(Found : int, Total : int)<transacts> : logic =
if (Found >= Total) { true } else { false }
OnBegin<override>()<suspends> : void =
var TotalScore : int = 0
for (N := 1..3):
set TotalScore += ScoreForShell(N)
Print("Final score: {TotalScore}")
if (IsHuntComplete(3, 3)?):
Print("Hunt complete!")
ScoreForShell and IsHuntComplete never touch a device. That means you can reason about them in your head, reuse them in any project, and — when something goes wrong at 2 a.m. — trust them completely while you hunt elsewhere.
Where this goes next
This clean function set is the skeleton of the Shell Hunt capstone — the beach minigame South Shores has been building toward. From here, each remaining lesson upgrades one function without disturbing the others:
- One Handler, Many Devices (next lesson) replaces the three copy-pasted
Subscribelines by fanning an array of ~10 shell triggers into your singleCollectShell— possible only becauseCollectShellis already one clean, shared function. - Your First Module: the ShellHuntKit Toolbox lifts
UpdateHud's logic into a reusablemodule(the zone's export that later zones import withusing). - The capstone's spawning, bobbing, and teleport features each slot in as new small functions beside these three — never inside them.
One job each. Coral insists — and so will every teammate you ever have.