
Custom Items Module: An Item Is an Entity Wearing a Component
Overview
Until now, our modules made a world item the old way: a device, a trigger volume, some glue. This one makes an item what SceneGraph says it should be — an entity wearing a pickup component. The custom_items module is this series' first set built on /Verse.org/SceneGraph's interactable-component surface, grounded on the live Release-41.20 digest and compile-verified as a set on the real UEFN compiler.
The honest line first, because it shapes everything else. Epic already ships a native pickup — fort_item_pickup_interactable_component, under /Fortnite.com/Itemization — and a real native inventory family behind it (the fort_inventory_component family — Fortnite's real native item slots). Both Epic's pickup and ours subclass the same base, basic_interactable_component, confirmed open for user subclassing. The difference is what each drags in. Epic's requires the owning entity to also carry an item_component and wires into fort_inventory_component — the right tool when a pickup should land as a real Fortnite item in the real Fortnite inventory. Ours needs only a mesh_component (for the sink/respawn visual) and never touches item_component or the native inventory family at all — the right tool when items are your own data: your ids, your rarity_tier (Fortnite's native rarity system is a separate thing for native items), your stack rules, headed for the custom-inventory module's custom inventory. This set even keeps a foot in both worlds: its shipped sink forwards grants to a native item_granter_device, so today's build grants real items while the custom lane stays swappable.
One more rule holds the set together: two acquisition lanes, one boundary. The scene-graph pickup lane and the classic device lane both call item_defs.item_acquire_sink.Add and nothing else — grep the mode dir for .Add( and you'll find exactly two call sites and one implementation.
The module, file by file
The compile-verified set is four files; three make the module, and the fourth — shell_collection.verse, the beach-shells worked example — wires them into a level (the Walkthrough drives it).
One script, one identity — the rule this set is built on. A
.versefile declares EITHER acreative_deviceOR a scene-graph component — never both in the same file. They're different kinds of script with different lifetimes: components run in edit AND at play; devices wake at game start. Separate files coordinate instead — and a device spawning entities or wiring components from itsOnBeginis Epic's own documented bridge between the two worlds. That's why this module keeps its component file and its device files strictly apart, joined only by an interface.
The dependency arrow points one way: item_pickup and item_device_bridge each import item_defs, which imports nothing.
item_defs.verse — items as data, and the ONE acquire boundary
Adding an item to this system is adding a table row, never a new class. The whole item model is one enum and one struct:
rarity_tier<public> := enum:
Common
Uncommon
Rare
Legendary
# One item, as DATA. Adding a new collectible is adding a Registry row
# below, never a new class (Stage 2).
item_def<public> := struct:
Id<public> : string
DisplayName<public> : string
StackCap<public> : int
Rarity<public> : rarity_tier
# Opaque id an effects system (not this module's job) resolves to a
# concrete effect. false = purely a collectible, does nothing.
EffectsHookId<public> : ?string
EffectsHookId := false means "purely a collectible, does nothing" — resolving a hook id to a concrete effect is deliberately some other module's job. The registry is where ids resolve to data, and it's a function, not a module-scope constant — the Gotchas section explains why the compiler forces that shape:
Registry<public>() : [string]item_def =
map{
"shell_common" => item_def{Id := "shell_common", DisplayName := "Common Shell", StackCap := 20, Rarity := rarity_tier.Common, EffectsHookId := false},
"shell_uncommon" => item_def{Id := "shell_uncommon", DisplayName := "Iridescent Shell", StackCap := 20, Rarity := rarity_tier.Uncommon, EffectsHookId := false},
"shell_rare" => item_def{Id := "shell_rare", DisplayName := "Starlight Shell", StackCap := 10, Rarity := rarity_tier.Rare, EffectsHookId := false},
"shell_legendary" => item_def{Id := "shell_legendary", DisplayName := "Kraken's Pearl Shell", StackCap := 5, Rarity := rarity_tier.Legendary, EffectsHookId := false}
}
Last, the centerpiece contract. Both acquisition lanes hand off through this interface and nothing else:
item_acquire_sink<public> := interface:
Add<public>(Player : player, Id : string, Amount : int) : void
Two things about that little interface do a lot of work. Its shape — (Player, Id, Amount) -> void — already matches the custom-inventory module's inv_core.For(Player).Add(Id, Amount) exactly, which is what makes the future inventory swap a one-method edit. And Add is deliberately left undecorated (not <transacts>) so any concrete sink can call native grant/attach APIs, which carry a no_rollback effect.
item_pickup.verse — the scene-graph pickup component
Here's the read-the-line moment of the whole set. Start at the class header and stop on the last field:
item_pickup_entity_component<public> := class(basic_interactable_component):
@editable
DefId<public> : string = "shell_common"
# Seconds the item entity stays sunk/hidden after a successful pickup
# before it respawns in place (Stage 4: "spawn rule ... owned by the
# component, not global").
@editable
RespawnSeconds<public> : float = 12.0
# The acquire boundary. Constructed by whoever spawns/wires this
# component (item_device_bridge.verse or shell_collection.verse) —
# never defaulted here, so a pickup can't silently exist without
# somewhere for its grants to go.
Sink<public> : item_defs.item_acquire_sink
Sink has no default value. That's not style — it's the ONE-pipeline rule made structural: Verse won't let you construct an item_pickup_entity_component without supplying somewhere for its grants to go, so a pickup that bypasses the boundary can't even exist. Whoever spawns the component wires it.
The lifecycle mirrors Epic's own native pickup exactly — override OnAddedToScene/OnRemovingFromScene, never OnStarted, and let the native interact system drive Start()/CanInteract() while you react to the inherited SucceededEvent:
# Mirrors fort_item_pickup_interactable_component's own shape exactly
# (Stage 8.2): it overrides OnAddedToScene/OnRemovingFromScene on this
# same base and does NOT override OnStarted — the native interact
# system drives Start()/CanInteract() on its own; this component only
# needs to react to the inherited SucceededEvent.
OnAddedToScene<override>():void =
spawn{ RunPickupWatch() }
OnRemovingFromScene<override>():void =
void
# One persistent watcher per pickup entity, started once when the
# component enters the scene — no tick polling, the loop suspends on
# SucceededEvent.Await() between pickups (the NPC state-machine module's no-polling
# convention, carried over to this mode set).
RunPickupWatch()<suspends> : void =
loop:
Interactor := SucceededEvent.Await()
HandlePickup(Interactor)
No tick polling anywhere — the watcher suspends on SucceededEvent.Await() between pickups, the NPC state-machine module's no-polling convention carried over. And the handler it calls is one careful if::
# Gotcha banked (corrected writeup in item_defs.verse): calling
# item_defs.Registry() in an `if:` CONDITION fails 3512 because
# Registry() is UNANNOTATED — the silent `no_rollback` default — not
# because it allocates; a `<transacts>`-annotated builder would be
# legal right in the condition. Materializing Table BEFORE the `if:`
# also works (relocation, not repair) and is kept for readability;
# only the fallible `Table[DefId]` index (a native decides op on an
# already-built value) goes inside the condition.
HandlePickup(Interactor : agent)<suspends> : void =
Table := item_defs.Registry()
if:
not Collected?
PickupPlayer := player[Interactor]
Def := Table[DefId]
then:
set Collected = true
Sink.Add(PickupPlayer, Def.Id, 1)
PickedUpEvent.Signal((PickupPlayer, Def))
SinkVisual()
Sleep(RespawnSeconds)
RestoreVisual()
set Collected = false
We got this one wrong at first. The comment above originally read as an allocation rule — that's honestly how we understood it when we shipped this set (and it says so now because we went back and corrected it). The real compiler taught us better: allocation in an
if:condition is legal. A map literal passes right in the condition list, and so does a map-building helper — as long as the helper says what it does. What actually failed here is thatRegistry()is unannotated, so it silently defaults tono_rollback, and a failure context rejects that (Script error 3512). AnnotatingRegistry()<transacts>would make the in-condition call compile exactly as written. The hoist you see in this set works too — but by relocation, not repair. Read it as a readability choice we happen to like, not a rule you have to follow.
Read the order: the Collected guard blocks a double-pickup while the item is sunk; Sink.Add is the one acquire call; PickedUpEvent fires only after it, as a notification for whoever wired the pickup — never a second acquire path. Then the mesh entity sinks 500cm, waits out RespawnSeconds, and comes back.
item_device_bridge.verse — the device lane, and the shipped sink
The bridge's class line is its whole thesis:
item_device_bridge<public> := class(creative_device, item_defs.item_acquire_sink):
It's a classic creative_device and the concrete item_acquire_sink this set ships with — the device lane both calls the boundary and implements it. Its spawner subscription and its Add sit a few lines apart:
Same story here: this block's comment originally carried the allocation-rule reading too — it now states the corrected rule.
Registry()fails in a condition because it's unannotated (no_rollbackby default), not because it allocates.<transacts>on the builder is the real fix; the hoist is style.
Add forwards to item_granter_device.GrantItem, so today a grant materializes as a real Fortnite item (the granter's Details-panel Default Item Count decides how many per call — Amount exists on the interface for a future count-aware sink, the custom-inventory module's honors it directly). This one method body is the custom-inventory module swap point. Custom-inventory hadn't merged to main when this set was built, so the bridge ships the native lane; the day inv_core lands, Granter.GrantItem(Player) becomes inv_core.For(Player).Add(Id, Amount) — and that's the entire integration step, because every pickup in the world only ever knew the interface.
Walkthrough
Dropping it in. Wire one item_device_bridge device (its Spawner and Granter device references) and one shell_collection device (its Bridge reference) into a level with a few placed meshes. On game start, shell_collection.OnBegin discovers every mesh entity under the simulation entity, constructs an item_pickup_entity_component for each — cycling shell_common → shell_common → shell_uncommon → shell_rare → shell_legendary, so a beach full of shells reads mostly-common without any spawn-weighting logic — wires each one's Sink := Bridge, and attaches it with AddComponents. That attach is the lesson in one call: no trigger device per shell, the mesh entity itself becomes interactable.
Playing it. Interact with a shell: the native interact system fires SucceededEvent, HandlePickup resolves the def from the registry, calls Sink.Add — which today grants through the native granter — signals PickedUpEvent (the example's watcher counts it and shows a HUD toast), sinks the mesh, and restores it after RespawnSeconds.
The registry is provably fallible. shell_collection doesn't just claim unknown ids fail — DemoUnknownIdIsFallible() runs on every OnBegin, looks up a deliberately unregistered id ("shell_does_not_exist") through the same Registry()/index path every real lane uses. Exercised by the compiled code, not asserted in prose.
Gotchas
The compiler lessons from this build recur across the whole series — you'll meet them again in the sibling modules. the custom-items module discovered seven of its entries; below are the ones this module's own lines embody. Read the shared reference before authoring your own module set.
Gate the set, not the files. Compiled one file at a time, item_defs.verse is the only file that PASSes standalone (it depends on nothing); item_device_bridge.verse, item_pickup.verse, and shell_collection.verse fail per-file runs with 4, 6, and 9 errors on the cross-file identifier item_defs (Script error 3506, verdict NEEDS-SET) — by design. The compile truth is the SET verdict: PASS, 0 errors, on the real UEFN compiler. Judge a multi-file module by per-file rows and you'll read false failures.
The module-scope construction trap (a known Verse compiler pitfall) — the costliest lesson of this build, pinned down through repeated runs on the real compiler. A module-scope constant built via archetype (Name : StructType = StructType{...}) fails Script error 3512; a module-scope var must be weak_map-typed (3502) — so a plain registry cache isn't legal Verse at module scope at all. Hence Registry() is a function that rebuilds four rows per call. The second half of this lesson is one we've since corrected — we originally read the in-condition failure as an allocation rule, and the compiler taught us better. Allocation in a condition is legal; the call failed because Registry() is unannotated, so it defaults to no_rollback, which a failure context rejects. The real fix is to annotate the pure builder <transacts>. The hoist this set ships (Table := item_defs.Registry() before the if:, only the fallible Table[Id] index inside) works too, but incidentally — it moves the unannotated call instead of fixing its contract. One rule underneath it all: annotate the effect, and the position takes care of itself.
Native grant/attach calls carry no_rollback (a known Verse compiler pitfall). item_granter_device.GrantItem and entity.AddComponents can't be called from a <transacts> function — which is exactly why item_acquire_sink.Add is left undecorated.
A fresh component must be born with an Entity (the component lifecycle pattern). Every prior scene-graph example in the repo only reads components off existing entities; constructing one fresh requires Entity := ... at construction or Script error 3600. And mirror the native lifecycle: override OnAddedToScene/OnRemovingFromScene, never OnStarted.
Three quick ones the worked example banks: <localizes> messages interpolate only a bare, unqualified parameter — pass Def.DisplayName as an argument, never interpolate it in the message body (a known Verse compiler pitfall); user-defined event(t) has no .Subscribe — await PickedUpEvent from a spawned watcher task (the custom-event Await rule); and for's Index -> Value pairing works on arrays only, so materialize a generator(entity) first, while tuple destructuring won't compile inside a loop: body — use indexed access (the AI-module import rule/14/15).
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 four-file custom-item system on SceneGraph's interactable-component surface — item definitions as data, a scene-graph pickup component subclassing basic_interactable_component, and a device-lane bridge, all feeding ONE acquire boundary with the custom-inventory module's custom inventory pre-wired as the swap-in 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.