Economy Module: Currency Is an Inventory Stack
Guideadvancedcompiles

Economy Module: Currency Is an Inventory Stack

Updated advancedModulesEconomyCode verified

Overview

The headline is a design decision: this module has no wallet. A player's shell balance is a custom-inventory stack whose id is currency:shells; owning a tycoon buildable is a stack whose id is unlock:shell_press. The economy module ships flows — earn, spend, purchase, unlock, passive income — and zero storage of its own: every balance and ownership flag lives in inv_core.inventory, reached only through its guarded Add/TryRemove/Count/CanAfford API. Our studio's ResourceSystem reached the same unified-API conclusion the hard way — its architecture docs record years of a currency-in-the-bank / item-in-the-inventory split its grant path still special-cases by currency name. This set never special-cases a currency name anywhere.

It's this series' first dependent module set — economy hard-depends on the custom-inventory module's inv_core/inv_types — so it teaches composition itself: riding another module's substrate, inheriting persistence without writing any, and gating honestly as a dependent set (see the Gotchas). If your island runs on earn-spend-progress — shops, unlock ladders, tycoon or raid loops — this is the spine.

Five files, one responsibility each, the arrow one way — device → shop/unlocks → flows → types, plus a single cross-set arrow into the custom-inventory module's inv_core:

File Responsibility Depends on
eco_types.verse shared type surface (price/receipt/tier structs) nothing
eco_flows.verse earn/spend flows + the capped-source registry types, custom-inventory/inv_core
eco_shop.verse price table + the fallible purchase pipeline types, flows, inv_core
eco_unlocks.verse tycoon tiers: unlock (sink) + income (source) types, flows, inv_core
trading_post.verse the worked example device wiring flows, shop, unlocks, inv_core

The worked example is a beach Trading Post — earn at a job dispenser, spend at three shop stations, unlock a Shell Press that pays shells back — compile-verified as a cross-set unit on the real UEFN compiler, Release-41.20.

The module, file by file

eco_types.verse — the shared type surface

Types only, zero logic — and every type is a nominal struct, never a bare alias: the set gate concatenates a mode's files in alphabetical order, eco_flows.verse sorts before this file, and an alias referenced from an earlier-concatenated file hits Verse error 3502; nominal declarations forward-reference fine. The interesting row is the tycoon tier:

unlock_tier<public> := struct:
        Id<public> : string
        Name<public> : string
        Cost<public> : int
        CurrencyName<public> : string
        RequiresTierId<public> : ?string
        IncomeAmount<public> : int
        IncomeCurrencyName<public> : string
        IncomeIntervalSeconds<public> : float

RequiresTierId is the dependency chain as plain data — an optional prerequisite replacing the hard-coded field-per-level shape our studio's UnlockSystem example wires by hand. The receipt structs exist so the notify half of a purchase never re-indexes a table — a no_rollback restriction you'll meet twice below.

eco_flows.verse — earn and spend, on someone else's substrate

The whole no-parallel-wallet design is three lines:

CurrencyId<public>(Name : string)<transacts> : string = "currency:{Name}"

    Balance<public>(Inv : inv_core.inventory, Name : string)<transacts> : int =
        Inv.Count(CurrencyId(Name))

A currency is just a namespaced stack in the SAME inv_core.inventory the player already has. Nothing about a balance lives anywhere else; the set's only var maps are source_registry's cooldown/cap bookkeeping — per-player cooldown state, never a balance.

That <transacts> on CurrencyId is not decorative: string interpolation is a no_rollback effect, and Balance/TrySpend call CurrencyId from <transacts> contexts — unannotated, the real UEFN compiler rejects the set (the string-interpolation effect rule; UnlockStackId needed the identical fix).

Spending reuses the custom-inventory module's split verbatim: inv_core.Remove signals ChangeEvent on success (a no_rollback effect), so it can never be branch-tested from a plain device handler. Hence:

TrySpend<public>(Inv : inv_core.inventory, Name : string, Amount : int)<decides><transacts> : int =
        Inv.TryRemove[CurrencyId(Name), Amount]

    NotifySpent<public>(Inv : inv_core.inventory, Name : string, NewBalance : int) : void =
        Inv.NotifyRemoved(CurrencyId(Name), NewBalance)

TrySpend is the fallible mutation with no signal, safe to test; NotifySpent re-fires the balance-change signal from the caller's own success body. Earn is the opposite: unconditional — a kill reward, a job payout, a dispenser tick always lands. Denial happens before earning, in source_registry.TryGrant — a <decides><transacts> cooldown/cap check with no mutation and no signal — paired with RecordGrant in the caller's success body: the same split, for bookkeeping.

eco_shop.verse — prices as data, purchases as failures

Adding a product is adding a row — zero code per product:

