I changed a variable on the server. On the server, it changed. On every client, it did not. The health bar sat there full whilst the actor was visibly being shot, and I sat there equally full of certainty that I'd done everything right, which is the traditional opening to learning how Unreal's replication actually works.
The mental model I'd arrived with was wrong in a specific way. I assumed that if a value lived on a replicated actor, changing it anywhere would propagate it everywhere. It doesn't. Replication in Unreal flows in one direction by default, from the server, which has authority, down to the clients. A client changing a replicated variable changes it locally and the server neither knows nor cares, and the next replication update from the server will quietly stomp the client's value back to the server's truth. Authority is the whole game here, and I'd been ignoring it.
So first, the variable has to be marked for replication, both in the declaration and in GetLifetimeReplicatedProps:
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
void AMyActor::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyActor, Health);
}
Marking it Replicated gets the value to the clients. But there's a second trap, and it's the one that actually had me. Setting Health on a client did nothing useful, because the client has no authority. The change has to happen on the server. From a client you ask the server to do it, via a Server RPC, and the server applies the change, and then replication carries the new value back out.
The ReplicatedUsing bit was the piece I didn't know I needed. A plain replicated variable updates silently, which is fine for, say, a position the renderer reads every frame. But for something like health, where I wanted to flash the screen red or play a sound the moment it dropped, I needed a hook that fires when the new value arrives. That's OnRep_Health, a RepNotify function that the engine calls on clients whenever the replicated value updates:
void AMyActor::OnRep_Health()
{
UpdateHealthBar();
if (Health <= 0.0f)
{
PlayDeathEffects();
}
}
The thing that unstuck me, in the end, was a clean separation in my head. The server owns the truth. Clients ask the server to change the truth and then react to the truth arriving. Replicated carries the value, RepNotify tells the client it landed, and a Server RPC is how a client politely requests a change it isn't allowed to make itself. Once I stopped trying to set values directly on clients and started routing every change through the authority, the health bar dropped when it should, and I felt slightly foolish for the afternoon I'd spent insisting the engine was broken when it was working exactly as designed.