Skip to content

Inventory System

Summary: Server-authoritative spatial inventory using UItemContainerComponent with 2D grid-based storage. Items occupy grid cells, support stacking, and replicate efficiently via FFastArraySerializer. All modifications go through server RPCs with desync detection.

Table of Contents


Architecture Overview

UItemContainerComponent (on PlayerController)
├── FInventoryFastArray (Replicated)
│   └── TArray<FInventoryEntry>
│       ├── UItemObject* Item
│       └── int32 TopLeftIndex (grid position)
├── TArray<bool> OccupiedGrid (Replicated)
│   └── Bitmap for O(1) space checks
├── Configuration
│   ├── Width (default: 14)
│   └── Height (default: 8)
└── Delegates
    ├── OnItemAdded
    ├── OnItemRemoved
    ├── OnContainerContentsChanged
    └── NoRoomInInventory

Key Design Principles

Principle Implementation
Spatial Grid Items occupy 2D positions, not list indices
FastArray FFastArraySerializer for delta replication
Server Authority All modifications via server RPCs
Occupied Bitmap O(1) space availability checks
IItemContainer Interface-driven for polymorphic containers

Core Concepts

Why Spatial Inventory?

Traditional list inventory: - Items have no physical presence - No Tetris-style inventory management - All items feel the same "size"

Spatial grid inventory: - Items occupy cells based on dimensions - Large items require more space (immersive) - Grid management becomes gameplay element

Why PlayerController Ownership?

The inventory component lives on AEternalPlayer (PlayerController), not the pawn: - Inventory persists across pawn death/respawn - No need to transfer items when pawn changes - Shares lifecycle with equipment

IItemContainer Interface

Containers implement IItemContainer for polymorphic operations:

Method Purpose
TryAddItemToContainer(Item) Add item to first available position
AddItemAtPosition(Item, Index) Add item at specific position
CanAddItemAt(Item, Index) Check if position is valid
RemoveItemFromContainer(Item) Remove item from container
GetAllItemsInContainer() List all items
GetContainerWidth/Height() Grid dimensions

Grid System

Coordinate Conversion

The grid uses 1D array indexing for efficient storage:

Grid (Width=5, Height=3):

     0   1   2   3   4
   +---+---+---+---+---+
 0 | 0 | 1 | 2 | 3 | 4 |
   +---+---+---+---+---+
 1 | 5 | 6 | 7 | 8 | 9 |
   +---+---+---+---+---+
 2 |10 |11 |12 |13 |14 |
   +---+---+---+---+---+

ToIndex(X, Y) = Y * Width + X
ToCoord(Index) = (Index % Width, Index / Width)

Item Placement

Items store their top-left cell index. The item's FGridFragment defines dimensions:

2x2 Item at TopLeftIndex=6:

     0   1   2   3   4
   +---+---+---+---+---+
 0 |   |   |   |   |   |
   +---+---+---+---+---+
 1 |   |###|###|   |   |  ← Item occupies cells 6,7,11,12
   +---+---+---+---+---+
 2 |   |###|###|   |   |
   +---+---+---+---+---+

Space Availability Check

To place an item: 1. Check bounds (item fits within grid) 2. Check each cell in item's footprint against OccupiedGrid 3. Optional: ignore specific items (for swap operations)


FastArray Replication

Why FastArray?

Standard TArray replication sends entire array on any change. FFastArraySerializer: - Sends only changed entries (delta) - Provides add/remove callbacks on clients - Maintains entry identity across updates

FInventoryEntry

Each entry in the FastArray:

Property Purpose
Item Pointer to UItemObject
TopLeftIndex Grid position (or INDEX_NONE if unplaced)

Replication Callbacks

Server modifies InventoryList
    ├─ MarkItemDirty() or MarkArrayDirty()
Client receives delta
    ├─ PostReplicatedAdd() ─► OnItemAdded.Broadcast()
    ├─ PostReplicatedChange() ─► OnContainerContentsChanged.Broadcast()
    └─ PreReplicatedRemove() ─► OnItemRemoved.Broadcast()