PriceTable<public>() : [string]eco_types.price_entry =
        map{
            "reed_net" => eco_types.price_entry{ProductId := "reed_net", DisplayName := "Reed Fishing Net", Cost := 25, GranterIndex := 0},
            "sun_hat" => eco_types.price_entry{ProductId := "sun_hat", DisplayName := "Woven Sun Hat", Cost := 40, GranterIndex := 1},
            "dive_spear" => eco_types.price_entry{ProductId := "dive_spear", DisplayName := "Dive Spear", Cost := 90, GranterIndex := 2}
        }

Note the shape — a module-scope function returning a freshly built map: a module-scope archetype constant fails Script error 3512, and a module-scope var may only be weak_map-typed; a function is the only legal shape here. Then the purchase:

TryPurchase<public>(Inv : inv_core.inventory, Product : eco_types.price_entry, CurrencyName : string)<decides><transacts> : eco_types.purchase_receipt =
        NewBalance := Inv.TryRemove[eco_flows.CurrencyId(CurrencyName), Product.Cost]
        eco_types.purchase_receipt{ProductId := Product.ProductId, Cost := Product.Cost, NewBalance := NewBalance}

First, Product arrives as a parameter: PriceTable() allocates a fresh map{} every call — a no_rollback effect a <transacts> body forbids — so the plain caller resolves the row and passes it in, sidestepping the restriction entirely. Second, can't-afford simply fails — no clamp, no partial spend — and the receipt carries everything the success body needs without touching the table again. NotifyPurchase re-fires the balance change; actual fulfillment (item_granter_device.GrantItemIndex) belongs to the caller, keeping the file device-agnostic — a vending-machine shop and a conditional-button shop call the identical pair and differ only in fulfillment.

eco_unlocks.verse — the tycoon loop, same substrate

Composition pays off twice in two declarations:

UnlockStackId<public>(TierId : string)<transacts> : string = "unlock:{TierId}"

    IsOwned<public>(Inv : inv_core.inventory, TierId : string)<transacts> : logic =
        logic{Inv.Count(UnlockStackId(TierId)) >= 1}

