Reference Devices compiles

The Hall of Badges: Cross-Zone Persistence with [player] Maps

A hub island is only a hub if it remembers you. In this lesson you build the IslandProgress badge profile — a versioned `<final><persistable>` record stored in a `weak_map(player, ...)` that survives sessions, migrates old saves gracefully, and lights the Hall of Badges in Professor AweShucks' Map Room. It reuses the compile-proven persistence pattern from east-volcano's tycoon lesson and becomes the profile every portal gate, beacon, and capstone system on the island reads.

Updated Examples verified on the live UEFN compiler

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> class island_progress with one logic badge flag per zone, a TownCoins balance, and — crucially — a Version:int field.
  • 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_device that 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 the portal_gatekeeper module: 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.

Build your own lesson with cross_zone_progress_persistence

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 →