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

the multiplayer prototype that put me firmly in my place

What I learned building a small networked prototype in Unreal, mostly that client-side prediction and server authority are harder than the tutorials let on.

A game development scene on a monitor

I set out to build a tiny multiplayer prototype in Unreal Engine 5. Two players, a top-down arena, things that move and things that get hit. I budgeted a weekend. I had read the networking documentation, I understood replication in the abstract, and I have written distributed systems for a living. How hard could a four-character deathmatch be?

I am writing this three weekends later, having learned that the answer is "harder than that, and harder than that, and then once more harder than that."

the lie I told myself

The lie was that I already understood the problem because I understand distributed systems. I do, in the sense that matters for backends: I know about consensus, about ordering, about the fact that the network will betray you at the worst moment. None of that was wrong. It was just aimed at the wrong target.

A backend distributed system mostly cares about correctness. It is allowed to be a bit slow if it gets the right answer. A multiplayer game cares about feel, which is correctness plus the constraint that the player must never perceive the latency that is unavoidably there. You cannot wait for the server to confirm a move before showing it, because a hundred and twenty milliseconds of input lag makes the game feel like wading through treacle. So you predict. The client shows the result immediately, the server validates it for real, and when they disagree you have to reconcile without the player noticing the seam. That reconciliation is the entire game of networked gameplay, and it is where my weekend went to die.

In Unreal terms this is the dance between GetLocalRole() and GetRemoteRole(), between ROLE_AutonomousProxy and ROLE_Authority, and the moment you realise the same Tick runs on both ends with subtly different truths. My first version moved the character on the client, replicated the position, and looked perfect on my machine. Then I added a 100ms simulated latency in the network emulation settings and watched the other player teleport around like a ghost with a grudge.

A close view of C++ source on screen

server authority, learned the painful way

My instinct, the backend instinct, was to make the server authoritative for everything and let the clients be dumb terminals. This is correct! It is also not enough, because a dumb terminal feels dreadful to play. So I rebuilt it the way Unreal actually wants you to: the client runs the same movement code locally and immediately, sends its input to the server, and the server runs the authoritative simulation. When the authoritative result comes back, the client compares it to what it predicted and, if they differ, smoothly corrects.

The character movement component does a lot of this for you, which I did not appreciate until I tried to do it myself for a non-character actor and discovered exactly how much labour Epic had hidden. Here is the shape of the input path I ended up with, stripped down:

void AArenaPawn::SetupPlayerInputComponent(UInputComponent* Input)
{
    // Runs on the owning client. We move locally and tell the server.
    Input->BindAxis("MoveForward", this, &AArenaPawn::Move);
}

void AArenaPawn::Move(float Value)
{
    if (IsLocallyControlled())
    {
        ApplyMove(Value);        // predict immediately, no waiting
        ServerMove(Value);       // ask the authority to do it for real
    }
}

void AArenaPawn::ServerMove_Implementation(float Value)
{
    ApplyMove(Value);            // authoritative; result replicates back
}

bool AArenaPawn::ServerMove_Validate(float Value)
{
    return FMath::Abs(Value) <= 1.0f;  // never trust the client
}

The _Validate function is the bit I kept forgetting, and it is the bit that matters. The client is the enemy. Not my friend across the table, the client process itself, which a cheater can and will modify. Anything you let the client decide unilaterally is a thing someone will eventually decide to abuse. Speed, position, whether a shot connected: all of it has to be the server's call, with the client merely predicting what the server is likely to say.

the humility part

Here is where the title comes from. Every single bug I hit was something I had been warned about. The documentation says client and server roles differ. The talks say validate everything. The forum posts say do not replicate what you can recompute. I had read all of it and nodded along, and I still walked straight into each pothole, because reading about prediction and reconciliation is nothing like watching your own character rubber-band across the floor because you replicated a velocity you should have derived.

More C++ replication code on a dark editor

There is a particular flavour of humbling that comes from being experienced and still being a beginner. I have shipped real systems. I am not a junior engineer. And yet here I was, genuinely stumped for an evening because two clients disagreed about whether a projectile had spawned, the answer being that I had spawned it on the wrong authority and let it replicate into existence twice. A junior would have made that mistake. I made it with twenty years of supposed wisdom and a much wider vocabulary for describing my failure.

The thing I will keep is not the prototype, which is a janky little arena that nobody will ever play. It is the recalibration. Multiplayer gameplay programming is a real discipline with its own deep tradition, and my backend instincts are a useful starting point and absolutely not a substitute for actually learning it. The people who make networked games feel good are doing something hard, and they make it look easy, and looking easy is the most expensive thing in the world to achieve.

I budgeted a weekend and got a lesson instead. Reasonable trade, on reflection. The prototype can stay broken. I came out understanding why every multiplayer game I have ever sworn at was, in fact, a small miracle.