Skip to content

Add, Remove, Stack, and Broadcast Inventory Changes

Inventory bugs usually start when AddItem half-succeeds silently. Make the rules explicit: validate, stack, create slots, report overflow, then broadcast once.

At a glance

Use this for: the first AddItem, RemoveItem, stack, and UI refresh rules - Main data: FInventoryEntry structs in an array, optionally backed by a map from item ID to slot index - Returns you should design: accepted count, rejected count, and failure reason - Official docs: Blueprint Arrays, Blueprint Maps, Struct Variables in Blueprints, and Event Dispatchers

The one-minute version

  • AddItem should decide whether to add to an existing stack, create a new stack/slot, accept only part of the request, or reject the whole request.
  • RemoveItem should reduce counts, remove empty stacks, clear equipped state when needed, and report whether it actually removed enough.
  • Arrays give display order and slot positions. Maps give fast lookup by item ID. Use the simplest shape that still answers the question.
  • Duplicate item IDs are normal in arrays when several stacks exist. Duplicate keys are not allowed in maps.
  • Invalid item data and non-positive quantities should fail without mutating inventory.
  • Broadcast OnInventoryChanged once after a successful mutation, not once per widget and not from Tick.

Start with the result contract

Do not make AddItem return only "done" if partial success matters. Design a small result struct:

Struct FInventoryChangeResult
    bChanged (Boolean)
    AcceptedCount (Integer)
    RejectedCount (Integer)
    FailureReason (Enum or Text for debug/UI)

Useful reasons:

EInventoryFailureReason
    None
    InvalidItem
    InvalidQuantity
    InventoryFull
    NotEnoughItems
    ItemNotUsable
    EquippedItemBlocked

This result makes pickup cleanup safe:

Pickup asks AddItem(Potion, 5)
-> AcceptedCount = 3
-> RejectedCount = 2
-> pickup keeps Quantity = 2 or stays in world

Without this contract, full inventories tend to either delete items or duplicate them.

The data shape

A compact first inventory can use one array:

Struct FInventoryEntry
    ItemId (Name)
    Count (Integer)
    SlotIndex (Integer)
    Durability (Float, optional)
    EquippedSlot (Enum or Name, optional)

InventoryComponent
    Entries: Array of FInventoryEntry
    MaxSlots: Integer
    OnInventoryChanged dispatcher

This is enough for small bags. Every add/remove can loop through Entries to find a matching item ID.

Add a map only when lookup noise becomes real:

ItemIdToFirstSlot: Map Name -> Integer

Blueprint Maps cannot use duplicate keys. If one item ID can occupy several stacks, a plain Map Name -> Integer only remembers one slot. That is fine for "one stack per item ID" designs, but wrong for separate stacks or unique item instances. For multiple stacks, looping the entries array is often clearer in Blueprint until a real performance problem appears.

AddItem flow

The normal AddItem order is:

  1. validate item ID/data and quantity;
  2. find compatible existing stacks;
  3. fill those stacks up to max stack;
  4. create new slots/stacks if space remains;
  5. return accepted/rejected counts;
  6. broadcast once if anything changed.
pseudocode - Blueprint-friendly, not engine source

function AddItem(ItemId, Quantity):
    Result = empty result

    if Quantity <= 0:
        Result.FailureReason = InvalidQuantity
        return Result

    Definition = FindItemDefinition(ItemId)
    if not found:
        Result.FailureReason = InvalidItem
        return Result

    Remaining = Quantity
    MaxStack = Definition.MaxStack

    for each Entry in Entries:
        if Remaining <= 0:
            break
        if Entry.ItemId == ItemId and Entry.Count < MaxStack and Entry can stack:
            Space = MaxStack - Entry.Count
            Move = Min(Space, Remaining)
            Entry.Count += Move
            Remaining -= Move
            Result.AcceptedCount += Move
            set array element back

    while Remaining > 0 and Entries.Length < MaxSlots:
        Move = Min(MaxStack, Remaining)
        Add new FInventoryEntry(ItemId, Move, SlotIndex)
        Remaining -= Move
        Result.AcceptedCount += Move

    Result.RejectedCount = Remaining
    Result.bChanged = Result.AcceptedCount > 0
    if Remaining > 0 and Result.AcceptedCount == 0:
        Result.FailureReason = InventoryFull

    if Result.bChanged:
        RebuildLookupMap if you use one
        Call OnInventoryChanged

    return Result

