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

USTRUCTs as Network Payloads, and Exposing Them to Blueprints

Defining a replicable USTRUCT payload in Unreal, making it a BlueprintType with editable members, and understanding how it rides inside an actor's replication.

A struct definition on screen alongside a Blueprint node graph

Where we are in the series: part 2 of six. In part 1 we established that you replicate values as structs and presence as actors. Now we build the value.

The unit of replicated data in Unreal, for the common case, is a USTRUCT. It is cheap, it sits inline inside the owning actor, and with a couple of specifiers it becomes a fully fledged Blueprint type that designers can read, write, and pass around in graphs. This is the workhorse, so it is worth getting the declaration exactly right.

a payload struct

Here is a small damage event, the kind of thing you might replicate when a hit registers:

#include "DamageEvent.generated.h"

USTRUCT(BlueprintType)
struct FDamageEvent
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Damage")
    float Amount = 0.f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Damage")
    TEnumAsByte<EDamageType> Type = EDamageType::Physical;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Damage")
    FVector_NetQuantize HitLocation = FVector::ZeroVector;

    UPROPERTY(BlueprintReadOnly, Category = "Damage")
    TObjectPtr<AActor> Instigator = nullptr;
};

A few deliberate choices in there.

BlueprintType on the USTRUCT is what makes the struct appear in the Blueprint type picker, so you can declare variables and function parameters of FDamageEvent in graphs. Without it the struct is C++ only.

BlueprintReadWrite exposes a member to be both read and written from Blueprint. BlueprintReadOnly exposes it for reading only, which I have used on Instigator because designers have no business reassigning who dealt the damage. EditAnywhere is a separate axis: it controls the details panel, letting the member be edited on instances and defaults in the editor. The two are orthogonal. You frequently want both, but they answer different questions ("can a graph touch it" versus "can I set it in the editor").

FVector_NetQuantize instead of a raw FVector is a small but real win. It serialises the vector at reduced precision suitable for positions, saving bytes on every replication. There is a family of these (FVector_NetQuantize10, _NetQuantize100, FVector_NetQuantizeNormal) trading precision for size. For a hit location, full float precision is wasted; nobody can see a millimetre.

how a struct replicates

The struct does not replicate on its own. It replicates because it is a UPROPERTY(Replicated) on a replicating actor, exactly like the int32 from part 1:

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

public:
    AProjectile();

    UPROPERTY(Replicated)
    FDamageEvent LastHit;

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

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

By default the engine serialises a struct member by member, comparing each UPROPERTY against the last acknowledged value and sending the lot if any of them changed. That is the important caveat: a plain struct is replicated atomically. If one field changes, the whole struct goes over the wire. For a four-field damage event that is fine. For a struct with twenty fields where one byte flickers every frame, it is wasteful, and that is precisely the problem part 3 solves with a custom NetSerialize.

There is also a subtlety with comparison. The engine decides whether a struct property changed using its identical operation. For a vanilla USTRUCT this is a member-wise compare generated for you. If you later hand-roll serialisation, you may also need to think about identity, but for the default path it just works.

struct versus UObject, in practice

It is worth being concrete about why this beats a replicated UObject for data, because part 1 stated it and you are entitled to the detail.

A struct is a value embedded in the actor's memory and in the actor's replication channel. There is no separate channel, no separate relevance calculation, no reference fix-up on the client. When the actor replicates, the struct is just part of the property set. Lifetime is trivially the actor's lifetime.

A UObject payload, by contrast, needs to be a registered replicated subobject, which means implementing ReplicateSubobjects, marking the subobject channels, and dealing with the window where the client has the outer actor but not yet the subobject. You take on garbage collection, reference replication via the package map, and a second relevance story. You do all that to gain polymorphism and independent reference identity. If your data is a flat value, you are paying a tax for features you are not using.

The honest trade-off: structs cannot be polymorphic over the wire (you cannot replicate "some subclass" of a struct), and they are copied by value. If you need a base-class pointer that resolves to different concrete types per item, that is a genuine reason to reach for a replicated UObject or an UInstancedStruct. But reach for it deliberately, not by default.

a note on object pointers in structs

You will have spotted the TObjectPtr<AActor> Instigator and wondered how that survives the trip. Honestly: carefully. A raw actor pointer in a replicated struct is serialised as a network GUID through the package map, and it only resolves on the client if that actor is itself relevant and replicated to the same client. If the instigator is not relevant, the pointer arrives null until it is. We will return to this when we write custom serialisation in part 3, because handling object references by hand through UPackageMap is one of the sharper edges.

next

We now have a Blueprint-friendly value type that replicates atomically inside its owning actor. In part 3 we take the gloves off and write NetSerialize ourselves: bit-packing fields, quantising floats, and skipping fields that have not changed, so a fat struct stops costing a fat payload.