Welcome to the Map Room, apprentice cartographer. Professor AweShucks has a wall here he is unreasonably proud of: the Hall of Badges, where every zone badge a player has ever earned glows on a brass plaque — even if they earned it three sessions ago and two updates back. The machinery behind that wall is UEFN player persistence: a weak_map(player, ...) holding one <persistable> record per player, saved by the engine itself. This is the same pattern Cinder the dragon taught over in east-volcano's Persisting a Tycoon's State with <persistable> — here we import it wholesale and grow it into the hub's shared IslandProgress profile.
What you will build
The IslandProgress badge profile — the single most load-bearing piece of the AweShucks Town capstone:
- A
<final><persistable>classisland_progresswith onelogicbadge flag per zone, aTownCoinsbalance, and — crucially — aVersion:intfield. - A module-scope
var IslandProgressMap : weak_map(player, island_progress)that the engine saves per player, per island, across sessions. - A migration function that upgrades old-version saves the moment a returning player joins.
- A
hall_of_badges_devicethat stamps badges, lights the Hall glow, and reports progress on Professor AweShucks' announcement board.
Downstream, this exact profile is what lesson 13's portal_gatekeeper module checks before opening a paid-zone booth, what the portal beacon component (lesson 15) reads to decide whether to glow, and what the full AweShucks Town capstone (lesson 18) shares island-wide.
Imported from east-volcano: the <final><persistable> record + copy-<constructor> + weak_map idiom is lifted directly from Persisting a Tycoon's State with <persistable> (compile-passed grounding, itself distilled from Epic's Santa's Toy Factory sample). If that lesson's save_data class looks familiar below — good. It should. We are reusing a proven part, not reinventing it.
Walkthrough
Step 1 — Declare the versioned schema
A persistable class must be <final>, and every field must itself be persistable (primitives, other persistable classes/structs, or arrays/maps of those). We give each zone a logic flag and — the star of this lesson — a Version field, so future-you can evolve the schema without orphaning old saves.
Step 2 — Write the copy-constructor
You cannot mutate a field of a stored persistable record in place. Every update rebuilds a fresh record: override the one field you are changing, then let the copy-<constructor> fill in everything else. This "override one, copy the rest" idiom is exactly the east-volcano tycoon pattern.
Step 3 — Store it in a module-scope weak_map
var IslandProgressMap : weak_map(player, island_progress) = map{} is the standard persistence container: the engine saves each player's entry and restores it next session. Reads and writes are failable ([]), because the key may be absent — so every accessor lives in a <decides><transacts> function and guards with if (not IslandProgressMap[Player]).
Step 4 — Migrate on join
EnsureProfile runs when a player joins (and once at OnBegin for anyone already present). If there is no record, it creates one at the current version. If there is an old-version record, MigrateProgress rebuilds it: stamp the new Version, translate anything that changed meaning (here, v2 grants 50 retroactive TownCoins per badge), and copy the rest through the constructor. Old badges survive untouched.
Step 5 — Grant and read badges
GrantBadge stamps a zone flag via StampBadge; the Hall device reads the profile and lights the glow. Both go through the same failable accessors — no raw map pokes anywhere.
Here is the complete, self-contained device. Drop it in a fresh Verse file, wire the four @editable devices in the editor, and play:
using { /Fortnite.com/Devices }
using { /Fortnite.com/Playspaces }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# ─── The versioned IslandProgress schema ──────────────────────────
# Bump this when the schema changes; MigrateProgress upgrades old saves.
CurrentSaveVersion:int = 2
island_progress := class<final><persistable>:
Version:int = 0
ShellBadge:logic = false # south-shores capstone earned
JungleBadge:logic = false # north-jungle capstone earned
VolcanoBadge:logic = false # east-volcano capstone earned
CoveBadge:logic = false # west-coves capstone earned
DeepsBadge:logic = false # the-deeps capstone earned
TownCoins:int = 0
# Copy-constructor: rebuilds a fresh record from an old one.
MakeIslandProgress<constructor>(Src:island_progress)<transacts> := island_progress:
Version := Src.Version
ShellBadge := Src.ShellBadge
JungleBadge := Src.JungleBadge
VolcanoBadge := Src.VolcanoBadge
CoveBadge := Src.CoveBadge
DeepsBadge := Src.DeepsBadge
TownCoins := Src.TownCoins
# The persisted store — the engine saves one record per player.
var IslandProgressMap:weak_map(player, island_progress) = map{}
# ─── Pure helpers ─────────────────────────────────────────────────
CountBadges(Profile:island_progress)<transacts>:int =
var Count:int = 0
if (Profile.ShellBadge?):
set Count += 1
if (Profile.JungleBadge?):
set Count += 1
if (Profile.VolcanoBadge?):
set Count += 1
if (Profile.CoveBadge?):
set Count += 1
if (Profile.DeepsBadge?):
set Count += 1
Count
# Old save -> current schema. v0/v1 badge holders get retroactive coins.
MigrateProgress(Src:island_progress)<transacts>:island_progress =
if (Src.Version < CurrentSaveVersion):
island_progress:
Version := CurrentSaveVersion
TownCoins := Src.TownCoins + (CountBadges(Src) * 50)
MakeIslandProgress<constructor>(Src)
else:
Src
# Returns a fresh record with the named zone's badge stamped.
StampBadge(Src:island_progress, Zone:string)<transacts>:island_progress =
if (Zone = "south-shores"):
island_progress:
ShellBadge := true
MakeIslandProgress<constructor>(Src)
else if (Zone = "north-jungle"):
island_progress:
JungleBadge := true
MakeIslandProgress<constructor>(Src)
else if (Zone = "east-volcano"):
island_progress:
VolcanoBadge := true
MakeIslandProgress<constructor>(Src)
else if (Zone = "west-coves"):
island_progress:
CoveBadge := true
MakeIslandProgress<constructor>(Src)
else if (Zone = "the-deeps"):
island_progress:
DeepsBadge := true
MakeIslandProgress<constructor>(Src)
else:
Src
# ─── Public API (lesson 13's portal_gatekeeper imports these) ─────
EnsureProfile<public>(Player:player)<decides><transacts>:void =
if (not IslandProgressMap[Player]):
set IslandProgressMap[Player] = island_progress{Version := CurrentSaveVersion}
Existing := IslandProgressMap[Player]
if (Existing.Version < CurrentSaveVersion):
set IslandProgressMap[Player] = MigrateProgress(Existing)
GrantBadge<public>(Player:player, Zone:string)<decides><transacts>:void =
EnsureProfile[Player]
Src := IslandProgressMap[Player]
set IslandProgressMap[Player] = StampBadge(Src, Zone)
# ─── The Hall of Badges device ────────────────────────────────────
hall_of_badges_device := class(creative_device):
# The plinth players interact with to inspect their profile.
@editable
HallButton : button_device = button_device{}
# Booth button that stamps THIS island's badge on completion.
@editable
GrantButton : button_device = button_device{}
# Glow that lights once the inspecting player owns any badge.
@editable
HallGlow : vfx_spawner_device = vfx_spawner_device{}
# Professor AweShucks' announcement board.
@editable
HallHud : hud_message_device = hud_message_device{}
# Which zone badge the GrantButton stamps on this island.
@editable
ThisZone : string = "south-shores"
BadgeCountMsg<localizes>(Count:int):message = "Hall of Badges: {Count} / 5 zone badges earned"
BadgeStampedMsg<localizes>:message = "Badge stamped! Your progress is saved across sessions."
OnBegin<override>()<suspends>:void =
Playspace := GetPlayspace()
# Migrate returning players the moment they arrive.
Playspace.PlayerAddedEvent().Subscribe(OnPlayerJoined)
for (ExistingPlayer : Playspace.GetPlayers()):
if (EnsureProfile[ExistingPlayer]):
Print("Profile ready (schema v{CurrentSaveVersion})")
HallButton.InteractedWithEvent.Subscribe(OnHallInspect)
GrantButton.InteractedWithEvent.Subscribe(OnGrantBadge)
OnPlayerJoined(NewPlayer:player):void =
# A returning player's old save is upgraded HERE, before any
# gate or beacon ever reads it.
if (EnsureProfile[NewPlayer]):
Print("Returning save migrated to v{CurrentSaveVersion}")
OnHallInspect(Agent:agent):void =
if (Player := player[Agent], Profile := IslandProgressMap[Player]):
Badges := CountBadges(Profile)
HallHud.SetText(BadgeCountMsg(Badges))
HallHud.Show(Agent)
if (Badges > 0):
HallGlow.Enable()
OnGrantBadge(Agent:agent):void =
if (Player := player[Agent], GrantBadge[Player, ThisZone]):
HallHud.SetText(BadgeStampedMsg)
HallHud.Show(Agent)
Line-by-line highlights
| Lines | What's happening |
|---|---|
island_progress := class<final><persistable> |
The saved record. <final> is required on persistable classes; every field is a persistable type. |
Version:int = 0 |
The migration hook. A brand-new record defaults to 0 and is immediately stamped to CurrentSaveVersion by EnsureProfile. |
MakeIslandProgress<constructor>(Src...) |
Copy-constructor — copies EVERY field. Forget one here and every update silently resets it. |
var IslandProgressMap:weak_map(player, island_progress) |
Module-scope persisted store. The engine saves one entry per player for this island. |
MigrateProgress |
Rebuilds an old-version record: new Version, retroactive coin bonus, everything else copied through the constructor. |
EnsureProfile[Player] |
Failable (<decides>): creates a missing record, upgrades an old one. Called on join and at startup so nothing downstream ever sees a stale schema. |
set IslandProgressMap[Player] = island_progress: ... |
The tycoon idiom: override the changed field(s), then MakeIslandProgress<constructor>(Src) copies the rest. |
PlayerAddedEvent().Subscribe(OnPlayerJoined) |
Returning players are migrated the moment they arrive — before any gate or beacon reads their profile. |
if (Player := player[Agent], Profile := IslandProgressMap[Player]) |
Chained failable bindings: cast the agent, then read the map, all in one guard. |
A word about "other islands"
Persistable data is scoped per player, per island: your hub island's IslandProgressMap is saved and restored by the engine every time that player returns to this island, but a different published island keeps its own separate store. The archipelago trick is to make the schema itself the shared part: every zone island in your curriculum ships this same island_progress module, so the code that reads and migrates progress is identical everywhere. When a player finishes a zone capstone and travels back through the hub's matchmaking portal (lesson 16), the hub stamps the badge in its store — and the Hall of Badges lights up for good.
Common patterns
Pattern 1 — The gatekeeper query (a <decides> badge check)
Lesson 13's portal_gatekeeper needs a check that fails when the badge is missing — perfect for gating a conditional button. Minimal standalone version:
using { /Verse.org/Simulation }
zone_profile := class<final><persistable>:
Version:int = 1
JungleBadge:logic = false
MakeZoneProfile<constructor>(Src:zone_profile)<transacts> := zone_profile:
Version := Src.Version
JungleBadge := Src.JungleBadge
var ZoneProfileMap:weak_map(player, zone_profile) = map{}
# The gatekeeper check lesson 13 imports: succeeds only when the
# player owns the badge — a perfect <decides> gate for a portal.
HasJungleBadge<public>(Player:player)<decides><transacts>:void =
Profile := ZoneProfileMap[Player]
Profile.JungleBadge?
Pattern 2 — Evolving the schema safely (v3 adds a field)
Adding a field with a default is save-compatible: an old record simply loads with the default. Removing or re-typing a field is what breaks saves — never do it; version and migrate instead.
using { /Verse.org/Simulation }
# v3 adds HallVisits. Adding fields WITH DEFAULTS is save-compatible:
# old records load with HallVisits = 0. Never remove or retype fields.
badge_profile_v3 := class<final><persistable>:
Version:int = 0
ShellBadge:logic = false
TownCoins:int = 0
HallVisits:int = 0 # NEW in v3 — old saves default to 0
MakeBadgeProfileV3<constructor>(Src:badge_profile_v3)<transacts> := badge_profile_v3:
Version := Src.Version
ShellBadge := Src.ShellBadge
TownCoins := Src.TownCoins
HallVisits := Src.HallVisits
var BadgeProfileV3Map:weak_map(player, badge_profile_v3) = map{}
RecordHallVisit<public>(Player:player)<decides><transacts>:void =
if (not BadgeProfileV3Map[Player]):
set BadgeProfileV3Map[Player] = badge_profile_v3{Version := 3}
Src := BadgeProfileV3Map[Player]
set BadgeProfileV3Map[Player] = badge_profile_v3:
Version := 3
HallVisits := Src.HallVisits + 1
MakeBadgeProfileV3<constructor>(Src)
Where this goes next
This profile is the spine of the whole center-village arc:
- Lesson 13 — Badge-Gated Doors imports
EnsureProfile/GrantBadge/ the badge query into theportal_gatekeepermodule: free booths always open, paid-zone booths demand the previous badge. - Lesson 15 — The Portal Beacon reads the same profile from a custom scene-graph component to drive the per-player unlock glow.
- Lesson 18 — Capstone: AweShucks Town shares the IslandProgress schema island-wide: quest rewards deposit TownCoins, capstone returns stamp badges, and the Hall of Badges becomes the town's trophy wall.
Professor AweShucks' parting note: version your saves before you need to. The Version:int = 0 field costs you one line today and saves an island full of returning players tomorrow.