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

the multiplayer prototype that ate my weekend and my ego

Building a small networked prototype in Unreal Engine 5 and learning, painfully, why replication is hard and why authority belongs on the server.

A game development workstation with an editor on screen

I decided to build a tiny multiplayer prototype. Two players, a shared arena, pick up a glowing cube, throw it at the other player, keep score. The kind of thing a competent engineer should be able to knock together in a weekend. I am, by most measures, a competent engineer. The weekend disagreed.

The plan was modest. Unreal Engine 5, C++ for the gameplay code because I wanted to actually understand what was happening rather than wire up a Blueprint spaghetti diagram. I had the single-player version working in about two hours. The cube spawned, you could grab it, the throw arc felt good. I was pleased with myself, which in retrospect was the warning sign.

the moment it all went sideways

The single-player version was a lie. It worked because there was only one source of truth: the machine I was sitting at. The instant I added a second client, every assumption I had quietly baked in fell over.

Here is the thing nobody tells you clearly enough when you start. In Unreal's model the server is authoritative, and almost everything you do on a client is a polite suggestion that the server may or may not honour. I had been mutating game state directly on the client, because of course I had, that is how single-player works. The cube grabbed fine on my screen and simply did not exist for the other player. The score went up locally and nowhere else. I had built two separate games that happened to share a window title.

Networking code on a dark editor background

The fix is conceptually simple and emotionally bruising. State changes go through the server. The client asks, the server decides, the result replicates back. In practice that means Server RPCs for input that mutates the world, replicated properties for state that everyone needs to see, and a hard rule that the client never trusts its own copy of anything that matters.

replication, authority, and the cube that teleported

My first attempt at replicating the cube's position was to mark the location as a replicated property and call it done. The cube juddered. It would lurch towards where the server thought it was, then drift, then lurch again. This is the classic mistake: replicating a transform that is also being driven by physics on every machine independently. The server's physics and the client's physics disagree by a few millimetres every tick, and those disagreements pile up into visible jitter.

What I needed was for the server to own the simulation and the clients to interpolate towards the replicated truth, rather than each running their own divergent physics. Unreal has movement replication machinery for exactly this, and I had been fighting it instead of using it. Once I let the server be the only place the cube's physics actually ran, the juddering stopped.

The throw was its own lesson. I wanted the throw to feel instant on the client that initiated it, because waiting for a server round-trip before the cube leaves your hand feels terrible. So you predict locally, fire the cube on the client immediately, send the server RPC, and then reconcile when the authoritative result comes back. Most of the time the prediction is correct and nobody notices. When it is wrong, you have to gently correct it without it looking like the cube hiccuped. I got this roughly seventy percent right and decided that was enough for a prototype. The remaining thirty percent is, I now understand, where the actual engineering lives.

UFUNCTION(Server, Reliable)
void ServerThrowCube(FVector_NetQuantize Direction, float Power);

void AArenaPlayer::ServerThrowCube_Implementation(FVector_NetQuantize Direction, float Power)
{
    if (!HasAuthority() || HeldCube == nullptr)
    {
        return;
    }

    HeldCube->ReleaseAndLaunch(Direction, Power);
    HeldCube = nullptr;
}

More networking code on screen

The HasAuthority() check at the top is the whole religion in one line. If this code is running anywhere that is not the server, do nothing. I cannot tell you how many bugs disappeared once I started guarding every state mutation with that and actually meant it.

what humbled me

The humbling part was not the difficulty. I expected networking to be hard. The humbling part was how confidently I had built something that was fundamentally wrong, and how good it had looked while being wrong. Single-player game code lets you get away with murder. Every shortcut, every bit of state you reach out and grab directly, every assumption that there is one timeline and you are standing on it. Multiplayer takes all of those shortcuts and turns each one into a bug that only appears when a second person is watching.

A few things I would tell myself on Saturday morning:

  • The server is the truth. Write that on a sticky note. The client is a rumour mill with a pretty renderer.
  • Latency is not an edge case you handle later. It is the medium you are working in. Design for it from the first line.
  • HasAuthority() is your friend. Sprinkle it everywhere state changes, and your debugging time roughly halves.
  • Test with real latency, not localhost. Localhost has a round-trip time of basically zero and hides every prediction bug you have.

I got the prototype to a state where two people could actually play it and the score was correct on both screens. It is not shippable. It is barely demonstrable. But it works in the specific sense that the thing I thought I understood, I now actually understand, having been thoroughly corrected by it. That is worth a weekend, even if my ego is still recovering.

The cube, for what it is worth, feels great to throw. That part was easy. It always is.