Item Operations

Add Item Flow

TryAddItemToContainer(Item)
    ├─ [Client?] → Server_TryAddItemToContainer(Item)
    ├─ [Server] TryStackItem(Item) → success? done
    ├─ TryPlaceItem(Item, OutIndex) → find first available position
    ├─ InitializeInstanceId() → assign GUID
    ├─ InventoryList.AddEntry(Item, Index)
    ├─ MarkGridOccupied(Index, Dimensions, true)
    ├─ Item->SetOwningContainer(this)
    └─ OnItemAdded.Broadcast(Item)

Remove Item Flow

RemoveItemFromContainer(Item)
    ├─ [Client?] → Server_RemoveItemFromContainer(Item)
    ├─ Find entry → get TopLeftIndex
    ├─ MarkGridOccupied(Index, Dimensions, false)
    ├─ InventoryList.RemoveEntry(Item)
    ├─ Item->SetOwningContainer(nullptr)
    └─ OnItemRemoved.Broadcast(Item)

Move/Swap Operations

Moving items within or between containers: 1. Validate source item exists at expected position 2. Validate target position is available 3. Update entries and grid occupation 4. Handle swaps when target has existing item


Stacking Behavior

When Items Stack

Items stack when: 1. Same ItemType (GameplayTag) 2. Source item has FStackableFragment 3. Target item has room (CurrentStack < MaxStackSize)

Stacking Algorithm

TryStackItem(NewItem)
    ├─ Find existing item of same type
    ├─ Calculate: AvailableSpace = MaxStack - CurrentStack
    ├─ StacksToAdd = Min(AvailableSpace, NewItem.StackCount)
    ├─ ExistingItem.StackCount += StacksToAdd
    ├─ NewItem.StackCount -= StacksToAdd
    └─ Return: NewItem.StackCount == 0 (fully stacked)

Partial stacking leaves remainder, which then tries to place as new item.


Server Authority

Why Server-Authoritative?

Client-authoritative inventory enables: - Item duplication exploits - Negative stack counts - Impossible item placements

Server authority ensures: - Single source of truth - Validated operations only - Cheating prevention

RPC Pattern

RPC Parameters Purpose
Server_TryAddItemToContainer (Item) Add at first available
Server_AddItemAtPosition (Item, Index) Add at specific position
Server_RemoveItemFromContainer (Item) Remove item
Server_RemoveItemFromGrid (Item, Index) Remove with position validation
Server_PlaceItem (Item, Index) Move within container
Server_PlaceItemById (Guid, Index) Move using GUID
Server_SwapItems (Item1, Index1, Item2, Index2, Target) Swap two items
Server_SwapItemsById (GuidA, GuidB) Drag-release positional swap (bidirectional fit)
Server_MergeStackById (SourceGuid, DestGuid) Merge stacks (same type + headroom)
Server_PlaceAndDisplaceById (HeldGuid, TargetGuid, DropIndex) Cursor-pickup place-onto-occupant (displaces the occupant)
Server_DropItem (Item, Count) Spawn as world pickup

Desync Detection

Client state can drift from server. Detection pattern:

Server_SwapItems(HoverItem, HoverOriginalIndex, ClickedItem, ClickedOriginalIndex, TargetIndex)
    ├─ Find entries at expected indices
    ├─ Validate entries match expected items
    │   │
    │   └─ [Mismatch?] MarkArrayDirty() → force resync
    └─ [Valid?] Perform swap

Cursor Pickup (Click-to-Hold) Model

Alongside drag-release, the inventory supports a PoE/Diablo-style "item on cursor" model: left-click an item to pick it up onto the cursor, left-click a destination to place / swap / merge / equip. Both gestures share the same resolution and server-op layer, so they can never diverge on what a given click would do.

Virtual Hold With a Real Anchor

