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

Breaking Up Data Objects to Send Across the Network in Unreal

Why you don't replicate a fat UObject in Unreal, what actually goes over the wire, and how the series will build a proper replicated data pipeline from structs and subobjects.

A whiteboard sketch of a server fanning state out to several game clients

Where we are in the series: this is part 1 of six. We start with the question everyone gets wrong on their first multiplayer feature, which is "how do I just send this object to the client?"

The short version is that you don't. Unreal does not let you pick up a UObject, lob it over the socket, and have an identical copy land on the other side. That mental model comes from REST and message queues, where you serialise a blob and ship it. Unreal's replication is a state synchronisation system, not a transport for objects, and the sooner you internalise that distinction the less you will suffer.

Here is the plan for the six parts, so you know where this is going:

  1. This part: why a fat replicated UObject is the wrong shape, and what actually replicates.
  2. USTRUCT payloads and exposing them to Blueprint.
  3. Custom NetSerialize, packing fields by hand to save bandwidth.
  4. FFastArraySerializer, replicating lists without resending the whole thing.
  5. Reconstructing state on the client, RepNotify, and the Blueprint surface.
  6. A complete worked inventory example, plus the gotchas that bite in PIE.

what actually replicates

Replication in Unreal lives on the AActor. An actor flagged with bReplicates = true gets a channel on each relevant client's connection, and the server periodically diffs the actor's replicated properties against what it believes each client last acknowledged. Only the properties that changed get serialised, and only to clients for whom the actor is currently relevant. That last clause matters: replication is per-connection and relevance-filtered, which is why "just send the object" is meaningless. There is no single recipient.

A property only replicates if you opt it in. Two things are required. First, the UPROPERTY(Replicated) specifier:

UCLASS()
class APickup : public AActor
{
    GENERATED_BODY()

public:
    APickup();

    UPROPERTY(Replicated)
    int32 ChargesRemaining = 0;

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

Second, you register the property in GetLifetimeReplicatedProps, which is where the engine learns which properties to track and under what conditions:

#include "Net/UnrealNetwork.h"

void APickup::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(APickup, ChargesRemaining);
}

And of course the actor has to actually replicate:

APickup::APickup()
{
    bReplicates = true;
}

Forget either the UPROPERTY(Replicated) or the DOREPLIFETIME, and the property silently stays put on the server whilst the client shows whatever the default was. There is no compile error and no warning. This is the single most common "why won't it replicate" support question, and the answer is nearly always one of those two halves missing.

the diagram I wish I'd had on day one

sequenceDiagram
    participant S as Server (authority)
    participant C1 as Client A channel
    participant C2 as Client B channel
    S->>S: tick, diff replicated props
    Note over S: ChargesRemaining changed 3 -> 2
    S->>C1: delta: ChargesRemaining = 2
    S->>C2: delta: ChargesRemaining = 2
    Note over C2: Actor not relevant, nothing sent
    C1->>C1: apply, fire OnRep if any

The server is the single source of truth. Clients receive deltas. There is no symmetric "object" being passed around, just a stream of property updates per connection, gated by relevance and priority.

why not a fat UObject

So why can't you make a big UInventoryData object with thirty UPROPERTY fields and replicate that? A few reasons, and they stack up.

UObjects are not network-addressable by default. Actors get replication channels; arbitrary UObjects do not. You can replicate a UObject as a subobject of an actor (via ReplicateSubobjects and registering it with the replication system), but it is fiddly, it carries the full cost of a tracked object on every connection, and it gives you a per-object channel overhead you usually do not want for what is really just a bag of data.

The bandwidth shape is wrong too. If your data is conceptually a value, a stat block, a damage event, an inventory line, then a separate replicated object means the engine tracks lifetime, reference, and relevance for a thing that has no independent existence. You pay object-graph costs for what should be a few bytes inline.

And the relevance and ownership story gets muddy. A replicated subobject inherits its owning actor's relevance, but now you are reasoning about two lifetimes instead of one. When the data is small and owned, a USTRUCT replicated inline as a property of the actor is simpler, cheaper, and easier to make correct.

The rule of thumb I have landed on after enough bruises: replicate actors when the thing has presence in the world and an independent lifetime, replicate structs when the thing is a value owned by an actor, and reach for replicated subobjects only when you genuinely need polymorphism or an independent reference identity on the client. Most "data object" problems are the middle case, and we will spend the next few parts making that case as efficient and as Blueprint-friendly as possible.

next

In part 2 we define an actual USTRUCT payload, make it a first-class Blueprint type, and look at exactly how it rides along inside an actor's replication. Later parts go under the hood with custom NetSerialize (part 3), delta arrays (part 4), client reconstruction (part 5), and a full inventory build (part 6).

Get the shape right first. Optimising the serialisation of the wrong abstraction is just polishing a mistake.