The set array element back line matters. Structs are values. If you break an entry, change Count, and never set the array element to the changed struct, the stored inventory did not change.

Stack rules

Stacking by item ID is correct only for items with no meaningful per-instance state.

Good stackable items:

  • small potions;
  • ammo;
  • coins;
  • crafting materials;
  • keys only if duplicate keys are meaningful in your game.

Usually not stackable by ID alone:

  • weapons with durability;
  • items with rolled stats;
  • quest items that must stay distinct;
  • equipped items;
  • items with custom names, charges, upgrades, or bound ownership.

Use definition data to decide:

Definition:
    MaxStack = 10
    bCanStack = true

Entry:
    ItemId = Potion_Small
    Count = 7

For unique items:

Entry:
    ItemId = Sword_Iron
    InstanceId = 3982
    Count = 1
    Durability = 0.72

If an entry has InstanceId, durability, or upgrades, do not merge it with another entry just because the item ID matches.

RemoveItem flow

RemoveItem is the mirror image of AddItem. Decide whether it removes from a specific slot or by item ID.

Remove from a specific slot when:

  • the player dragged one slot to trash;
  • a consumable button is tied to one slot;
  • unique item state matters;
  • UI order matters.

Remove by item ID when:

  • spending currency;
  • consuming ammo;
  • crafting ingredients;
  • paying costs from any matching stack.
function RemoveItem(ItemId, Quantity):
    if Quantity <= 0:
        return InvalidQuantity

    Available = CountItem(ItemId)
    if Available < Quantity:
        return NotEnoughItems

    Remaining = Quantity
    for each matching Entry:
        Move = Min(Entry.Count, Remaining)
        Entry.Count -= Move
        Remaining -= Move

        if Entry.Count == 0:
            if Entry is equipped:
                Unequip first or block removal
            remove array element
        else:
            set array element back

    RebuildLookupMap if used
    Call OnInventoryChanged once
    return success

Choose your equipped-item rule up front. A first project usually wants one of these:

Rule Behavior
Block removal if equipped Drop/use/delete fails until unequipped.
Unequip then remove Removal clears equipped actor/component first.
Remove only extra stacks Equipped slot keeps its instance; costs come from non-equipped entries.

Do not silently destroy an equipped actor because some crafting recipe spent the same item ID from the bag.

Empty slots and full inventory

There are two common slot models.

Compact list

Entries:
    [0] Potion x4
    [1] Key x1
    [2] Ammo x20

Removing Key shifts later entries down. This is simple for small menu lists, but drag/drop slot positions can move unless you handle it.

Fixed slots

Slots:
    [0] Potion x4
    [1] Empty
    [2] Ammo x20
    [3] Empty

Fixed slots make grid UI and drag/drop clearer. They require an explicit empty slot representation: either an entry with bIsEmpty, or a separate array of optional item data.

Inventory full means different things:

  • no empty fixed slot;
  • no compact-list capacity left;
  • existing stacks are full and no new stack can be created;
  • weight limit reached;
  • item category limit reached.

Return the reason that your UI needs. "Full" is not always enough.

Broadcast once

The inventory owner should broadcast after the mutation is complete.

AddItem
-> change Entries
-> rebuild lookup map
-> update equipped state if needed
-> Call OnInventoryChanged(Entries or InventoryComponent reference)

Do not do this:

Widget Tick
-> Get Player Character
-> Get Inventory
-> rebuild all slots every frame

Do not do this either:

AddItem loops 5 stacks
-> Call OnInventoryChanged after every stack touched

