I had a small third-person prototype in Unreal that worked. A character that ran around, picked things up, opened a door, fought a couple of dim enemies. Single player, all the logic living happily in one place, no surprises. I'd spent a few evenings on it and I was pleased with myself. So naturally I decided to make it multiplayer, because how hard could it be. The engine has replication built in. There's a checkbox. There are tutorials.
Reader, it humbled me.
The thing nobody tells you clearly enough up front is that "add multiplayer" is not a feature you bolt on. It's a different model of where truth lives. In single player, your machine is the universe. Whatever your code says is what happens, immediately, with no argument. The moment you go networked, your machine becomes one opinion amongst several, and most of those opinions are wrong, late, or both. The server decides what actually happened. Every client is running a hopeful guess and waiting to be corrected.
the first wall: nothing replicated
My first attempt was to play in the editor with two clients, run around, and watch the other player do absolutely nothing. I'd move, they'd see me standing still. They'd move, I'd see them standing still. Both of us, alone together.
This is the moment most people meet Replicated and SetReplicates(true) for the first time. In Unreal, an Actor doesn't share its state across the network unless you tell it to, property by property, with UPROPERTY(Replicated) and a GetLifetimeReplicatedProps implementation to register them. Movement is a partial exception because CharacterMovementComponent does a lot of clever work for you, which is precisely why it lulls you into thinking the rest will be easy.
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health);
DOREPLIFETIME(AMyCharacter, bIsCarrying);
}
So I dutifully marked things replicated and felt clever again. That feeling lasted until I tried to pick something up.
the second wall: authority
The pickup logic ran on whichever machine pressed the button. On a listen server that's mostly fine for the host, because the host is the server. On a client, it's a disaster, because the client doesn't have the authority to change the world. It can change its own local copy, briefly, and then the server stamps on it.
The pattern I needed, and resisted for an embarrassingly long time, is: the client asks, the server decides. A client never directly mutates shared state. It sends a Server RPC, the server validates it (is the object close enough, is it still there, are you even allowed), the server performs the change, and that change replicates back out to everyone including the client that asked. The client's job is to make a polite request and then trust the answer.
UFUNCTION(Server, Reliable, WithValidation)
void ServerTryPickup(AActor* Target);
bool AMyCharacter::ServerTryPickup_Validate(AActor* Target) { return Target != nullptr; }
void AMyCharacter::ServerTryPickup_Implementation(AActor* Target)
{
if (!HasAuthority()) return;
if (!IsWithinReach(Target)) return;
// mutate state here, on the server, where it counts
}
Once that clicked, half my bugs evaporated and the other half got more interesting.
the third wall: it looks fine on my machine
This is the cruel one. Everything worked perfectly when I tested it, because I was testing on a listen server with zero latency to myself. The host is always going to feel great. The host is lying to you about the whole experience.
I added simulated latency in the editor's network emulation settings, a hundred milliseconds and a bit of packet loss, and suddenly the prototype felt like a different game. Doors opened a beat late. Picking something up had a tiny hesitation while the round trip to the server completed. An enemy I'd clearly hit on my screen sometimes survived because, on the server's authoritative timeline, I'd missed. None of this was a bug in the sense of broken code. It was the actual, honest behaviour of a networked system, which I'd simply never seen because I'd only ever played God on my own box.
You can paper over some of it with client-side prediction, where the client optimistically shows the action immediately and reconciles if the server disagrees. Movement does this for you. For my pickup I added a little local prediction: show the object as held straight away, and quietly snap it back if the server says no. It feels better. It is also more code, more state, and more ways to be subtly wrong, and I gained a lot of respect for anyone who ships this for a living.
what i actually took away
I did not finish the multiplayer prototype. I got it to the point where two players could run around the same world, fight the same dim enemies, and mostly agree on reality, and then I stopped, because I'd learned the thing I actually came for.
The lesson is that multiplayer is not an extension of single player, it's a constraint you design under from the first line. If I started again I wouldn't write the single-player version first and convert it. I'd ask, for every piece of state, "who owns the truth and who's allowed to change it", and answer that before writing the logic. Authority isn't a feature you add at the end. It's the question the whole architecture is an answer to.
My tidy little prototype taught me more by resisting me than it ever did by working. I'll take the bruise. It was a good one.