Badge-Gated Doors: Conditional Buttons and Unlock Logic

In this lesson you'll learn to

  • βœ“Student can gate interactions with conditional_button_device and a Verse gatekeeper module that checks the persistence profile.

Student can gate interactions with conditional_button_device and a Verse gatekeeper module that checks the persistence profile.

πŸ“– Reference & full walkthrough: Badge-Gated Doors: Conditional Buttons and Unlock Logic

πŸ” Builds on: north-jungle: verse-modules / shared-helper-module (import the gatekeeper as a module)

🧩 Your capstone piece: portal_gatekeeper module: free booths always open, paid-zone booths require the previous badge


Professor AweShucks has a rule about the five portal booths ringing the plaza: anyone may sail back to the free shores, but the paid-zone portals only open for creators who finished the zone before it. Two very different checks make that rule work β€” and knowing which check lives where is the whole lesson. The conditional_button_device natively verifies items (a booth fee of town coins). Your Verse gatekeeper module verifies badges β€” persistent, account-level progress the device panel can't see. Put each check where it belongs and the plaza runs itself.

What you will build

The portal_gatekeeper module β€” the capstone piece AweShucks Town's hub imports at every booth:

  • A PortalGatekeeper module exposing badge ids, HasBadge, GrantBadge, and the one gate that matters: CanEnter[Player, RequiredBadge].
  • The rule from the capstone spec: free booths always open (RequiredBadge = 0), paid-zone booths require the previous zone's badge.
  • A portal_booth_device you stamp five times around the plaza β€” one @editable RequiredBadge per booth is the only per-booth configuration.
  • The badge profile rides the persistence pattern from the Cross-Zone Progress lesson (class<final><persistable> + weak_map(player, ...) + copy-constructor), and the module technique is the same one you learned in the North Jungle when you built and imported CodewoodKit.

Walkthrough

Step 1 β€” Decide which check goes where

Check Owner Why
Booth fee (town coins in inventory) conditional_button_device The device counts key items natively and fires NotEnoughItemsEvent for free β€” no Verse needed.
Zone badge (finished the previous capstone) Verse PortalGatekeeper module Badges live in a persistable profile that survives sessions. Devices can't read a weak_map β€” only Verse can.

The device is the bouncer checking what's in your pockets right now; the module is the town registry checking what you've earned, ever.

Step 2 β€” Declare the module and its persistent profile

PortalGatekeeper<public> := module: opens the named box. Inside it: four public badge-id constants, the island_progress persistable class (the badge profile the Cross-Zone Progress lesson established), its copy-constructor, and the module-scope weak_map that the engine persists per player.

Step 3 β€” Write the three public verbs

  • HasBadge[Player, Badge] β€” failable lookup: read the profile, Find the badge id in the earned array.
  • GrantBadge[Player, Badge] β€” the copy-constructor idiom: build a fresh island_progress overriding only Badges, copying everything else, so an award never wipes another field. It's idempotent β€” granting twice stores once.
  • CanEnter[Player, RequiredBadge] β€” the gate. When RequiredBadge is 0 the if body never runs, so the check always succeeds: that's the free booth. Otherwise it delegates to HasBadge.

Step 4 β€” Wire the booth device

portal_booth_device subscribes to both button events. ActivatedEvent fires only after the device's own item check passed β€” that's the moment Verse layers the badge check on top. NotEnoughItemsEvent fires when the item check failed, so you get the "can't afford the fee" case for free.

Here is the complete script β€” module and booth device in one file:

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

# Localized helper so we can pass dynamic text as a `message`.
BoothPrompt<localizes>(S : string) : message = "{S}"

