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

Constructing It Client-Side, and the Blueprint Surface

Reconstructing replicated state on the client with RepNotify and OnRep handlers, and exposing the result to Blueprint with BlueprintCallable factories, pure getters, and native events.

A Blueprint event graph reacting to a replicated value changing

Where we are in the series: part 5 of six. The wire format is sorted. Parts 3 and 4 got the bytes small and the lists efficient. The question now is what the client does when the bytes land, and how designers get at any of it.

Replicated data is useless until something reacts to it. There are two reaction mechanisms, and most features use both: RepNotify for "this single property changed", and the fast-array callbacks from part 4 for "an item in this list changed". On top of those sits the Blueprint surface, where you decide what designers can call, read, and respond to.

RepNotify

ReplicatedUsing turns a replicated property into one that fires a function on the client whenever the value arrives. This is how you rebuild derived state, kick off effects, or update UI without polling:

UCLASS()
class AVehicle : public APawn
{
    GENERATED_BODY()

    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health = 100.f;

    UFUNCTION()
    void OnRep_Health(float OldHealth);   // optional old-value parameter

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

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

void AVehicle::OnRep_Health(float OldHealth)
{
    const float Delta = Health - OldHealth;
    if (Delta < 0.f)
    {
        PlayDamageReaction(-Delta);
    }
    UpdateHealthWidget(Health);
}

Three things people trip over. The OnRep_ function must be a UFUNCTION(), or it will not be found and will not fire. The old-value parameter is optional but extremely useful: declare OnRep_Health(float OldHealth) and the engine passes the previous value, so you can compute a delta without keeping your own shadow copy. And the OnRep runs only on clients, not on the authority that set the value. If you want the same reaction to happen on the server (a listen-server host, for instance) you call the handler yourself after the server-side mutation. A common pattern is to set the value, then on the server explicitly invoke OnRep_Health(OldValue) so host and remote clients run the same path.

By default an OnRep fires only when the value actually changed. If you need it to fire every time the property is received even when unchanged, that is REPNOTIFY_Always, set via the DOREPLIFETIME_CONDITION_NOTIFY family. You rarely want it, but it exists for cases like re-triggering a one-shot effect.

reconstructing state

The deltas give you the new state, not the consequences of it. Reconstruction is the step where you turn "Health is now 40" or "an item was added" into the things the rest of the game cares about: cached totals, sorted views, spawned cosmetic actors, an open inventory panel refreshing.

The discipline that keeps this sane is one direction of flow. The server mutates authoritative state. Replication delivers it. OnRep and the fast-array callbacks are the only place client-side derived state gets rebuilt. If you find yourself updating a UI from a gameplay function directly on a client, you have a second source of truth and it will drift the moment a packet is reordered or lost and re-sent. Funnel everything through the notify.

void UInventoryComponent::OnItemAdded(const FInventoryItem& Item)
{
    RebuildSortedView();          // derived: a sorted/filtered cache
    OnInventoryChanged.Broadcast();   // tell anyone listening (incl. Blueprint)
}

the Blueprint surface

Now the part designers actually touch. You decide three things: what they can call, what they can read, and what they can respond to.

Call: UFUNCTION(BlueprintCallable) exposes a function as an executable node. Use it for actions and for factory functions that construct your data types. Note that BlueprintCallable does not imply network anything; if a designer-facing action must run on the server, the underlying call still has to go through a server RPC, which we cover in part 6. The Blueprint node is just a node.

// A factory: build a validated item from designer-friendly inputs.
UFUNCTION(BlueprintCallable, Category = "Inventory")
static FInventoryItem MakeInventoryItem(FGameplayTag ItemTag, int32 Quantity);

Read: UFUNCTION(BlueprintPure) is for side-effect-free getters. It produces a node with no execution pins, so it can be dropped anywhere a value is needed and re-evaluated freely. Mark accessors pure; mark anything that mutates BlueprintCallable instead. Lying about purity (a "pure" node that has side effects) causes genuinely baffling bugs because the node may be called more or fewer times than you expect.

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

Respond: two ways to hand an event to Blueprint, and the difference matters.

BlueprintImplementableEvent declares a function with no C++ body; the implementation lives entirely in Blueprint. You call it from C++, a designer fills it in. Use it when there is no sensible default behaviour and you just want a hook.

// No C++ body. Designers implement the graph.
UFUNCTION(BlueprintImplementableEvent, Category = "Inventory")
void OnItemPickedUp(const FInventoryItem& Item);

BlueprintNativeEvent gives you a C++ default that a designer can override. You implement the _Implementation and call the public name; if Blueprint overrides it, the graph runs, otherwise your C++ runs.

UFUNCTION(BlueprintNativeEvent, Category = "Inventory")
void OnInventoryFull();
void OnInventoryFull_Implementation();   // the C++ default

// call site uses the public name, not the _Implementation:
// OnInventoryFull();

The rule I use: BlueprintNativeEvent when there is meaningful default behaviour worth shipping, BlueprintImplementableEvent when the behaviour is purely a designer concern. And for "many listeners, loosely coupled", neither, use a dynamic multicast delegate (DECLARE_DYNAMIC_MULTICAST_DELEGATE) exposed as a BlueprintAssignable property, which lets multiple Blueprints and widgets bind to the same event. That is the OnInventoryChanged.Broadcast() from earlier.

pushing constructed data into Blueprint

Tie it together: the client receives a delta, the fast-array callback or OnRep fires, you rebuild derived state, and then you surface it to Blueprint through one of the above. A typical chain is OnRep or PostReplicatedAdd, then a RebuildSortedView, then a BlueprintAssignable broadcast that a widget is bound to, and optionally a BlueprintImplementableEvent for a one-shot designer-authored reaction like a pickup flourish. Each layer has one job, and the data only ever flows one way.

next

We have every piece: the value, the packing, the list, the notifications, and the Blueprint exposure. Part 6 assembles all of it into a complete replicated inventory component, server RPCs and all, and then catalogues the gotchas that wait for you in a real multi-client PIE session.