[UE5] Building an Inventory System: Separate the Definition from What You Own

Created: 2026-07-20

Build an inventory starting from splitting item definitions and owned state. Define items with Data Assets, hold counts in a Map, split it into an Inventory Component, and notify the UI with an Event Dispatcher, plus a hands-on minimal inventory where picking up, holding, using, and depleting all come full circle.

The moment you start building an inventory, every decision arrives at once. Where item information lives, how identical items combine, how limits are stored, how the UI is notified. Give the player a single array and start from there, and you'll be rebuilding it every time you add equipment or stacking.

An inventory is assembled from parts that appeared in earlier articles: Data Asset, Map, Struct, Component, Event Dispatcher. This article starts with how to make the design decisions for combining them, and builds up to a minimal inventory where picking up, holding, using, and depleting come full circle.

Three item definition cards on the left and a table of owned counts on the right, connected by lines

What You'll Learn

  • The design starts with separating the "definition" from the "owned state"
  • Owned state goes in either a Map (definition → count) or an array of structs . Two questions decide it
  • Split it into an Inventory Component so you can attach it to any Actor
  • Where to check the stack limit and the capacity limit
  • Notify the UI with OnInventoryChanged and stop rebuilding it every frame
  • Hands-on: a minimal inventory where pick up, hold, use, and deplete come full circle

Sponsored

Separate the Definition from the Owned State

There's only one decision to make first. Split item information into two.

Definition (shared by every copy)Owned state (specific to that player)
ContentsDisplay name, icon, max stack, heal amount, descriptionWhat you have and how many
When it changesOnly during development tuningConstantly during play
How many existOne per item typeOne per player
Where it livesData AssetA variable on the Inventory Component
One herb definition card on the left with name, icon, and MaxStack, and a table of owned counts for three players on the right, all three pointing at the same definition card

The reason to split is that one definition is enough . The herb healing for 20 is true whether the player holds 3 of them or 99. Copy the heal amount into the owned data and the value at pickup time gets baked in per owner. Rebalance 20 into 25 and every already-collected herb still heals 20.

The owned data only needs which definition it points at (a reference) and how many .

Definitions include references to icons and meshes, so Data Assets fit well. How to create them is covered in the Data Asset article; here we'll just show the shape.

Variable nameTypeExample (herb)
DisplayNameTextHerb
IconTexture 2D(icon image)
MaxStackInteger9
HealAmountFloat20.0

Note: if the content is mostly numbers and strings and you want to compare 100 rows in a table, a Data Table is fine too. In that case the owned data points at a row name (Name) rather than a definition asset. Everything else in the design stays the same.

Two Ways to Hold What You Own

There are two broad shapes for the owned state. It isn't a question of which is correct, but of which one your game demands.

Left: a Map, with arrows from definition cards to counts. Right: an array of structs, with slots lined up and each slot holding a definition, count, and durability
Map (definition → count)Array of structs
ShapeMap<ItemDefinition, Integer>Array<S_ItemSlot>
"How many herbs do I have?"One FindScan every element
Splitting the same item into two slotsNot possiblePossible
Per-instance durability or upgradesCan't hold themCan hold them
OrderingNot guaranteedGuaranteed
Games it suitsMaterials and consumablesEquipment, crafting, grid-style bags

Two questions decide it.

  1. Do identical items differ from each other? If you want to distinguish "iron sword (durability 72)" from "iron sword (durability 13)", a count alone can't express it. Use an array of structs
  2. Does slot position carry meaning? If "it's in the 9th slot" matters to display or controls, Minecraft-style, only an array guarantees order

If neither applies, a Map is the shortest path . Adjusting counts is two nodes, Find and Add , and "how many do I have" is answered in one step. The hands-on in this article uses a Map.

There's one caveat when you choose a Map. Ordering isn't guaranteed , so you have to decide UI display order separately. Either give the definition an integer like SortOrder and sort the result of Keys , or keep a separate display-order Array.

