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

the multiplayer prototype that taught me humility

Building a small multiplayer prototype in Unreal Engine 4, and learning the hard way that the network is not a wire that carries your variables.

A game development scene rendered on screen

I wanted a small thing. A top-down arena, two players, a ball they could shove around. A weekend project, nothing more. Three weeks later I understood why people get paid actual money to do networked games, and I had a much quieter opinion of my own abilities.

The mistake I made is the one everyone makes. I built the whole thing single-player first, got it feeling lovely, and then thought I'd just "add multiplayer". As if multiplayer were a feature you bolt on at the end, like a sound effect. It is not. It is the substrate. Build on the wrong substrate and you spend your evenings prying the house off its foundations.

the network is not a wire

The thing that took me longest to internalise, and I mean genuinely longest, is that in Unreal's model there isn't one game running. There are several. The server runs one. Each client runs its own. They are different programs, with different memory, that happen to be pretending to share a world. The whole job is keeping that pretence convincing.

In single-player I had code like this scattered everywhere, and it was fine:

void APlayerPawn::Shove()
{
    Ball->AddImpulse(GetActorForwardVector() * ShoveStrength);
}

In multiplayer that line is a lie. When a client calls it, the client moves its local copy of the ball. The server never hears about it. The other player never hears about it. You end up with two players staring at two completely different balls, each utterly convinced they're winning. It is genuinely funny to watch, the first time. Less so the fifth evening running.

The fix is that the client doesn't do the thing. The client asks the server to do the thing, and the server, being the single authority, does it and tells everyone.

// Client calls this; it runs on the server.
UFUNCTION(Server, Reliable)
void ServerShove();

void APlayerPawn::ServerShove_Implementation()
{
    // Only the server reaches here. Trust nothing the client said
    // beyond the fact that it pressed a button.
    Ball->AddImpulse(GetActorForwardVector() * ShoveStrength);
}

That Server, Reliable marker does an enormous amount of quiet work. Unreal generates the RPC plumbing, serialises the call, ships it over the wire, and runs the implementation on the authoritative copy. I'd read about all this. Reading about it and feeling it in your own broken prototype are different kinds of knowing.

Source code for the networked pawn on screen

replication, and the word "relevant"

So the server moves the ball. How does the client find out where it went? Replication. You mark a property as replicated, the engine watches it on the server, and when it changes the engine tells the clients. You don't write the netcode. You declare intent and the engine carries it.

UPROPERTY(Replicated)
FVector_NetQuantize BallVelocity;

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

This felt like magic until I watched the bandwidth. The engine doesn't ship updates the instant something changes. It batches. It prioritises. It decides some actors aren't "relevant" to a given client and quietly stops sending them. For a two-player arena none of that mattered, but I could see the shape of the problem you'd hit at scale, and I had a sudden deep respect for anyone shipping a game with a hundred players in view.

the lie you tell to feel responsive

Here is the part that properly humbled me. Even with all the above working, the game felt awful. You'd press shove and the ball would move a fraction of a second later, after your input had gone to the server and the result had come back. On a LAN it was tolerable. Over the internet it would have been unplayable.

The answer is that you lie. The client predicts the result locally, immediately, so it feels responsive, and then quietly reconciles when the server's authoritative version arrives. If the server agrees, you see nothing. If it disagrees, you snap to the truth, and the player hopefully doesn't notice. Every responsive networked game you have ever played is doing this, constantly, behind your back. I had played thousands of hours of these games and never once thought about the machinery. Now I couldn't stop seeing it.

I got a crude version working. Local prediction on the shove, a correction when the server's velocity replicated back. It was rough. It popped sometimes. But two browser tabs, sorry, two game instances, could shove a ball around and mostly agree on where it was, and I sat back genuinely pleased.

what I actually took away

The prototype is not going anywhere. It was never meant to. What I keep is the humility, which is the honest title of this post. I went in thinking networking was a transport detail, a thing you add. I came out understanding it is the architecture, and that single-player is the special case where the server and the client happen to be the same machine.

If you're starting an Unreal multiplayer project: decide what the server owns on day one. Write the authoritative path first. Treat every client as a thing that asks politely and might be lying. Do that and the rest is detail. Do it the other way, the way I did, and you'll spend three weeks learning the same lesson with a worse mood.

Worth it, mind. I'd do it again.