Owning a tier is owning one unit of an unlock:<TierId> stack in the player's existing inventory — not a second per-domain store. The quiet consequence: the custom-inventory module's inv_persist already snapshots and restores every stack, so unlock ownership persists with zero new persistence code — no new field, no new weak_map. (the custom-inventory module's envelope had even reserved a commented EconomyUnlocks field for this module plan; riding the substrate made it unnecessary.) IsOwned returns a logic value via the same logic{...} cast inv_core.CanAfford uses — it bites below.

TierTable() holds three tiers as data — Shell Press at 50 shells, Kelp Dryer at 150, Trading Dock at 400, each gated behind the last. The fallible half checks three gates in one <decides> body:

TryUnlock<public>(Inv : inv_core.inventory, Tier : eco_types.unlock_tier)<decides><transacts> : eco_types.unlock_receipt =
        Inv.Count(UnlockStackId(Tier.Id)) < 1
        PrereqCount := if (PrereqId := Tier.RequiresTierId?) then Inv.Count(UnlockStackId(PrereqId)) else 1
        PrereqCount >= 1
        NewBalance := Inv.TryRemove[eco_flows.CurrencyId(Tier.CurrencyName), Tier.Cost]
        eco_types.unlock_receipt{TierId := Tier.Id, Cost := Tier.Cost, NewBalance := NewBalance}

Not already owned; prerequisite owned (no prerequisite defaults the count to 1 — auto-satisfied); affordable, via TryRemove — never a hand-rolled subtract. The unconditional NotifyUnlock fires the spend signal and grants ownership in the same breath — Inv.NotifyRemoved(...) then Inv.Add(UnlockStackId(Tier.Id), 1); Add always succeeds, so the grant is a second plain call in the success body. The income half carries the set's other new compiler lesson:

CollectIncome<public>(Inv : inv_core.inventory) : void =
        Table := TierTable()
        for (Id -> Tier : Table):
            if (IsOwned(Inv, Id)?):
                eco_flows.Earn(Inv, Tier.IncomeCurrencyName, Tier.IncomeAmount)

Table is hoisted deliberately — for (Id -> Tier : TierTable()): fails like a no_rollback call in an if: condition (the loop-iterable effect rule) — and IsOwned(Inv, Id)? needs its postfix ? because a logic is a value, not a failable expression; both get full write-ups in the Gotchas.

trading_post.verse — the worked example

Device wiring only. Every real decision — can they earn? afford it? already own it? — is answered by the framework; this file resolves agent → player → inventory at each device event and calls in. The shared purchase handler is the centerpiece:

The table resolves as a plain first statement, the fallible index sits inside the if: chain, and the else branch is fail-don't-clamp made visible — the exact cost read back to the player. OnBuildAttempt follows the identical shape for the Shell Press unlock; OnJobCompleted runs the earn side (TryGrant → RecordGrant → Earn, with a "try again shortly" denial path). RunIncomeLoop is the set's one deliberate timer — the same isolated-exception discipline the NPC state-machine module established — waking every IncomeIntervalSeconds to CollectIncome for every player in the playspace.

Walkthrough

Dropping it in. Both module directories go into your project — custom-inventory/ (the substrate) and economy/. Place a trigger_device (the job), three conditional_button_devices (shop stations — native key-item costs deliberately unconfigured: our balance is the truth, devices are only interaction surfaces), an item_granter_device holding the three products (GranterIndex maps each price_entry to a granter slot), a second trigger_device (the build site), and hud_message_devices for feedback. Drop a trading_post device and link them in its details panel. Out of the box: jobs pay 10 shells on a 5-second cooldown, gear runs 25–90, the Shell Press unlocks at 50 and then pays 1 shell every 20 seconds, the Kelp Dryer and Trading Dock gated behind it.

What composition buys you. The arrow is one-way: economy imports inv_core; the custom-inventory module never learns economy exists. Because NotifySpent/NotifyPurchase/NotifyUnlock re-fire the same ChangeEvent the pouch UI already awaits, every balance change renders in the custom-inventory module's pouch panel with zero UI code here. Because balances and unlocks are ordinary stacks, inv_persist.SaveFor/RestoreFor's snapshot round-trip persists the whole economy — composing into the island's one add-only player_save envelope without adding a field. Deeper save strategy — versioned evolution, session maps, event systems — belongs to the planned Persistence & Save Data advanced module; here the envelope just keeps working.

Gotchas

the economy module surfaced two compiler rules you'll meet again across this series — the string-interpolation effect rule and its loop-iterable sibling — alongside the persistence rules (the persistable weak_map rule) this set satisfies by construction. Its own lessons:

A dependent set gates as a combined unit, with its dependencies pinned. Per-file runs fail on cross-file identifiers by design — compiled alone, eco_flows (9 errors), eco_shop (13) and eco_unlocks (20) fail on Unknown identifier eco_types, trading_post (20) on inv_core — all Script error 3506, NEEDS-SET; only eco_types.verse passes standalone. The compile truth is the cross-set SET verdict: PASS, 0 errors, on the real UEFN compiler — with the external inv_core/inv_types md5s recorded so the PASS is attributable to exact, named dependency bytes. The pin is not ceremony: inv_core has already taken an API-neutral follow-up on main since this build was verified (a local rename — the shadowed-name pitfall), and the build record pins exactly which version of the dependency this PASS covered. When a dependency moves, re-gate; never re-assert. That's the standard for dependent module plan: no honest verification without pinned dependencies.

String interpolation is a no_rollback effect (the string-interpolation effect rule). A bare "currency:{Name}" inside a <transacts> context fails the set. Annotate the string-building helper itself <transacts> and the callers stay legal — CurrencyId and UnlockStackId both needed this exact fix on the real compiler.

A for's iterable position is restricted like an if: condition (the loop-iterable effect rule). for (Id -> Tier : TierTable()): fails at the () call site (Script error 3512) because TierTable() allocates. Hoist: Table := TierTable() as its own statement, then iterate Table.

A logic needs ? to become a branch test. if (IsOwned(Inv, Id)): fails Script error 3513 ("expected an expression that can fail") — the logic{...} cast is one-way; the postfix ? query is the way back.

Tables resolve in the plain caller. TryPurchase/TryUnlock take an already-resolved Product/Tier parameter rather than indexing internally — the fresh-map{} allocation is the no-allocation-in-a-condition rule's effect, and a <transacts> body is no place to gamble on where its boundary sits. The handler resolves the table first, as a bare statement; only the fallible index goes in the condition list.

Get the complete code — free

You've read the full walkthrough. The complete, copy-paste-ready Verse solution is free for members — sign in to unlock it.

Free with your BrainDead.TV / BrainDeadGuild Discord account. The walkthrough above is always free.

Check your understanding

Test yourself with an interactive quiz and track your progress + earn XP — free for members.

Turn this into a guided course

Add A data-driven currency economy composed entirely on top of the custom-inventory module — earn/spend flows, a fallible shop pipeline, tycoon-style unlocks with prerequisites and passive income, and zero parallel storage: every balance and every owned tier is an inv_core stack, so the custom-inventory module's save envelope persists it all for free to your free study plan — we'll suggest related pages and stitch the lot into one compile-checked, self-guided lesson with worked examples and quizzes.

Original guide generated by Verse Island from the Verse/UEFN knowledge base, with references to the Epic Games sources above. Code is validated against the knowledge base.

Comments

    Sign in to vote, comment, or suggest an edit.Sign in