If you choose an array of structs, you'll inevitably run into the value-type behavior that a struct you fetched is a copy . Get it and reduce durability and the original array doesn't change, so you write it back with Set Array Elem . Details are in the struct article.

Sponsored

Splitting It into an Inventory Component

You could write the inventory directly on the player Character, but making it a Component pays off later . Chests, shop NPCs, enemies you can loot after killing them. Things that "hold contents" show up beyond the player.

The parent class is ActorComponent . It doesn't need a position, so it's not a SceneComponent (→ the Component article).

Structure diagram of BP_InventoryComponent, holding Items (Map) and MaxCapacity inside, with only the AddItem/RemoveItem/GetCount entry points and the OnInventoryChanged broadcast facing outward

Here's what goes inside BP_InventoryComponent .

KindNameContents
VariableItemsMap with BPDA_ItemDefinition (object reference) as key and Integer as value
VariableMaxCapacityInteger. Total items that can be held. Make it Instance Editable to vary per instance
FunctionAddItem (Item, Amount) → SuccessEntry point : call when something is picked up
FunctionRemoveItem (Item, Amount) → SuccessEntry point : call when something is used or dropped
FunctionGetCount (Item) → IntegerReturns how many you hold
FunctionGetTotalCount () → IntegerReturns the total count across all types
Event DispatcherOnInventoryChangedBroadcast : announces that the contents changed

What's exposed outward is only the entry points (functions) and the broadcast (Dispatcher) . The key is never letting anything Set Items directly, which removes any path that modifies the contents without passing the limit checks.

Making MaxCapacity Instance Editable lets you set 15 for the player and 5 for a chest, per placed instance.

Adding: Where to Check the Two Limits

The first thing you build for an inventory is AddItem , and two kinds of limit land here. They're easy to mix up, so let's separate the meanings.

LimitWhere it's writtenWhat it checks
Stack limitThe definition ( MaxStack )How many of that one item type can stack
Capacity limitThe Component ( MaxCapacity )How many can be held in the bag overall

Herbs stack only up to 9, but the bag as a whole holds 15 items. With just one of these, you can't express either restriction fully.

Node graph of the AddItem function, with Is Valid, Find, the MaxStack Branch, the capacity Branch, Add, and the OnInventoryChanged Call laid out left to right
Function: AddItem (inputs: Item / BPDA_ItemDefinition, Amount / Integer → output: Success / Boolean)

  → Is Valid (Input Object: Item)
      Is Not Valid → Set Success = false → Return

  → Find (Target: Items, Key: Item)
      Value → Promote to local variable (CurrentCount)

  → Branch (Condition: CurrentCount + Amount > Item → Max Stack)
      True → Print String ("Can't stack any more of these") → Set Success = false → Return

  → Branch (Condition: GetTotalCount + Amount > MaxCapacity)
      True → Print String ("Your bag is full") → Set Success = false → Return

  → Add (Target: Items, Key: Item, Value: CurrentCount + Amount)
  → Call OnInventoryChanged
  → Set Success = true

Three things worth noting.

First, Find returns 0 when nothing is found. Integer's default is 0, so you don't need a branch for "is this the first one". CurrentCount + Amount is already correct even for the very first item.

Second, Map's Add overwrites. Add with an existing key replaces the value rather than adding an element. Find the current count, add to it, and put it back with Add is the standard three-step move for incrementing counts in a Map (→ the containers article).

Third, do the checks before adding. If you Add first and then check whether you went over, you need logic to give the excess back. Refusing before letting it in leaves no room for the state to break.

GetTotalCount gets the array of counts with Values and sums them in a For Each Loop.

Function: GetTotalCount (output: Total / Integer)

  → Values (Target: Items)
  → For Each Loop
      Loop Body → Set Total = Total + Array Element
      Completed → Return (Total)

