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

replication in unreal, and the actor that only moved on the server

A first pass at Unreal Engine 4's actor replication, the authority model that confused me, and why my pickup moved on the host but nowhere else.

A game development scene on screen

I've spent the last fortnight learning Unreal Engine 4's networking, and the honest summary is that replication is not hard, but it assumes you already understand a mental model that nobody states out loud until you've broken something. I broke something. A pickup that span gently in the world, visible to the host, completely invisible to every client. So here is the model I wish someone had handed me on day one.

There is one authority, and it is the server

The single idea everything hangs off: the server owns the truth. Clients are, fundamentally, displaying a prediction of what the server has told them. When you mark a property or an actor as replicated, you are not setting up a two-way sync. You are telling the engine: the server's copy of this is canonical, push it down to the clients. Replication flows server to client. It does not flow back up by magic, and it never flows client to client directly.

This is the bit I got wrong. I'd written movement code in the actor and tested it as a listen server, where one player is also the host. On a listen server the host is the authority, so my code ran on the authoritative copy and everything looked perfect. The moment I ran a second client, that client had its own non-authoritative copy of the actor, my movement code was happily running on it too, and the two were diverging because nothing was being told to replicate.

In C++ the first thing you do is opt the actor into replication in the constructor.

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

bReplicateMovement was the line I was missing. With it, the server's transform gets replicated down and the clients stop guessing. Without it, every machine animates its own copy and they drift apart immediately.

Unreal C++ replication code

HasAuthority, or how to stop doing work twice

Once bReplicates is on, your actor's logic runs on both server and client. That's not what you usually want for game state. Spawning, scoring, deciding that a pickup has been collected: those are decisions, and decisions belong to the authority. The guard you reach for constantly is a check on the network role.

void APickup::OnPickedUp(ACharacter* Collector)
{
    if (HasAuthority())
    {
        bIsCollected = true;
        Destroy();
    }
}

HasAuthority() is true on the server and on a listen-server host, false on a pure client. Wrap state changes in it. Let the effects of that change, the actor vanishing, a sound, a counter ticking up, ride down to clients through replication. Decide once, on the server. Show everywhere.

To make a property replicate at all you declare it and register it:

UPROPERTY(ReplicatedUsing = OnRep_IsCollected)
bool bIsCollected;

void APickup::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const
{
    Super::GetLifetimeReplicatedProps(Out);
    DOREPLIFETIME(APickup, bIsCollected);
}

The ReplicatedUsing part is the genuinely lovely bit. OnRep_IsCollected is a function that fires on the client when the replicated value actually changes. So the server flips bIsCollected to true, the new value arrives on each client, and each client's OnRep runs to play the effect locally. That's your hook for the visual and audio response, running on the machine that needs to see it, triggered by the server's decision. Clean separation, once it clicks.

What clients are allowed to do

Clients aren't completely mute. They send input to the server through server RPCs, functions marked to run on the server when called from a client.

UFUNCTION(Server, Reliable, WithValidation)
void ServerInteract();

The flow becomes: client presses a button, calls ServerInteract, the server validates it (the WithValidation half exists so you can reject nonsense before it touches game state), the server changes the authoritative state, and replication carries the result back to everyone. The WithValidation requirement felt like ceremony at first. It isn't. It's the engine making you draw the trust boundary explicitly, because a client is, in the general case, an adversary. Anything a client asserts, the server checks.

The takeaway from two weeks

My bug was entirely a testing artefact. A listen server hides the whole class of authority mistakes because the one machine you're testing on happens to be the authority. The instant you have a dedicated server, or even a second client, the non-authoritative copies show you every place you forgot to ask who's in charge. So I now do my first real test with a dedicated server and two clients, never the comfortable listen-server-of-one, because the comfortable case is exactly the one that lies to you.

Replication isn't difficult. It's just that the framework has firm opinions about who owns reality, and it would rather you learn them by reading the docs than by watching a pickup spin in an empty room while your second player sees nothing at all.