Gameplay

Multiplayer Grid-Based Inventory System

Grid inventory with rotating items, nested containers, caliber based magazines, server validated moves, and persistence.

  • C++
  • Multiplayer
  • Gameplay Ability System
  • CommonUI
  • Data Driven
  • Blueprints
Inventory screen over the first person view: equipment slots, a chest rig and belt loadout, and an empty backpack grid beside a rendered character portrait.

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.

Inventory screen over the first person view: equipment slots, a chest rig and belt loadout, and an empty backpack grid beside a rendered character portrait.
Inventory screen in a play session on the test level. Item art and the character are placeholders.

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;
    }
}