Leave this function non-Pure . Making it Pure means it recalculates once for every connection from its output pin. With a loop inside, that count becomes impossible to predict.

Removing and Using

RemoveItem is the reverse of adding, plus one extra move: delete keys that hit 0 .

Node graph of the RemoveItem function: Find checks the count, bails out false if insufficient, subtracts otherwise, Removes if the result is 0 or less, otherwise writes back with Add, then Calls OnInventoryChanged
Function: RemoveItem (inputs: Item / BPDA_ItemDefinition, Amount / Integer → output: Success / Boolean)

  → Find (Target: Items, Key: Item)
      Value → CurrentCount
  → Branch (Condition: CurrentCount < Amount)
      True → Set Success = false → Return        <- don't have it, or not enough

  → Set NewCount = CurrentCount - Amount
  → Branch (Condition: NewCount <= 0)
      True  → Remove (Target: Items, Key: Item)   <- don't leave keys with 0
      False → Add (Target: Items, Key: Item, Value: NewCount)

  → Call OnInventoryChanged
  → Set Success = true

You Remove keys that reach 0 so that Length can be used straightforwardly as "the number of item types you hold" . Leave zero-count keys around and the UI keeps showing empty slots, giving you "it's in the list even though I don't have any".

"Using" sits as a thin layer on top of RemoveItem .

Function: UseItem (input: Item / BPDA_ItemDefinition)

  → RemoveItem (Item: Item, Amount: 1)
      Success → Branch
          True → apply the effect (e.g. heal by HealAmount)
          False → Print String ("You don't have one")

Do it in that order: remove first, and only apply the effect on success . The other way around tends to produce a build where you heal even at 0 items. The removal doubles as the sole "do I have one" check, so you never write the condition twice.

The effects themselves, healing or equipping, aren't the inventory's job. The HP side is split out into the health and damage article.

Sponsored

Getting Changes to the UI

The most common mistake in inventory UI is rebuilding the slots every frame on Event Tick . Even when nothing changed, it keeps creating and destroying Widgets every frame.

Left: rebuilding slots every frame on Tick. Right: updating only at the moment OnInventoryChanged fires

OnInventoryChanged exists for exactly this. It reaches the UI only at the moment the contents change .

WBP_Inventory
Event Construct
  → Get Owning Player Pawn
  → Get Component by Class (Component Class: BP_InventoryComponent)
      → Promote to variable (InventoryRef)
  → Bind Event to On Inventory Changed (Target: InventoryRef)
        Event pin ← Custom Event: RefreshSlots
  → RefreshSlots                       <- call it once yourself the first time

Custom Event: RefreshSlots
  → Clear Children (Target: SlotContainer)
  → Keys (Target: Items) → For Each Loop
        Loop Body →
          Create Widget (WBP_ItemSlot)
          → Set Slot Data (Definition: Array Element, Count: GetCount(Array Element))
          → Add Child (Target: SlotContainer)

Event Destruct
  → Unbind Event from On Inventory Changed (Target: InventoryRef)

Two notes.

Call it once yourself in Construct. The Dispatcher only fires "when something changes", so items already held when the UI opens won't show up unless you go read them yourself.

Always Unbind in Destruct. If the Widget disappears first while the subscription remains, notifications get sent to a Widget that no longer exists. Why Bind and Unbind come in pairs is covered in the Event Dispatcher article.

Slot Widget layout and icon display belong to the UMG article. Here it's enough to know the shape of Add Child into a Uniform Grid Panel or Wrap Box .

Hands-On: Closing the Pick Up, Hold, Use Loop

RPG belongings, a roguelike backpack, survival crafting materials, escape-room keys. The loop of pick up, count, use, deplete takes the same shape in every genre. Here we'll skip polished visuals and close the loop while watching the counts through Print String.

What it looks like running

You can pick up 9 herbs but the 10th is refused, the bag fills at 15 items, using one decreases the count, and a type that hits 0 disappears from the list.

