Where we are in the series: part 4 of six. We have efficient single values from part 3. Now we replicate collections of them properly.
A plain UPROPERTY(Replicated) TArray<FThing> works, and for tiny, rarely changing arrays it is fine. The problem is its change detection is coarse. When the engine decides the array has changed, it resends the array. Add one item to a list of fifty and you can end up paying for the whole list, plus you get no clean per-item notification on the client, just "the array is different now, diff it yourself". For inventories, status effects, scoreboard rows, anything that mutates element by element, this is the wrong tool.
FFastArraySerializer is the right one. It is a delta serialiser for arrays: it tracks a replication key per item and an array-wide key, sends only items that changed since the client last acknowledged, and hands you precise callbacks for added, removed, and changed items on the receiving side.
the two types
You need two structs. An item type deriving from FFastArraySerializerItem, and an array type deriving from FFastArraySerializer.
USTRUCT()
struct FInventoryItem : public FFastArraySerializerItem
{
GENERATED_BODY()
UPROPERTY()
FGameplayTag ItemTag;
UPROPERTY()
int32 Quantity = 0;
// Callbacks the array serialiser invokes on the client.
void PreReplicatedRemove(const struct FInventoryArray& InArraySerializer);
void PostReplicatedAdd(const struct FInventoryArray& InArraySerializer);
void PostReplicatedChange(const struct FInventoryArray& InArraySerializer);
};
USTRUCT()
struct FInventoryArray : public FFastArraySerializer
{
GENERATED_BODY()
UPROPERTY()
TArray<FInventoryItem> Items;
// Pointer back to the owning component so callbacks can do something useful.
UPROPERTY(NotReplicated)
TObjectPtr<class UInventoryComponent> OwnerComponent = nullptr;
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
{
return FFastArraySerializer::FastArrayDeltaSerialize<FInventoryItem, FInventoryArray>(
Items, DeltaParms, *this);
}
};
template<>
struct TStructOpsTypeTraits<FInventoryArray>
: public TStructOpsTypeTraitsBase2<FInventoryArray>
{
enum
{
WithNetDeltaSerializer = true,
};
};
Three things to note. The base item type carries a hidden ReplicationID and ReplicationKey; you never touch them directly, but they are how the serialiser knows which items moved. The array's NetDeltaSerialize forwards to the templated FastArrayDeltaSerialize, which does all the delta work. And the trait is WithNetDeltaSerializer, not WithNetSerializer from part 3; this is a delta serialiser, a different and more powerful hook.
FGameplayTag and FInventoryItem's own members will serialise through the usual path. If you wanted them packed tightly you could give FInventoryItem a custom NetSerialize as in part 3, and the two techniques compose.
marking dirty
The delta system only knows an item changed if you tell it. After you mutate an item you call MarkItemDirty; after you add or remove items you also mark the array. This is server-side, authority-only code:
void UInventoryComponent::ServerAddItem(FGameplayTag ItemTag, int32 Count)
{
check(GetOwner()->HasAuthority());
FInventoryItem& NewItem = Inventory.Items.AddDefaulted_GetRef();
NewItem.ItemTag = ItemTag;
NewItem.Quantity = Count;
Inventory.MarkItemDirty(NewItem); // bumps this item's replication key
}
void UInventoryComponent::ServerChangeQuantity(int32 Index, int32 NewQuantity)
{
check(GetOwner()->HasAuthority());
FInventoryItem& Item = Inventory.Items[Index];
Item.Quantity = NewQuantity;
Inventory.MarkItemDirty(Item); // changed, not added
}
void UInventoryComponent::ServerRemoveItem(int32 Index)
{
check(GetOwner()->HasAuthority());
Inventory.Items.RemoveAt(Index);
Inventory.MarkArrayDirty(); // structural change: re-index keys
}
MarkItemDirty(Item) assigns or bumps the item's replication key so the next delta includes it. MarkArrayDirty() is the heavier hammer for structural changes such as removal, where the per-item bookkeeping needs a full re-evaluation. Forgetting to mark is the equivalent of forgetting DOREPLIFETIME from part 1: the data sits on the server and never moves. There is no error.
the client callbacks
This is what makes fast arrays genuinely pleasant. On the receiving client, the serialiser calls one of three methods on each affected item, so you react to exactly what changed rather than diffing two arrays yourself:
void FInventoryItem::PostReplicatedAdd(const FInventoryArray& InArraySerializer)
{
if (InArraySerializer.OwnerComponent)
{
InArraySerializer.OwnerComponent->OnItemAdded(*this);
}
}
void FInventoryItem::PostReplicatedChange(const FInventoryArray& InArraySerializer)
{
if (InArraySerializer.OwnerComponent)
{
InArraySerializer.OwnerComponent->OnItemChanged(*this);
}
}
void FInventoryItem::PreReplicatedRemove(const FInventoryArray& InArraySerializer)
{
// Still valid here; this fires BEFORE the item is removed locally.
if (InArraySerializer.OwnerComponent)
{
InArraySerializer.OwnerComponent->OnItemRemoved(*this);
}
}
The timing matters and the names tell you the truth. PreReplicatedRemove fires before the item is removed from the client's array, so *this is still the valid outgoing item and you can read it to, say, play a "dropped" effect. PostReplicatedAdd fires after the new item exists locally. PostReplicatedChange fires after an existing item's contents updated in place. There is no "pre add" or "post remove" because they would have nothing useful to point at.
One wiring detail people miss: OwnerComponent is how the callbacks reach back into the component. It is not replicated (NotReplicated), so you must set it on the client, typically in the component's constructor or BeginPlay, otherwise your callbacks fire with a null pointer and do nothing.
the flow, in one picture
sequenceDiagram
participant Srv as Server component
participant FA as FastArray delta
participant Cli as Client component
Srv->>Srv: Items.Add(...), MarkItemDirty(item)
Srv->>FA: NetDeltaSerialize (changed items only)
FA->>Cli: delta: 1 added item
Cli->>Cli: item inserted locally
Cli->>Cli: PostReplicatedAdd -> OnItemAdded
Note over Cli: only the new item crossed the wire
why this beats a plain array
Concretely: a plain replicated TArray re-sends on coarse change detection and gives you no per-element hooks, so on the client you keep a shadow copy and diff it yourself every time, which is both code you have to write and a correctness trap. FFastArraySerializer sends only the dirty items, scales with the size of the change rather than the size of the list, and hands you typed callbacks at exactly the right moments. For anything that mutates incrementally, it is not a micro-optimisation, it is the correct data structure.
next
We can now replicate a value, pack it tightly, and maintain a list of values cheaply with clean change notifications. Part 5 turns to the client side properly: RepNotify, reconstructing usable state from the deltas, and exposing all of this to Blueprint with the right UFUNCTION specifiers and events.