Multiplayer Grid-Based Inventory System
Grid inventory with rotating items, nested containers, caliber based magazines, server validated moves, and persistence.

Feature context
- Project context
- Obversus: Umbra
- Status
- In development
- Engine
- Unreal Engine 5.8
Responsibilities
- Designed and implemented the inventory in C++: grid fragments, equipment slots, magazine and ammo logic, and server validation.
- Built the networking: replicated item list with server authoritative moves and client rejection.
- Built persistence, world items and loot containers, the character portrait, and the editor icon studio.
Overview
A grid inventory. Items sit in slots or on grid cells, nest in containers, and rotate 90 degrees. Dropping ammo on a magazine loads it. The server decides every change, saves persist per player, and a live portrait follows the body.

Items are data
A definition holds display data, footprint, mesh, and sounds. Fragments add behavior, and a fragment exists only on the items that need it:
| Fragment | Adds |
|---|---|
| Container | Grids, shown side by side |
| Mounts | Slot tags, such as a weapon mount on a backpack |
| Equipment | Worn slot, and whether it can be removed |
| Firearm | Magazine slot and chamber caliber |
| Magazine | Caliber and capacity |
| Ammo | Caliber and maximum stack |
if (Definition->FindFragment<UItemFragment_Ammo>())
{
// Ammo loads into magazines and chambers instead of taking a slot.
}
New content is a data asset, not a class.
Where an item lives
struct FItemLocation
{
int32 ParentId = INDEX_NONE; // Container item, or the component root
FGameplayTag Slot; // Set when the item sits in a slot
int32 GridIndex = 0; // Set when the item sits on a grid
FIntPoint Position; // Top left cell on that grid
bool bRotated = false; // Footprint turned 90 degrees
};
Root slots hold worn items, containers hold grids, and mounts carry items in slots. Free space is searched in order: the equip slot, auto-place containers, then root grids.
Fit is a bounds check, then an overlap check against the container’s other items:
const FIntPoint GridSize = Grids[Location.GridIndex].Size;
const FIntPoint Size = Definition->GetSize(Location.bRotated);
if (Location.Position.X < 0 || Location.Position.Y < 0
|| Location.Position.X + Size.X > GridSize.X
|| Location.Position.Y + Size.Y > GridSize.Y)
return false;
for (const FItemEntry& Other : ItemList.Items)
{
if (Other.ItemId == IgnoreItemId || !Location.SameContainer(Other.Location))
continue;
if (InventoryPrivate::RectsOverlap(Location.Position, Size,
Other.Location.Position, Other.GetSize()))
return false;
}
return true;
One component, many owners
The same component runs on the body, loot containers, and world items, so one move function covers looting, dropping, and picking up.
Moving an item
Clients ask through a server call. The server checks both ends, then transfers the item:
void UInventoryComponent::ServerMoveItem_Implementation(UInventoryComponent* Source, int32 ItemId,
UInventoryComponent* Target, FItemLocation Location)
{
if (!CanAccess(Source) || !CanAccess(Target) || !TransferItem(Source, ItemId, Target, Location))
{
ClientRequestRejected(ItemId);
}
}
Worn items in locked slots stay put. A round dropped on a magazine loads it, so rounds never take a cell. A refused move leaves the item where it was.
Magazines and rounds
A firearm holds one chambered round and one magazine. Firing, loading, and unloading share one count of rounds, so the counts cannot drift apart:
bool UInventoryComponent::ConsumeRound(int32 GunId)
{
FScopedChangeBatch Batch(*this);
FeedChamber(GunId);
const FItemEntry* Chambered = FindRounds(GunId);
if (!Chambered || !FindItem(GunId)->Definition->FindFragment<UItemFragment_Firearm>())
{
return false;
}
RemoveItem(Chambered->ItemId);
FeedChamber(GunId);
return true;
}
Reload fits the fullest loose magazine that has rounds, then chambers a round.
Fitting a magazine
The server validates the fit like any move, then multicasts it. Each machine plays the fit montage on the owner’s body, and the owner plays it at once from their own screen. A loose gameplay tag disables hand IK during the montage. Each machine clears its own tag on montage end, including on interruption.
On the server, a move that lands in a part slot of its host is a fit:
bMoved = TransferItem(Source, ItemId, Target, Location);
const FItemEntry* Host = bMoved && Location.IsSlot() ? Target->FindItem(Location.ParentId) : nullptr;
if (Host && Host->Definition && Host->Definition->HasPart(Location.Slot))
{
MulticastFitMagazine(Moved);
}
The owner already played it, so the multicast skips the local player:
void UInventoryComponent::MulticastFitMagazine_Implementation(const UItemDefinition* Magazine)
{
const APawn* Pawn = GetOwner<APawn>();
if (Pawn && !Pawn->IsLocallyControlled())
{
PlayFitMagazine(Magazine);
}
}
The tag lives only on this machine. The end delegate runs on interruption too, so the tag never sticks:
AbilitySystem->AddLooseGameplayTag(FitMagazineActionTag);
FOnMontageEnded OnEnded = FOnMontageEnded::CreateWeakLambda(Body,
[WeakAbilitySystem = TWeakObjectPtr<UAbilitySystemComponent>(AbilitySystem),
Tag = FitMagazineActionTag](UAnimMontage*, bool /*bInterrupted*/)
{
if (UAbilitySystemComponent* Ability = WeakAbilitySystem.Get())
{
Ability->RemoveLooseGameplayTag(Tag);
}
});
Body->Montage_SetEndDelegate(OnEnded, FitMagazineMontage);
Sorting and weight
A container sorts by mode. If the sorted order does not fit, nothing moves. The inventory sums item weight against a limit, shown in the status panel.
Items sort by category, then by name, with the id as the final tie break so the order is stable:
Indices.Sort([this, Mode](int32 A, int32 B)
{
const FItemEntry& EntryA = ItemList.Items[A];
const FItemEntry& EntryB = ItemList.Items[B];
if (Mode == EInventorySortMode::ByType)
{
const int32 CategoryOrder = SortCategory(EntryA).Compare(SortCategory(EntryB));
if (CategoryOrder != 0)
return CategoryOrder < 0;
}
const int32 NameOrder = SortName(EntryA).Compare(SortName(EntryB), ESearchCase::IgnoreCase);
return NameOrder != 0 ? NameOrder < 0 : EntryA.ItemId < EntryB.ItemId;
});
Packing is first fit, row major, unrotated before rotated. It writes into scratch occupancy, so a failure changes nothing:
for (const int32 Index : Indices)
{
if (!PackFirstFit(Occupied, Grids, ItemList.Items[Index].Definition, NewLocations))
{
return false; // The real list is untouched.
}
}
for (int32 Order = 0; Order < Indices.Num(); ++Order)
{
FItemEntry& Entry = ItemList.Items[Indices[Order]];
Entry.Location = NewLocations[Order];
ItemList.MarkItemDirty(Entry);
}
World items and loot
A world item holds a small inventory on a physics body. Taking it is a server call. Loot containers work the same way and open through a server call. Both implement the interaction interface from the FPS post.
A stack picks up what fits and leaves the rest on the ground:
void UInventoryComponent::ServerPickUp_Implementation(UInventoryComponent* WorldInventory)
{
if (!WorldInventory || !Cast<AWorldItem>(WorldInventory->GetOwner()) || !CanReach(WorldInventory))
return;
const FItemEntry* Root = WorldInventory->FindItemInSlot(InventoryTags::Slot_World);
const UItemDefinition* Item = Root ? Root->Definition.Get() : nullptr;
if (Item && Item->IsStackable())
{
const int32 Added = AddToStacks(Item, Root->Count);
WorldInventory->SetItemCount(Root->ItemId, Root->Count - Added);
return;
}
FItemLocation Location;
if (Root && FindFreeLocation(Item, Location, /*bAllowEquip=*/ true))
{
TransferItem(WorldInventory, Root->ItemId, this, Location);
}
}
The server accepts moves into a loot target only after it opened that target. A living player is never loot:
void UInventoryComponent::ServerOpenLoot_Implementation(UInventoryComponent* Target)
{
const APawn* TargetPawn = Target ? Target->GetOwner<APawn>() : nullptr;
if (!Target || Target == this || (TargetPawn && TargetPawn->GetController()) || !CanReach(Target))
return;
LootTarget = Target;
OnLootTargetChanged.Broadcast();
}
bool UInventoryComponent::CanAccess(const UInventoryComponent* Other) const
{
return Other == this || (Other && Other == LootTarget && CanReach(Other));
}
Persistence
The save is local to each player. Changes wait a short delay and then write once. A joined player sends their loadout to the server once per body. Restore gives every item a fresh id, and an item with no free spot falls back to the first free one. Death deletes the save.
Many changes in a row start one timer, so they become one write:
void UInventoryPersistenceComponent::MarkSaveDirty()
{
if (!SaveKey.IsEmpty() && !SaveTimer.IsValid())
{
GetWorld()->GetTimerManager().SetTimer(SaveTimer, this, &ThisClass::WriteSave, SaveDelay);
}
}
The write goes to a temporary file and then replaces the old save. A crash during the write leaves the previous save whole:
if (!UGameplayStatics::SaveGameToMemory(Save, Bytes)
|| !FFileHelper::SaveArrayToFile(Bytes, *Temp)
|| !IFileManager::Get().Move(*Path, *Temp, /*Replace=*/ true))
{
UE_LOG(LogInventoryPersistence, Error, TEXT("Could not write inventory save %s."), *Path);
}
Restore places parents first, so each item’s container is back before its content. An item whose container is lost goes wherever it fits, and its own content still follows it:
TArray<FSavedItem> Pending = Saved;
while (!Pending.IsEmpty())
{
int32 Next = Pending.IndexOfByPredicate([&NewIds](const FSavedItem& Item)
{ return Item.ParentId == INDEX_NONE || NewIds.Contains(Item.ParentId); });
const bool bExact = Next != INDEX_NONE;
if (!bExact)
{
Next = FindTopmostOrphan(Pending);
}
Place(Pending[Next], bExact); // Exact spot, else the first free one.
Pending.RemoveAt(Next);
}
User interface
A local player subsystem opens the screen. It draws the replicated list and sends move requests on drag. It never edits its own copy.
A drop only picks the sound to play and asks the server. The list changes when replication arrives:
void UInventoryScreen::RequestMove(const UInventoryDragDrop* Operation,
UInventoryComponent* Target, const FItemLocation& Location)
{
const FItemEntry* Entry = Operation->Source->FindItem(Operation->ItemId);
if (Entry && Operation->Source == Target && Entry->Location.SameSpot(Location))
{
return; // Put back where it was: nothing to ask.
}
AddPending(Operation->Source, Operation->ItemId, Target, Operation->Definition, PickSound(Target, Location));
PlayerInventory->ServerMoveItem(Operation->Source, Operation->ItemId, Target, Location);
}
Editor tools: icon studio
AItemIconStudio renders item icons in a photo booth, so the catalog looks consistent. BakeIcon writes T_Icon_* next to the definition. RebakePoseUsers re-renders every item that shares a pose. An editor widget handles rotate, zoom, save, and PNG export.
The frame fits the mesh as the camera sees it. Each vertex is projected onto the view’s right and up axes, and the outline’s middle becomes the aim point:
for (const FVector& Point : Points)
{
const FVector Delta = Point - GetActorLocation();
const FVector2D Projected(FVector::DotProduct(Delta, Right), FVector::DotProduct(Delta, Up));
Min = FVector2D::Min(Min, Projected);
Max = FVector2D::Max(Max, Projected);
}
const FVector Centre = Right * (0.5f * (Min.X + Max.X)) + Up * (0.5f * (Min.Y + Max.Y));
A shared pose re-renders every definition that uses it, found through the asset registry:
for (const FAssetData& Asset : Assets)
{
UItemDefinition* Definition = Cast<UItemDefinition>(Asset.GetAsset());
if (Definition && Definition->IconPose == Pose
&& CaptureIcon(Definition, Pose->Rotation, Pose->Zoom, Pose->Offset, Pose->bFlipHorizontal, Pixels, Size))
{
Written += WriteIconTexture(Definition, Pixels, Size) ? 1 : 0;
}
}