That makes UI rebuild several times for one pickup and can expose temporary states in the middle of a mutation.

The pattern:

WBP_Inventory opens
-> get InventoryComponent
-> bind to OnInventoryChanged
-> RefreshInventory once from current Entries

InventoryComponent changes
-> Call OnInventoryChanged once
-> widget RefreshInventory

When the widget closes or changes owner, unbind or rebind according to your normal widget lifetime pattern.

Invalid data and duplicate IDs

Invalid item data should not mutate inventory.

Examples:

  • item ID not found in Data Table;
  • Data Asset reference is None;
  • max stack is 0 or negative;
  • quantity is 0 or negative;
  • item category is blocked by game rules;
  • entry count is already invalid because of an old bug.

During development, fail loudly:

AddItem invalid ID
-> Print String "AddItem failed: missing item definition Potion_Small"
-> return AcceptedCount 0, RejectedCount Quantity

Duplicate item IDs are normal in arrays:

[0] Potion x10
[1] Potion x3

Duplicate item IDs are not normal in a simple lookup map:

Map ItemIdToSlotIndex:
    Potion -> 1

The map lost slot 0. If you need many stacks per ID, use a deliberate helper structure/code lookup that stores every matching slot, or skip the map until the simple array loop is not enough.

Multiplayer boundary

Inventory is gameplay state. In multiplayer, make the server own actual mutation.

Client presses pickup/use/drop
-> ServerRequestAddItem / ServerRequestUseItem

Server
-> validates distance, item, quantity, ownership, rules
-> mutates inventory
-> replicates inventory summary or changed entries

Client UI
-> updates from replicated state or RepNotify

Event Dispatchers are local notification tools. They are useful after state changes on a machine, but they do not replace replication. A client-side dispatcher call does not make the server inventory change.

When it fails (and what failure does)

Symptom Likely cause
Pickup deletes item when bag is full AddItem returned no accepted/rejected result, or pickup ignored it.
Count changes in the graph but not in inventory Struct copy was edited without setting the array element back.
Potion stacks past max AddItem never clamps available stack space.
UI refreshes several times per pickup Broadcast happens inside the stack loop.
UI never refreshes on open Widget bound after old changes and did not do an initial refresh.
Removing an item leaves an empty count-zero entry RemoveItem did not remove or mark empty entries.
Using one equipped item destroys another RemoveItem removed by item ID without respecting equipped instance/slot.
Map lookup points to wrong slot Array changed and lookup map was not rebuilt, or map cannot represent multiple stacks.
Multiplayer client sees item but server rejects use Client mutated local inventory state instead of requesting server authority.

Failure should look like a result struct, a false Boolean, or a visible debug print while developing. Do not let failure look like "nothing happened, maybe."

What these functions do not do

  • They do not define item data by themselves.
  • They do not create or destroy world pickups unless you call those paths.
  • They do not equip actors, attach sockets, or play use effects by themselves.
  • They do not save to disk automatically.
  • They do not make UI update unless you broadcast or refresh.
  • They do not make invalid item IDs safe to ignore.
  • They do not replicate client changes by themselves.

Lookalikes - which one do I want?

Tool Use when Watch out
Array of entries You need ordered inventory slots or a simple small bag. Check length and set struct elements back after edits.
Map ItemId -> count You only need counts by stable item ID. It has no slot order or per-instance state.
Map ItemId -> slot index One stack per item ID and fast lookup matters. Multiple stacks need a different value shape.
Set of IDs You only care whether something is unlocked/owned. It cannot store counts.
Event Dispatcher Inventory changed and listeners should refresh. It does not carry authority or replay old changes.
Widget binding Tiny UI value that is cheap and truly derived. Inventory polling from bindings grows expensive and hides logic.
SaveGame copy Persist durable item IDs/counts to disk. It is not the live inventory owner.

Rule of thumb: mutation belongs in one inventory owner; display listens to the owner; saves copy values out and back in.

Going deeper