# ══════════════════════════════════════════════════════════════════
# PortalGatekeeper β€” the module every booth in AweShucks Town imports.
# Same technique as CodewoodKit from the North Jungle: one named box,
# public surface on top, private plumbing underneath.
# ══════════════════════════════════════════════════════════════════
PortalGatekeeper<public> := module:

    # Badge ids β€” one per zone capstone, in curriculum order.
    BadgeShellHunt<public>   : int = 1
    BadgeModRally<public>    : int = 2
    BadgeVolcano<public>     : int = 3
    BadgeCoastalRace<public> : int = 4

    # The persistent badge profile from the Cross-Zone Progress lesson.
    island_progress<public> := class<final><persistable>:
        Version : int = 0
        Badges : []int = array{}

    # Copy-constructor: rebuild a fresh record around one change.
    MakeIslandProgress<constructor>(Src : island_progress)<transacts> := island_progress:
        Version := Src.Version
        Badges := Src.Badges

    # One profile per player, persisted across sessions by the engine.
    var ProgressMap : weak_map(player, island_progress) = map{}

    # Make sure the player has a profile row before any read/write.
    EnsureProfile<public>(Player : player)<transacts><decides> : void =
        Player.IsActive[]
        if (not ProgressMap[Player]):
            set ProgressMap[Player] = island_progress{}

    # Succeeds only when Player has earned Badge.
    HasBadge<public>(Player : player, Badge : int)<transacts><decides> : void =
        Progress := ProgressMap[Player]
        Progress.Badges.Find[Badge]

    # Award a badge (idempotent β€” granting twice stores it once).
    GrantBadge<public>(Player : player, Badge : int)<transacts><decides> : void =
        EnsureProfile[Player]
        Src := ProgressMap[Player]
        if (not Src.Badges.Find[Badge]):
            set ProgressMap[Player] = island_progress:
                Badges := Src.Badges + array{Badge}
                MakeIslandProgress<constructor>(Src)

    # THE gate. Free booths pass RequiredBadge = 0 β€” always open.
    # Paid booths pass the PREVIOUS zone's badge id.
    CanEnter<public>(Player : player, RequiredBadge : int)<transacts><decides> : void =
        EnsureProfile[Player]
        if (RequiredBadge > 0):
            HasBadge[Player, RequiredBadge]

# ══════════════════════════════════════════════════════════════════
# One portal booth = one of these devices, wired in the editor.
# ══════════════════════════════════════════════════════════════════
portal_booth_device := class(creative_device):

    # The conditional button players interact with at the booth.
    @editable
    BoothButton : conditional_button_device = conditional_button_device{}

    # Fires the booth's open-portal wiring (lights, matchmaking portal enable).
    @editable
    OpenPortalTrigger : trigger_device = trigger_device{}

    # 0 = free booth (South Shores). Paid booths use PortalGatekeeper badge ids.
    @editable
    RequiredBadge : int = 0

    # Booth name shown in the interaction prompt.
    @editable
    BoothName : string = "South Shores"

    OnBegin<override>()<suspends> : void =
        BoothButton.SetInteractionText(BoothPrompt("Travel to {BoothName}"))
        BoothButton.ActivatedEvent.Subscribe(OnBoothActivated)
        BoothButton.NotEnoughItemsEvent.Subscribe(OnMissingItems)
        BoothButton.Enable()

    # The DEVICE's item check already passed β€” now run the VERSE badge check.
    OnBoothActivated(Agent : agent) : void =
        if (Player := player[Agent], PortalGatekeeper.CanEnter[Player, RequiredBadge]):
            OpenPortalTrigger.Trigger(Agent)
            Print("Portal open β€” safe travels to {BoothName}!")
        else:
            BoothButton.SetInteractionText(BoothPrompt("Locked β€” earn the previous zone's badge first"))

    # The DEVICE said no: the player lacks the key items (the booth fee).
    OnMissingItems(Agent : agent) : void =
        Print("Booth fee not met β€” the button's own item check failed.")

Step 5 β€” Split it into files (the module import)

In the real hub project the module gets its own file and each booth script imports it, exactly like importing CodewoodKit in the North Jungle:

# --- file: Hub/booths.verse ---
# The gatekeeper lives in its own file/module; every booth imports it β€”
# exactly how you imported CodewoodKit back in the North Jungle.
using { PortalGatekeeper }

Step 6 β€” Stamp out the five booths in the editor

  1. Place a conditional_button_device + a trigger_device at each booth; set the button's key item (Town Coin) and count in its panel if the booth charges a fee.
  2. Place one portal_booth_device per booth and wire BoothButton / OpenPortalTrigger.
  3. Set RequiredBadge: 0 for South Shores (free), 1 (BadgeShellHunt) on the North Jungle booth, 2 on the Volcano booth, and so on down the chain.
  4. The OpenPortalTrigger feeds whatever opens the portal β€” in the finished hub, the matchmaking portal's enable wiring.