Flow showing 9 herbs, 5 potions, and 1 key totaling 15 and full, using an herb dropping it to 8, and running out reducing the type count to 2

Setup to follow along

Create a new project from the Third Person template .

The item definition type (create it following the Data Asset article; parent class is Primary Data Asset )

Class nameVariableTypeNotes
BPDA_ItemDefinitionDisplayNameTextTurn on Instance Editable
MaxStackIntegerDefault 1
HealAmountFloatDefault 0.0

Four item data assets (right-click in the Content Browser → Miscellaneous > Data Asset → choose BPDA_ItemDefinition )

AssetDisplayNameMaxStackHealAmount
DA_HerbHerb920.0
DA_PotionPotion550.0
DA_KeyKey10.0
DA_BombBomb30.0

BP_InventoryComponent (parent class: ActorComponent)

Variable nameTypeContainerDefault value
ItemsBPDA_ItemDefinition (object reference) → IntegerMap(empty)
MaxCapacityIntegerSingle15 (Instance Editable)

Create the functions AddItem / RemoveItem / UseItem / GetCount / GetTotalCount from the previous sections, plus the OnInventoryChanged Event Dispatcher.

BP_ThirdPersonCharacter

What to prepareDetails
ComponentAdd BP_InventoryComponent ( MaxCapacity set to 15 )
Variable CurrentHealthFloat / default 50.0
Variable MaxHealthFloat / default 100.0

Wiring up the pickup side

To keep verification easy, we'll pick items up with key presses. Using raw keyboard events avoids touching project settings.

Node graph from a keyboard event through Get Component by Class to fetch the Inventory Component, calling AddItem and taking Success into a Branch
BP_ThirdPersonCharacter (event graph)

Keyboard Event: 1 (Pressed)
  → AddItem (Target: BP_InventoryComponent, Item: DA_Herb, Amount: 1)
      Success → Branch
          True → Print String (Append: "Herb: " + GetCount(DA_Herb) as string)

Keyboard Event: 2 (Pressed) → AddItem (Item: DA_Potion, Amount: 1)  <- same shape
Keyboard Event: 3 (Pressed) → AddItem (Item: DA_Key,    Amount: 1)
Keyboard Event: 4 (Pressed) → AddItem (Item: DA_Bomb,   Amount: 1)

Keyboard Event: Q (Pressed)
  → UseItem (Target: BP_InventoryComponent, Item: DA_Herb)

Keyboard Event: E (Pressed)
  → Print String (Append: "Total: " + GetTotalCount + " / Types: " + Length(Items))

The effect side of UseItem raises the Character's CurrentHealth .

Applying the UseItem effect (inside BP_InventoryComponent)
  → Get Owner → Cast To BP_ThirdPersonCharacter
  → Set Current Health
      = Clamp (Value: CurrentHealth + Item → Heal Amount, Min: 0.0, Max: MaxHealth)
  → Print String (Append: "HP: " + CurrentHealth)

Careful: this does a Cast To BP_ThirdPersonCharacter from inside the Component. It works, but that Component is now player-only . If you also plan to attach it to chests, apply the effect on the Owner side instead and have the Component merely broadcast "this was used" (→ the Component article). It's fine to build the working version first and separate them later.

Verifying it

Play and press the keys in this order.

ActionExpected output
1 nine timesCounts up from Herb: 1 to Herb: 9
1 once more (the 10th)Can't stack any more of these . Herbs stay at 9
2 five timesPotion: 1 through Potion: 5
3 onceKey: 1
ETotal: 15 / Types: 3
4 (bomb)Your bag is full . 15 + 1 exceeds MaxCapacity
QHP: 70 , herbs drop to Herb: 8
Q twice moreHP: 90HP: 100 (never 110)
Q the remaining six times (nine total)Herbs reach 0, and the 10th Q prints You don't have one
ETotal: 6 / Types: 2 . The herb key is gone from the Map
4 (bomb)Now it goes through. The total is 6, so there's room