The held item never leaves its container server-side. A hold is pure client/UI state on UInventoryController:

  • The item's real, replicated grid position is its anchor — its "return address". Grids skip rendering the held item; a cursor-following visual renders it instead.
  • Every placement is an ordinary server op from the anchor to the target, using the same owner-guarded GUID RPC layer the drag path uses.
  • No new replicated state, no new item location, no persistence changes. Cancel / disconnect / death / forced UI teardown all reduce to "the item is where it always was" — zero limbo, and zero server traffic on cancel.

Click Resolution

Resolution is computed client-side by ItemGridWidget::ResolveDropAction (with bDisplaceToCursor = true) — the same pure predicate the drag path uses:

Click target (while holding A) Resolution Server op
Empty region, A fits Place PlaceHeldItemInContainerRequestPlaceItemInContainer (vendor routing preserved)
Occupant B, same-type stackable, headroom Merge Server_MergeStackById (remainder keeps holding A)
Occupant B, A fits at B's cell ignoring B Displace Server_PlaceAndDisplaceById (A → clicked cell; B → A's anchor, else first-free); client auto-holds B → chained swap
2+ occupants / A doesn't fit Reject none — keep holding + feedback
Compatible equipment slot Equip RequestEquipItem (occupant evicted to bag; cursor empties)
Incompatible equipment slot Reject none — keep holding

Server_PlaceAndDisplaceById (the one new server op)

Server_PlaceAndDisplaceById(FGuid HeldItemId, FGuid TargetItemId, int32 DropIndex) [Server, Reliable] on UItemContainerComponent delegates to the authoritative helper PlaceAndDisplace(Held, Target, DropIndex):

1. Resolve both GUIDs via Registry->FindItemForController(.., OwnerPC) — foreign/invalid → reject.
2. Both entries must sit in THIS container (intra-container, parity with Server_SwapItemsById) — else reject.
3. Held's footprint at DropIndex must overlap EXACTLY the one Target, and fit ignoring both movers — else reject.
4. Displaced landing: Held's vacated anchor cell, else FindAvailableSpace; none → reject.
5. All-or-nothing: on any failure nothing moves and both entries re-dirty so the client re-syncs.

Unlike SwapItemPositions there is no bidirectional-fit demand — the occupant pops onto the cursor, so its grid cell is merely a return anchor. This is what removes the "ring onto a 2x4 feels arbitrary" drag-swap refusals; refusal shrinks to the genuinely impossible case (inventory literally full), which refuses-with-feedback rather than destroying anything.

Commit / Reject Reconcile (dispatch-anchor comparison)

The hold has no client-predicted mutation. It reconciles purely from replicated contents-change broadcasts:

  • BeginHeldDispatch records the dispatch anchor (container + index) where the item verifiably sits before sending the op.
  • On the next OnContainerContentsChanged, ReconcileHeldState re-derives the item's current position:
  • Item gone from every owned container → hold ends (full merge / world-drop / consumed / committed cross-container move).
  • Still at the dispatch anchor → the server rejected (its reject re-dirty landed and nothing moved); resume the sticky hold — the item stays on the cursor, PoE-style.
  • Moved off the anchor → the op committed. For a Displace, the displaced occupant (PendingSwapTarget) auto-holds → chained swap.
  • No pending op (sticky hold, nothing in flight) → re-bind and follow external moves.

Pickup Eligibility

CanPickUpItem: the item must sit in one of the local player's own containers (bag / equipment / crafting), resolved via Container->ResolveOwningController() == OwningPlayer. Vendor and loot grids resolve to a different/no controller and stay drag-only (protects the purchase flow). The dim 2H off-hand mirror is HitTestInvisible, so it can't be picked up without a special case.

Cancel & World-Drop

  • Cancel (RMB over a grid, ESC, closing the inventory): CancelHeldItem discards client state and re-broadcasts the anchor container → the widget reappears. No server op.
  • World-drop: clicking the drop-catcher region (outside every panel) calls DropHeldItemToWorldServer_DropItem on the item's current container, so an equipped hold gets its drop-side unequip reconcile. The drop catcher is a shared session shown on pickup (as on drag start) and torn down when the hold clears.

Controller API (UInventoryController)

