Overview
A magic number is any literal value — integer, float, or string — embedded directly in logic without explanation. They cause two real problems in UEFN projects:
- Readability —
Sleep(3.0)tells you nothing;Sleep(RespawnDelaySec)tells you everything. - Maintainability — if
500appears twelve times and you want to change it, you must hunt down every occurrence and hope you don't miss one.
Verse gives you three tools to fix this:
| Tool | When to use it |
|---|---|
@editable field with a default |
Anything a designer might want to tune in the UEFN Details panel |
Module-level <var> constant |
A value that is fixed at compile time but needs a name |
<localizes> function |
Any player-facing text |
This article walks through each tool in a real game scenario: a respawn-and-score loop that would otherwise be riddled with magic numbers.
API Reference
(API surface could not be resolved for this device.)
Walkthrough
Scenario: Timed Respawn Loop with Score Threshold
A player steps on a trigger plate. After a configurable delay they respawn, earn points toward a win threshold, and see a localized on-screen message. Without named values this would be a soup of 3.0, 10, 500, and raw strings. Here is the clean version.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /Verse.org/Native }
# ---------------------------------------------------------------------------
# Localised messages — a <localizes> function is the ONLY way to produce
# a `message` value in Verse. There is no StringToMessage.
# ---------------------------------------------------------------------------
RespawnMsg<localizes>(PlayerName : string) : message = "Welcome back, {PlayerName}!"
RoundWonMsg<localizes>() : message = "You reached the score target — round won!"
# ---------------------------------------------------------------------------
# The device
# ---------------------------------------------------------------------------
respawn_score_device := class(creative_device):
# -----------------------------------------------------------------------
# @editable fields replace every magic number a designer might want to
# change without touching code.
# -----------------------------------------------------------------------
# How many seconds before the player is considered "respawned" (delay loop)
@editable
RespawnDelaySec : float = 3.0
# How many points the player earns per respawn
@editable
PointsPerRespawn : int = 10
# Score the player must reach to win the round
@editable
WinThreshold : int = 50
# The trigger plate players step on
@editable
RespawnPlate : trigger_device = trigger_device{}
# HUD message device used to display feedback
@editable
HudMessage : hud_message_device = hud_message_device{}
# -----------------------------------------------------------------------
# Internal state — also named, not magic
# -----------------------------------------------------------------------
var PlayerScore : int = 0
# -----------------------------------------------------------------------
# Entry point
# -----------------------------------------------------------------------
OnBegin<override>()<suspends> : void =
# Subscribe — handler is a method at class scope
RespawnPlate.TriggeredEvent.Subscribe(OnPlateTriggered)
# Keep the device alive for the lifetime of the round
loop:
Sleep(1.0)
# -----------------------------------------------------------------------
# Triggered when a player steps on the plate
# -----------------------------------------------------------------------
OnPlateTriggered(Agent : ?agent) : void =
if (A := Agent?):
# Award points using the named constant — no magic 10 here
set PlayerScore += PointsPerRespawn
# Show localised feedback via the HUD device
HudMessage.Show(A)
# Check win condition against the named threshold
if (PlayerScore >= WinThreshold):
HudMessage.Show(A) # reuse device; designer wires the win message in Details
Line-by-line explanation
| Lines | What it teaches |
|---|---|
RespawnDelaySec : float = 3.0 |
Designer-tunable delay — change once in Details, applies everywhere |
PointsPerRespawn : int = 10 |
Named award value — self-documenting, no mystery 10 in arithmetic |
WinThreshold : int = 50 |
Win condition expressed as intent, not a bare 50 in an if |
set PlayerScore += PointsPerRespawn |
Reads like a design doc, not a puzzle |
RespawnMsg<localizes> |
Correct pattern for player-facing text — message type, not string |
Common patterns
Pattern 1 — Named delay constant driving Sleep
Use an @editable float instead of a bare Sleep(5.0). The designer can tune the countdown without opening VS Code.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
countdown_device := class(creative_device):
# Named, tunable — replaces magic number 5.0
@editable
CountdownDurationSec : float = 5.0
# Named, tunable — replaces magic number 3.0
@editable
WarningAtSec : float = 3.0
@editable
EndZone : end_game_device = end_game_device{}
OnBegin<override>()<suspends> : void =
# Sleep for the named duration — intention is crystal clear
Sleep(CountdownDurationSec - WarningAtSec)
# ... show warning UI here ...
Sleep(WarningAtSec)
# Trigger end-game using the device API, not a magic side-effect
EndZone.Activate()
Pattern 2 — Localised text via <localizes> (never raw strings as messages)
Verse's message type requires a <localizes> function. This pattern also avoids the magic-string antipattern.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
# Correct: localised functions produce `message` values
KillMsg<localizes>(Kills : int) : message = "Eliminations: {Kills}"
ObjectiveMsg<localizes>() : message = "Capture the zone!"
kill_tracker_display := class(creative_device):
@editable
InfoDisplay : hud_message_device = hud_message_device{}
@editable
KillPlate : trigger_device = trigger_device{}
# Named constant — replaces magic number 0
var KillCount : int = 0
OnBegin<override>()<suspends> : void =
KillPlate.TriggeredEvent.Subscribe(OnKill)
loop:
Sleep(1.0)
OnKill(Agent : ?agent) : void =
if (A := Agent?):
set KillCount += 1
# KillMsg produces a proper `message` — no StringToMessage needed
InfoDisplay.Show(A)
Pattern 3 — @editable array size threshold replacing a magic index
Array access in Verse fails at runtime if the index is out of bounds. Name your index limits so the intent is obvious and the designer can adjust them.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
wave_spawner := class(creative_device):
# Named: how many waves exist — replaces bare `3` in loop condition
@editable
TotalWaves : int = 3
# Named: seconds between waves — replaces magic 8.0
@editable
TimeBetweenWavesSec : float = 8.0
@editable
Spawner : creature_spawner_device = creature_spawner_device{}
@editable
EndGame : end_game_device = end_game_device{}
OnBegin<override>()<suspends> : void =
var WaveIndex : int = 0
# Loop uses the named constant — no magic 3
loop:
if (WaveIndex >= TotalWaves):
break
Spawner.Spawn()
# Sleep uses the named duration — no magic 8.0
Sleep(TimeBetweenWavesSec)
set WaveIndex += 1
EndGame.Activate()
Gotchas
1. float and int do NOT auto-convert
Verse is strict about numeric types. If RespawnDelaySec is a float and you try to compare it with an int, you get a compile error. Always declare constants with the type the API expects:
# WRONG — Sleep expects float, but you accidentally wrote an int literal
Sleep(3) # compile error: int is not float
# RIGHT
Sleep(3.0)
If you need both, use Float or Int conversion explicitly.
2. message is NOT string — there is no StringToMessage
If you try to pass a raw string where a message is expected (e.g., hud_message_device.SetText), you will get a type error. The only correct pattern is a <localizes> function:
# WRONG
MyDevice.SetText("Round Over") # string, not message — compile error
# RIGHT
RoundOverMsg<localizes>() : message = "Round Over"
# ... then pass RoundOverMsg()
3. @editable defaults must be valid at compile time
You cannot use a runtime expression as an @editable default. Keep defaults as simple literals:
# WRONG — runtime expression as default
@editable
Delay : float = GetSomeRuntimeValue() # compile error
# RIGHT
@editable
Delay : float = 3.0
4. Integer overflow is a runtime error
Verse protects against integer overflow during serialization. If your WinThreshold or score accumulator could theoretically exceed the safe int range, add a guard or use a smaller domain.
5. Named constants don't replace @editable — they complement it
Use @editable for anything a designer should tune in the Details panel. Use a named var or a module-level constant for values that are internal implementation details. Mixing them up leads to cluttered Details panels or values that are hard to change without recompiling.