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

the day i finally understood unreal replication

Notes from learning Unreal Engine 4's networked replication model, authority, replicated properties and RPCs, the hard way.

A multiplayer game scene rendered in an editor viewport

I have been poking at Unreal Engine 4 in the evenings, and this week I tried to make two players see each other move. Simple ask. It took me three nights, two of which were spent being wrong in interesting ways. This is the writeup I wish I'd found on night one.

The core thing nobody tells you up front: in UE4 the server is the truth, and everything else is a polite suggestion. There is no peer-to-peer here by default. One machine is the server (it might also be a player, that's "listen server"), and the clients hold approximate copies of the world that the server is constantly correcting. Once that clicked, half of my confusion evaporated.

Authority, and who is allowed to decide things

Every actor has a concept of authority. On the server, HasAuthority() returns true. On a client's copy of that same actor, it returns false. This is the question you end up asking constantly, because if you mutate game state on a client, you are lying to yourself: the server will quietly stomp your change on the next update and you'll spend an hour wondering why the door keeps re-closing.

The pattern that finally worked for me was to gate any authoritative logic:

void AMyPawn::ApplyDamage(float Amount)
{
    if (!HasAuthority())
        return; // only the server decides health

    Health = FMath::Max(0.f, Health - Amount);
}

Health then needs to get back to the clients, which brings us to the actually clever bit.

Replicated properties

Mark a UPROPERTY as Replicated and Unreal will sync it from server to clients for you. You declare it, you opt the actor in with bReplicates = true in the constructor, and you tell the engine which properties via GetLifetimeReplicatedProps:

// header
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

// cpp
void AMyPawn::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const
{
    Super::GetLifetimeReplicatedProps(Out);
    DOREPLIFETIME(AMyPawn, Health);
}

void AMyPawn::OnRep_Health()
{
    // runs on clients when Health changes; update the health bar here
}

ReplicatedUsing is the part I underused at first. It gives you a callback on the client that fires precisely when the value arrives, which is exactly where you want to trigger UI and effects. Don't poll the value in Tick like I did for an embarrassing evening.

The thing worth internalising is that this is property state, not events. Replication sends the current value, not the history. If Health goes 100, 80, 60 between two network updates, a client that only sees the bookends gets 100 then 60, and never observes 80. That's fine for a health bar and disastrous if you were hoping to count the hits. For events you want RPCs.

A diagram of server-to-client property flow

RPCs, and the three flavours of them

Remote Procedure Calls are how you send the events. There are three, and the distinction matters more than the syntax:

  • Server RPCs run on the server, called from a client. This is how a client asks to do something ("I pressed fire"). The server validates and decides.
  • Client RPCs run on one specific client, called from the server. Good for "play this sound on the owning player's machine only".
  • Multicast (NetMulticast) RPCs run on the server and all clients. Good for cosmetic things everyone should see, an explosion, a sound.
UFUNCTION(Server, Reliable, WithValidation)
void ServerFire();

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayMuzzleFlash();

The naming convention (Server, Client, Multicast prefixes) is enforced enough that the engine will shout at you if you get it wrong, which I appreciated. Reliable versus Unreliable is your call: reliable RPCs are guaranteed to arrive and ordered, but you pay for that, so cosmetic fluff like a muzzle flash should be unreliable. Losing one is invisible; losing a "you died" is not.

WithValidation forces you to write a _Validate function that returns a bool. If it returns false, the engine assumes the client is misbehaving and disconnects them. It is the engine nudging you to never trust the client, which, after this week, I have come to feel quite strongly about.

A second diagram showing RPC call directions between client and server

What actually went wrong

My original sin was calling ServerFire() and expecting the muzzle flash to appear. It didn't, because I'd put the flash inside the server RPC, and the server was a dedicated process with nothing to render. The fix was the chain everyone eventually learns: client input calls a Server RPC, the server does the authoritative work, then the server calls a Multicast RPC so every machine, including the firing client, plays the cosmetic bit. Input goes up, truth comes down.

Two more small lessons. First, replication only happens for actors that are relevant to a client; Unreal culls things that are far away to save bandwidth, which is wonderful until you're testing two pawns on opposite ends of a level and wondering why nothing replicates. Second, the network update frequency is tunable per actor (NetUpdateFrequency), and the defaults are sensible, so resist the urge to crank everything.

It's a genuinely well-thought-out model once the shape of it lands. Server authoritative, properties for state, RPCs for events, and a deep, structural distrust of the client. I came in expecting to fight it and left rather impressed. Next: prediction and smoothing, so the thing I built doesn't feel like it's underwater. That's another three evenings, I suspect.