
Custom Inventory Module: Session Stacks, One Save Envelope
Overview
The honest boundary first — the same line the custom-items module drew. Epic's native item slots are the fort_inventory_component family under /Fortnite.com/Itemization — the right tool when a grant should land as a real Fortnite item in the real Fortnite inventory. This module is the other lane: items as your own data — your ids, your stack rules. The custom-inventory module never touches the native family: typed per-player stacks with a capacity, a guarded Add/Remove/Count/CanAfford API, a change-event-driven pouch UI, an optional persistence layer, and a worked example — collect shells, kelp, and driftwood off triggers, spend shells at a trade station. Compile-verified as a set on the real UEFN compiler, Release-41.20.
It's a wave-1 foundation: every sibling domain — the custom-items module's items, the economy module's economy, farming, shops — imports inv_core for its stacks instead of re-deriving its own map-of-counts (the series' DRY rule) — so the two decisions this set encodes matter beyond the pouch:
- Live state is session state.
inv_corekeys its runtime store bysession, not byplayer— a classification, not a workaround. - Persistent state is ONE envelope. An island gets at most two persistent weak_maps, and a published one's shape is frozen forever — so per-player saves consolidate into a single
player_saverecord that future systems add fields to, never a second map.
Five files, one responsibility each, the dependency arrow one way — device → UI/persist → core → types:
| File | Responsibility |
|---|---|
inv_types.verse |
shared type surface (stack_change) |
inv_core.verse |
the framework: inventory + the guarded per-round registry |
inv_ui.verse |
pouch UI, subscribes to ChangeEvent |
inv_persist.verse |
the player_save envelope |
scavenger_pouch.verse |
teachable example device |
The module, file by file
inv_types.verse — the shared type surface
Types only, zero logic. The one type is the change-event payload every UI row re-renders from:
stack_change<public> := struct:
Id<public> : string
NewCount<public> : int
Nothing else about a mutation — who did it, why — crosses the module boundary; the struct-not-alias choice is a Gotcha below.
inv_core.verse — the framework, and the session-vs-persistent decision
Pure Verse, no devices. One player's stacks live in an inventory class: a shared Capacity ceiling (per-item caps are the custom-items module's job), a private Stacks map nothing writes directly, and a ChangeEvent that fires after every successful mutation. Count reads with a default; CanAfford teaches something big:
CanAfford<public>(Id : string, Amount : int)<transacts> : logic =
logic{Count(Id) >= Amount}
>= doesn't evaluate to a logic value in Verse — comparisons are failure-context expressions that succeed or fail. logic{...} is the documented cast from a failable expression to an actual logic value. Both carry <transacts> out of necessity: a function called from another <transacts> context must itself be <transacts>, or the caller fails with a "no_rollback effect not allowed by its context" error.
Add always succeeds, clamping so a stack never exceeds Capacity, and is deliberately not <transacts>: ChangeEvent.Signal has a no_rollback effect (signaling subscribers is a real, irreversible side effect), and <transacts> functions cannot call no_rollback ones. Remove is the opposite temperament — it fails, never clamps:
Remove<public>(Id : string, Amount : int)<decides> : void =
Count(Id) >= Amount
Next : int = Count(Id) - Amount
if (set Stacks[Id] = Next) {}
ChangeEvent.Signal(stack_change{Id := Id, NewCount := Next})
The gate is the bare comparison as the whole <decides> effect. Called with insufficient funds, the entire call fails: Stacks is never touched, ChangeEvent never fires.
But Remove has a compiler-enforced restriction — the read-the-line moment of the set: a <decides> function whose success path signals an event may only be invoked bare, from another <decides> function; testing it with if, or, or logic{} is rejected ("This invocation calls a function that has the 'no_rollback' effect, which is not allowed by its context"). A device event handler can't comply — Subscribe's callback is plain agent->void. Hence the split:
TryRemove<public>(Id : string, Amount : int)<decides><transacts> : int =
Count(Id) >= Amount
Next : int = Count(Id) - Amount
if (set Stacks[Id] = Next) {}
Next
NotifyRemoved<public>(Id : string, NewCount : int) : void =
ChangeEvent.Signal(stack_change{Id := Id, NewCount := NewCount})
TryRemove is the same fallible mutation with no Signal — no no_rollback effect, safe to branch-test from a plain function. NotifyRemoved fires the identical payload from the caller's own success body, never from inside a condition.
Then the store — the session-vs-persistent decision. A module-scoped var may only be weak_map-typed, and the digest tags player itself <persistent>: a module-scoped weak_map(player, X) routes through the save system, which requires X to be <persistable>. inventory holds a live event, which can never be persistable — the combination reliably fails to compile ("Persistent var weak_map type must have persistable value type"). That's the compiler telling the truth: a registry of live events is runtime state, and runtime state belongs to the round, not the save file. session carries no <persistent> tag, so the sanctioned shape is one session-keyed registry holding an ordinary class-field map (unrestricted — class-scoped, not module-scoped):
inv_registry<public> := class:
var Inventories : [player]inventory = map{}
var RegistryMap : weak_map(session, inv_registry) = map{}
GetRegistry is the only function that touches RegistryMap; everything else reaches a player's inventory through For:
For<public>(Player : player, ?DefaultCapacity : int = 20)<decides><transacts> : inventory =
Registry := GetRegistry[]
Registry.For[Player, DefaultCapacity]
Player.IsActive[] inside the registry is the liveness guard — the join/leave landmine applies to a plain map as much as a raw weak_map; treat a For failure as "do nothing" (a same-tick leave race).
inv_ui.verse — the pouch panel
The UI never polls. pouch_panel.Show builds a canvas of per-item text rows via the canonical GetPlayerUI[]/AddWidget/canvas trio (grounded on samples/dynamic_ui_button_grid.verse), then loops forever applying the change feed:
PlayerUI.AddWidget(Canvas, player_ui_slot{InputMode := ui_input_mode.None})
loop:
Change := Source.ChangeEvent.Await()
ApplyChange(Change)
Because suspends and decides can't share one function signature, the failable GetPlayerUI[] is handled inside the body (an if is a failure context too) — if it can't resolve, Show returns having done nothing. Rows are keyed by item id, so a repeat change updates the same text_block instead of stacking duplicates.
inv_persist.verse — the island's ONE save envelope
Here's the headline: the island save-slot rule (a hard platform constraint). An island gets at most two persistent weak_maps, and a persistent weak_map's data structure is immutable once published — change its shape and you break players' saved data. So per-player persistent state consolidates into ONE <persistable> record:
player_save<public> := class<final><persistable>:
Version<public> : int = 1
# the custom-inventory module: the scavenger pouch's stacks.
PouchStacks<public> : []saved_stack = array{}
# Future systems add fields BELOW this line (add-only), e.g.:
# EconomyUnlocks<public> : []saved_unlock = array{} (economy module)
# FarmingLevel<public> : int = 0 (farming module)
That comment block is load-bearing. the economy module's economy unlocks and the farming module's farming progression do not declare their own persistent weak_maps — they append fields below that line, with defaults, and bump Version. Add-only, forever: never remove, rename, or retype an existing field — old saves must keep loading. It's our studio's shipped SaveSystem player_persistent_data pattern applied to the module library; player_save sits behind the island's single persistent slot, leaving the second free. (Stacks save as flat saved_stack rows — persistable classes hold flat data only.)
Saving is a read-modify-write of the whole record:
Existing := if (Prior := PlayerSaves[Player]) then Prior else player_save{}
Record : player_save = player_save{
Version := Existing.Version,
PouchStacks := Rows
}
if (set PlayerSaves[Player] = Record) {}
Construct a fresh envelope over an existing record and you'd wipe every other system's saved fields. RestoreFor replays saved stacks back through the normal guarded Add path — restoring never bypasses the framework's API. inv_core itself has zero persistence coupling — a domain that doesn't need saves never imports this file.
That's deliberately the whole persistence story here: this is a foundational module, so the pouch ships the canonical envelope and moves on. Persistence done properly — save-structure strategy, versioned evolution, EventSystem and session-map techniques — lives in the dedicated Persistence & Save Data module coming later in this series.
scavenger_pouch.verse — the worked example
Device wiring only; all game logic stays in the framework. Three trigger pickups funnel into one handler resolving agent → player → inventory:
Collect(MaybeAgent : ?agent, Id : string, Amount : int) : void =
if:
Agent := MaybeAgent?
Player := player[Agent]
Inv := inv_core.For[Player]
then:
Inv.Add(Id, Amount)
And the spend flow is the TryRemove/NotifyRemoved split earning its keep — OnTradeAttempt is a plain Subscribe callback, so it branches on TryRemove, never on Remove:
The else branch is fail-don't-clamp made visible: it runs precisely when the player can't afford the trade, and the message reads back the actual count via Inv.Count(SpendId). The trade station's native key-item cost stays unconfigured on purpose: our inventory is the source of truth; the device is only the interaction surface.
Walkthrough
Dropping it in. Place three trigger_devices (shell/kelp/driftwood pickups), a conditional_button_device (trade station, native cost unconfigured), an item_granter_device, and a hud_message_device; drop a scavenger_pouch device and link all six in its details panel. A trigger adds one of its item, live in the pouch; the trade station spends 5 shells for the reward, or shows "need 5 — only have M".
Composing with the custom-items module — the one-pipeline swap. The custom-items module funnels both acquisition lanes — scene-graph pickups and the device lane — through one boundary, item_acquire_sink.Add, whose (Player, Id, Amount) -> void shape was chosen to match this module's API. Its shipped sink, item_device_bridge.Add, forwards to a native item_granter_device.GrantItem today. Swap that one method body — resolve inv_core.For[Player] (failable; guard it the way Collect does) and call Add(Id, Amount) — and that's the entire integration step: every shell picked up lands in this inventory, the pouch updating through the same ChangeEvent feed.
Gotchas
the custom-inventory module's persistence discoveries — the persistable weak_map rule — recur throughout this series. The module's own lessons:
Gate the set, not the files. Per-file compile runs fail on cross-file identifiers by design: compiled alone, inv_core (3 errors), inv_ui (5) and inv_persist (7) fail on Unknown identifier inv_types, scavenger_pouch (8) on inv_core — all Script error 3506, verdict NEEDS-SET; only inv_types.verse, which depends on nothing, passes standalone. The compile truth is the SET verdict: PASS, 0 errors, on the real UEFN compiler.
weak_map(player, X) is a persistence declaration (the persistable weak_map rule). At module scope it forces X <persistable>; live per-player state gets the session + class-field-map shape instead. Corollary, a known Verse compiler pitfall: a module-scoped var must be weak_map-typed at all — which is why per-player stores look like this.
The save budget is real (the two-weak-map island limit/23/24). At most two persistent weak_maps per island; a published map's shape is immutable; hence one player_save envelope, add-only evolution, read-modify-write on save.
Save on checkpoints, not per-Add. Persistable writes are a save-system operation, not a per-mutation cost — SaveFor belongs on round-end or checkpoint.
The no_rollback split is transitive. A wrapper that merely calls Remove bare inherits the same no-branch-test restriction the instant anything tests it — verified across more than a dozen real compile runs; neither or nor the empty-body if (X) {} idiom escapes it. Split mutation from notification, as TryRemove/NotifyRemoved do.
A struct, not a type alias. The set gate compiles the mode's files concatenated in alphabetical file order (inv_core.verse before inv_types.verse), and a bare type alias referenced from an earlier-concatenated file hits Verse error 3502; nominal struct/class/enum declarations forward-reference fine. stack_change is a struct for exactly that reason.
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 reusable five-file per-player inventory outside Fortnite's native item slots — a guarded Add/Remove/Count/CanAfford API, a change-event-driven pouch UI, session-keyed live state, and the island's ONE player_save persistence envelope, with the custom-items module's item pipeline pre-wired as a one-method swap 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.