Method Purpose
PickUpItem(Item) Start a virtual hold (eligibility-gated)
CanPickUpItem(Item) Local-owned-container eligibility check
PlaceHeldItemInContainer(Container, Index) Resolved Place
PlaceHeldItemOntoOccupant(Container, Target, Index) Displace-swap via Server_PlaceAndDisplaceById
MergeHeldItemInto(Container, Target) Merge held stack into target
PlaceHeldItemInEquipmentSlot(SlotTag) Equip the held item
DropHeldItemToWorld() Drop the held item to the ground
CancelHeldItem() Discard the hold (item stays at its anchor)
GetHeldItem() / IsHoldingItem() Held-state queries
OnHeldItemChanged Fires on pickup / place-commit / cancel / chained-swap hand-off (grids re-render off this)

Public Contracts

UItemContainerComponent

Item Operations

Method Purpose
TryAddItemToContainer(Item) Add item (stacking + placement)
AddItemAtPosition(Item, Index) Add at specific position
RemoveItemFromContainer(Item) Remove item
IsSpaceAvailable(Dimensions, Index, Ignore...) Check placement validity

Queries

Method Purpose
GetAllItemsInContainer() All items in container
GetItemAtIndex(Index) Item at grid position
GetContainerWidth() Grid width
GetContainerHeight() Grid height
GetInventoryFastArray() Direct FastArray access

Events

Delegate Payload When
OnItemAdded UItemObject* Item added to container
OnItemRemoved UItemObject* Item removed from container
OnContainerContentsChanged none Any modification
NoRoomInInventory none Add failed (no space)

FInventoryFastArray

Method Purpose
AddEntry(Item, Index) Add new entry
RemoveEntry(Item) Remove entry
GetEntries() All entries (const)
FindFirstItemByType(Tag) Find by item type
MarkItemDirty(Entry) Mark entry for replication
MarkArrayDirty() Force full resync

FInventoryEntry

Property Purpose
Item The item object
TopLeftIndex Grid position (INDEX_NONE if unplaced)

Source Reference

Core Classes

File Location Purpose
ItemContainerComponent.h Inventory/Components/ItemContainerComponent.h Main container component
ItemContainerComponent.cpp Inventory/Components/ItemContainerComponent.cpp Implementation
FastArray.h Inventory/FastArray/FastArray.h FInventoryFastArray, FInventoryEntry
IItemContainer.h Inventory/Interfaces/IItemContainer.h Container interface
InventoryController.cpp UI/Controllers/InventoryController.cpp Cursor-hold state machine (pickup / dispatch / reconcile)

Key Functions

Function File Purpose
TryAddItemToContainer() ItemContainerComponent.cpp Main add logic
TryStackItem() ItemContainerComponent.cpp Stacking logic
TryPlaceItem() ItemContainerComponent.cpp Find available position
IsSpaceAvailable() ItemContainerComponent.cpp Grid validation
MarkGridOccupied() ItemContainerComponent.cpp Update occupation bitmap
PlaceAndDisplace() ItemContainerComponent.cpp Cursor-pickup place-onto-occupant (displaces the occupant)
BeginHeldDispatch() / ReconcileHeldState() InventoryController.cpp Dispatch-anchor commit/reject reconcile for the cursor hold
PostReplicatedAdd() FastArray.cpp Client add callback
PreReplicatedRemove() FastArray.cpp Client remove callback


Recent Changes

Date Change Impact
- Initial spatial grid implementation 2D item placement
- FastArray replication Delta replication for efficiency
- Desync detection pattern Server validates client state
- IItemContainer interface Polymorphic container operations
2026-07-02 Cursor pickup (click-to-hold) model Virtual hold with a real anchor (item never leaves its container server-side); new atomic Server_PlaceAndDisplaceById/PlaceAndDisplace op for place-onto-occupant chained swaps; commit/reject reconcile via dispatch-anchor comparison (BeginHeldDispatch/ReconcileHeldState on UInventoryController); own-container-only eligibility (vendor/loot stay drag-only)