The last two rows are what this hands-on is really about. A bag that was full accepts items again by exactly as much as you used. That's proof the limit check compares against the current total rather than a fixed number.

Troubleshooting if it doesn't work.

  • Herbs stay at 1 forever → you're passing 1 straight into Add without adding the result of Find
  • The 10th one goes in anyway → you've mixed up where >= versus > belongs in the MaxStack check. CurrentCount + Amount > MaxStack is the correct form
  • Types: 3 stays and herbs never disappearRemoveItem writes 0 back with Add instead of calling Remove at zero
  • Total never goes downGetTotalCount is counting Keys instead of Values
  • You can pick up even when fullMaxCapacity is Instance Editable and the placed instance still has the default 0 or an inflated value
  • The UI doesn't update → you forgot to Call OnInventoryChanged at the end of AddItem / RemoveItem . The symptom is correct Print String numbers with stale UI
  • Nothing happens when you press keys → the Character isn't the possessed pawn. Check the GameMode's Default Pawn Class (→ the Print String and logging article)

Two things to take away.

  • Finish every limit check before letting anything in: AddItem either refuses or adds and broadcasts, nothing in between. Never building a path that partially adds and then rolls back removes any chance for the counts to break. Even as entry points multiply, dropping, selling, consuming in crafting, the roads stay the same two: AddItem and RemoveItem
  • Keep owned data to a definition reference and a count: the display name and heal amount are all in the definition asset. Not copying them into the owned side means rebalancing is nothing more than editing numbers in a Data Asset

Next comes keeping these belongings for the next launch. A reference to a Data Asset can't be saved directly , so give the definition an ID, write out an array of IDs, and look them back up on load. For the steps, continue to the save/load article; for slot visuals and drag operations, continue to the UMG article.

Sponsored

Bonus: Good to Know for Later

  • If you place pickups in the world: for items lying on the ground, a single BP_ItemPickup holding an Instance Editable reference to BPDA_ItemDefinition is enough. Swapping the Data Asset turns it into an herb or a key, so you never need one Blueprint per item type. If you only want to pick up things within reach, the approach in the Line Trace article applies directly
  • A weight-based system is the same shape: make MaxCapacity a weight rather than a count, give the definition a Weight , and change GetTotalCount into "total weight". Keeping the check in one place means this kind of spec change is a single-function edit
  • To carry belongings across levels: a Component attached to the Character is rebuilt on a level transition. To carry things between stages, hand the owned data to the Game Instance, or write it out and read it back around the transition
  • Careful when using Data Assets as keys: Map keys must be hashable types. Object references work, but a second asset representing the same item becomes a different key. Don't duplicate DA_Herb into DA_Herb2 and let both circulate
  • Equipment belongs in a separate mechanism: "the currently equipped weapon" is part of your belongings, but it has a different rule: only one can be selected . Keeping it apart from the inventory Map as a standalone EquippedWeapon variable is more natural. Whether moving an item to equipment also removes it from the inventory is a per-game decision

Summary

  • The design starts with separating the definition (Data Asset) from the owned state (a Component variable) . Don't copy definition values into the owned side
  • How you hold owned data comes down to do items differ individually, and does order matter . If neither, a Map (definition → count) is the shortest path
  • Split the inventory into an ActorComponent . Expose only the entry points (functions) and the broadcast (Dispatcher)
  • There are two limits. The stack limit lives in the definition, the capacity limit in the Component. Finish all checks before adding
  • The shape for incrementing in a Map is Find → add → Add . Remove keys that hit 0 instead of leaving them
  • Update the UI through OnInventoryChanged . Call it once yourself in Construct and Unbind in Destruct

In the game you're building, do the things a player carries differ from one another? That answer alone decides between a Map and an array of structs.