Ramblings of an aging IT geek
← Ramblings of an aging IT geek
gamedev

Putting It Together: A Worked Inventory Replication Example

A complete server-authoritative replicated inventory component in Unreal using FFastArraySerializer, server RPCs, and a Blueprint-exposed change event, plus the gotchas that bite in PIE.

A multi-client PIE session showing an inventory syncing across windows

Where we are in the series: part 6 of six, the payoff. We assemble parts 1 to 5 into one working inventory and then go through everything that will go wrong the first time you test it.

The brief: a server-authoritative inventory on an actor component, items delta-replicated with FFastArraySerializer, add and remove driven by server RPCs, and a single Blueprint-facing event that fires whenever the inventory changes. Nothing here is new if you read the series; this is the integration.

the data types

The item and the array, exactly the shape from part 4:

USTRUCT(BlueprintType)
struct FInventoryItem : public FFastArraySerializerItem
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly, Category = "Inventory")
    FGameplayTag ItemTag;

    UPROPERTY(BlueprintReadOnly, Category = "Inventory")
    int32 Quantity = 0;

    void PostReplicatedAdd(const struct FInventoryArray& Serializer);
    void PostReplicatedChange(const struct FInventoryArray& Serializer);
    void PreReplicatedRemove(const struct FInventoryArray& Serializer);
};

USTRUCT()
struct FInventoryArray : public FFastArraySerializer
{
    GENERATED_BODY()

    UPROPERTY()
    TArray<FInventoryItem> Items;

    UPROPERTY(NotReplicated)
    TObjectPtr<class UInventoryComponent> Owner = nullptr;

    bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
    {
        return FastArrayDeltaSerialize<FInventoryItem, FInventoryArray>(
            Items, DeltaParms, *this);
    }
};

template<>
struct TStructOpsTypeTraits<FInventoryArray>
    : public TStructOpsTypeTraitsBase2<FInventoryArray>
{
    enum { WithNetDeltaSerializer = true };
};

the component

UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class UInventoryComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    UInventoryComponent();

    // Designer-facing entry points. Validate, then route to the server.
    UFUNCTION(BlueprintCallable, Category = "Inventory")
    void RequestAddItem(FGameplayTag ItemTag, int32 Count);

    UFUNCTION(BlueprintCallable, Category = "Inventory")
    void RequestRemoveItem(FGameplayTag ItemTag, int32 Count);

    UFUNCTION(BlueprintPure, Category = "Inventory")
    int32 GetQuantity(FGameplayTag ItemTag) const;

    // One event everything binds to.
    UPROPERTY(BlueprintAssignable, Category = "Inventory")
    FOnInventoryChanged OnInventoryChanged;

    // Called from the fast-array callbacks.
    void HandleItemAdded(const FInventoryItem& Item);
    void HandleItemChanged(const FInventoryItem& Item);
    void HandleItemRemoved(const FInventoryItem& Item);

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;

private:
    UFUNCTION(Server, Reliable)
    void ServerAddItem(FGameplayTag ItemTag, int32 Count);

    UFUNCTION(Server, Reliable)
    void ServerRemoveItem(FGameplayTag ItemTag, int32 Count);

    UPROPERTY(Replicated)
    FInventoryArray Inventory;
};

FOnInventoryChanged is a dynamic multicast delegate declared with DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnInventoryChanged), which is what makes BlueprintAssignable legal.

Now the implementation. Note the wiring of Inventory.Owner in BeginPlay, which the fast-array callbacks from part 4 depend on, and the authority checks that keep the server the only thing mutating state:

UInventoryComponent::UInventoryComponent()
{
    SetIsReplicatedByDefault(true);   // the component itself must replicate
}

void UInventoryComponent::BeginPlay()
{
    Super::BeginPlay();
    Inventory.Owner = this;           // so callbacks can call back in
}

void UInventoryComponent::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(UInventoryComponent, Inventory);
}

void UInventoryComponent::RequestAddItem(FGameplayTag ItemTag, int32 Count)
{
    if (Count <= 0) return;
    ServerAddItem(ItemTag, Count);    // client asks; server decides
}

void UInventoryComponent::ServerAddItem_Implementation(FGameplayTag ItemTag, int32 Count)
{
    // Authority only. Re-validate: never trust the client's request.
    if (Count <= 0) return;

    for (FInventoryItem& Item : Inventory.Items)
    {
        if (Item.ItemTag == ItemTag)
        {
            Item.Quantity += Count;
            Inventory.MarkItemDirty(Item);
            OnInventoryChanged.Broadcast();   // server/host fires too
            return;
        }
    }

    FInventoryItem& NewItem = Inventory.Items.AddDefaulted_GetRef();
    NewItem.ItemTag = ItemTag;
    NewItem.Quantity = Count;
    Inventory.MarkItemDirty(NewItem);
    OnInventoryChanged.Broadcast();
}