Common patterns

Awarding the badge when a player returns from a zone

The other half of the gate: the return pad at the hub grants the badge the next booth requires.

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

# Minimal gatekeeper (same shape as the main lesson) so this file is
# self-contained. In your project it lives once, in its own file.
BadgeVault<public> := module:

    badge_profile<public> := class<final><persistable>:
        Version : int = 0
        Badges : []int = array{}

    MakeBadgeProfile<constructor>(Src : badge_profile)<transacts> := badge_profile:
        Version := Src.Version
        Badges := Src.Badges

    var Profiles : weak_map(player, badge_profile) = map{}

    GrantBadge<public>(Player : player, Badge : int)<transacts><decides> : void =
        Player.IsActive[]
        if (not Profiles[Player]):
            set Profiles[Player] = badge_profile{}
        Src := Profiles[Player]
        if (not Src.Badges.Find[Badge]):
            set Profiles[Player] = badge_profile:
                Badges := Src.Badges + array{Badge}
                MakeBadgeProfile<constructor>(Src)

# Placed at the hub's return pad: the zone capstone teleports finishers
# here, the trigger fires, and the badge lands in the profile.
badge_award_device := class(creative_device):

    @editable
    ReturnTrigger : trigger_device = trigger_device{}

    # Which badge this pad awards (1 = Shell Hunt, 2 = Mod Rally, ...).
    @editable
    BadgeToGrant : int = 1

    OnBegin<override>()<suspends> : void =
        ReturnTrigger.TriggeredEvent.Subscribe(OnReturned)

    OnReturned(MaybeAgent : ?agent) : void =
        if (Agent := MaybeAgent?, Player := player[Agent], BadgeVault.GrantBadge[Player, BadgeToGrant]):
            Print("Badge banked β€” the next booth on the plaza just unlocked.")

Letting the device do all the money work

When a booth just charges coins with no badge rule, don't write a Verse check at all β€” configure the device and listen:

using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

FeePrompt<localizes>(S : string) : message = "{S}"

# A booth that ALSO charges town coins β€” the device handles the item
# cost natively; Verse only tunes it and reads the result.
fee_booth_device := class(creative_device):

    @editable
    BoothButton : conditional_button_device = conditional_button_device{}

    # Key item index 0 = Town Coin (configured in the device panel).
    CoinIndex : int = 0

    @editable
    CoinCost : int = 5

    OnBegin<override>()<suspends> : void =
        # Enforce the fee in code so it's tuned from one place.
        BoothButton.SetItemCountRequired(CoinIndex, CoinCost)
        BoothButton.SetInteractionText(FeePrompt("Pay {CoinCost} coins to travel"))
        BoothButton.Enable()
        BoothButton.ActivatedEvent.Subscribe(OnPaid)

    OnPaid(Agent : agent) : void =
        # HasAllItems is <decides> β€” usable as a readiness probe elsewhere too.
        if (BoothButton.HasAllItems[Agent]):
            Print("Player could pay again β€” coins to spare.")
        else:
            Print("Fee paid β€” that was their last coinful.")

Where this goes next

This module is the beating heart of the AweShucks Town hub capstone. The very next lessons consume it: the portal-gate scene-graph prefab reuses one gatekeeper call per booth, and the matchmaking-portal-device lesson enables the five live zone portals only when PortalGatekeeper.CanEnter says so. By the final integration lesson, every paid door in town swings on the hinge you just built β€” and the Halo of Badges above the plaza lights straight from island_progress.Badges.

πŸ”’

Keep going β€” free

You've read the intro. The rest of this lesson is free for members. Sign in to continue and track your progress.

Sign in free to continue this lesson
Pass the quiz above to chart this quest in your Journal.

Sources

/guides/portal-gating-conditional-button
Source

Biloxi Studios original lesson

Guided course

Add this lesson to your free study plan.

🧭 The Keeper’s log

Quest complete? Chart your next heading from the 🧠 Center Village (Free) expedition.

β›΅ Take me there