Inventory System¶
Summary: Server-authoritative spatial inventory using
UItemContainerComponentwith 2D grid-based storage. Items occupy grid cells, support stacking, and replicate efficiently viaFFastArraySerializer. All modifications go through server RPCs with desync detection.
Table of Contents¶
- Architecture Overview
- Core Concepts
- Grid System
- FastArray Replication
- Item Operations
- Stacking Behavior
- Server Authority
- Cursor Pickup (Click-to-Hold) Model
- Public Contracts
- Source Reference
- Related Systems
- Recent Changes
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 | PlaceHeldItemInContainer → RequestPlaceItemInContainer (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:
BeginHeldDispatchrecords the dispatch anchor (container + index) where the item verifiably sits before sending the op.- On the next
OnContainerContentsChanged,ReconcileHeldStatere-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):
CancelHeldItemdiscards 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
DropHeldItemToWorld→Server_DropItemon 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 |
Related Systems¶
- Item System - Item objects and creation
- Item Fragments - FGridFragment for dimensions
- Equipment System - Equipment slot container
- Loot System - Items spawning into inventory
- Character Framework - Component ownership
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) |