void UInventoryComponent::HandleItemAdded(const FInventoryItem& Item)
{
    OnInventoryChanged.Broadcast();   // client path, via PostReplicatedAdd
}

And the fast-array callbacks forward into the component:

void FInventoryItem::PostReplicatedAdd(const FInventoryArray& S)
{
    if (S.Owner) S.Owner->HandleItemAdded(*this);
}
void FInventoryItem::PostReplicatedChange(const FInventoryArray& S)
{
    if (S.Owner) S.Owner->HandleItemChanged(*this);
}
void FInventoryItem::PreReplicatedRemove(const FInventoryArray& S)
{
    if (S.Owner) S.Owner->HandleItemRemoved(*this);
}

That is the whole feature. A client calls RequestAddItem, which routes to ServerAddItem (a reliable server RPC), the server mutates and marks dirty, the delta replicates, and on every client PostReplicatedAdd or PostReplicatedChange fires, broadcasting OnInventoryChanged. A widget bound to that delegate refreshes. The server/host broadcasts directly because its OnRep equivalents do not fire on the authority.

sequenceDiagram
    participant UI as Client Blueprint/UI
    participant C as Client component
    participant S as Server component
    participant All as All clients
    UI->>C: RequestAddItem(tag, n)
    C->>S: ServerAddItem RPC (reliable)
    S->>S: validate, mutate, MarkItemDirty
    S->>All: FastArray delta (changed item only)
    All->>All: PostReplicatedAdd -> OnInventoryChanged
    All->>UI: widget refreshes

gotchas

This is the part that saves you a week.

Authority and ownership. A Server RPC only travels if the calling client owns the actor the component lives on. Ownership means the actor's owning connection is that client's, which for player-driven things usually flows from the PlayerController. Put this inventory on an unowned world actor and the server RPC silently does nothing. Check GetOwner()->GetNetConnection() resolves, and put player inventories on a pawn or player state that the controller owns.

Relevancy. Replication is relevance-gated, as part 1 laboured. If the owning actor is not relevant to a client (distance-culled, not yet spawned on that client), its inventory does not replicate there. For a player's own inventory this is fine because they are always relevant to themselves, but for "see another player's loadout" you must ensure the other actor is relevant, possibly with bAlwaysRelevant or NetCullDistanceSquared tuning. Do not reach for bAlwaysRelevant casually; it defeats the culling that keeps bandwidth sane.

Reliable versus unreliable. Our add/remove RPCs are Reliable because losing an inventory mutation is unacceptable. Reliable RPCs are ordered and guaranteed but share a limited reliable buffer; flood it and you can overflow and disconnect the client. Use Unreliable for high-frequency cosmetic things (a movement nudge, a transient effect) and keep Reliable for state that must not be lost. Never spam a reliable RPC every tick.

NetSerialize and object pointers. If your items hold UObject or AActor references, recall from part 3 that those go through UPackageMap and resolve to null until the referenced object is itself relevant and replicated. Set bOutSuccess = false when a reference fails to map so the engine retries; otherwise you ship null and never recover. Prefer stable identifiers (a FGameplayTag, a soft reference, an id you resolve locally) over raw pointers in replicated data where you can.

Struct size and array bounds. The default replication property has a size budget, and a single overgrown struct or a huge array can blow past the per-property limits or simply cost too much in a single bunch. Fast arrays help by sending only deltas, but a genuinely enormous inventory still wants paging or chunking. If you see "RPC parameter too large" or bunch overflow warnings, that is the engine telling you a payload is too big for one go.

Testing in PIE. Test with Number of Players set to two or more and Net Mode as Play As Client, so you get a real server plus separate client worlds rather than a single shared one. Bugs that only appear across the network (the missing DOREPLIFETIME, the OnRep that never fires on the host, the unowned-actor RPC that goes nowhere) are invisible in single-player PIE and obvious the moment you have two client windows. Watch the Network Profiler if bandwidth matters; it shows you exactly which property is costing you, which is how you find the fat struct you forgot to pack.

that's the series

Six parts, one through-line: get the shape right (part 1), make values Blueprint-friendly (part 2), pack them tightly (part 3), replicate lists as deltas (part 4), reconstruct and expose on the client (part 5), and assemble the whole thing here. The engine never sends you an object. It sends you the smallest set of changes it can, to the clients that need them, and leaves you to rebuild meaning on the far side. Once that clicks, the rest is just choosing your specifiers well.