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

i thought multiplayer was just sending the player's position

Building a tiny two-player prototype in Unreal taught me that networked games are mostly about lying convincingly, and I was bad at lying.

A game development scene on a monitor

I set out to build the smallest multiplayer thing I could imagine in Unreal Engine 5: two players, a flat grey box of a level, and the ability to pick up a glowing cube and throw it at each other. A weekend, I thought. It was not a weekend. It was the most thoroughly humbling fortnight I have spent at a keyboard in a long time, and I want to write down why before the bruises fade and I start telling myself it was easy.

My mental model going in was embarrassingly simple. The server knows where everyone is. Each client sends its position up, the server sends everyone's positions back down, you draw the other player where they say they are. Send the position. How hard can it be.

the position is a lie, and that is the whole point

The first thing that breaks this model is latency. By the time the other player's position arrives at your machine, it is already 50 to 120 milliseconds out of date. If you draw them exactly where the packet says, they teleport: a stutter every time an update lands, smooth nowhere in between. So you interpolate, drawing them slightly in the past and smoothly blending between the last two known positions. Now they move smoothly, but you are deliberately rendering everyone a fraction of a second behind reality. The other player you are aiming at is not where you see them. They are where you see them, plus wherever they have moved since the packet left.

Then there is your own player. If your character only moves when the server says so, every input has a full round trip of lag before anything happens on screen. Press W, wait 100ms, start walking. Unplayable. So the client predicts: it moves you immediately on your own machine, then reconciles when the authoritative server update arrives. If the server disagrees (you ran into a wall it knew about and you did not), it snaps you back, and you write code to make that snap as unnoticeable as you can.

So I had three different versions of the truth running at once. The server's authoritative state. My client's optimistic prediction of my own player. My client's interpolated, deliberately-delayed view of everyone else. Multiplayer is not "send the position". It is a careful, structured set of lies, each one covering for the speed of light, and the art is keeping the lies consistent enough that nobody notices.

Networking code on screen

unreal hands you a lot, and hides a lot

Credit where it is due: Unreal's replication system does an enormous amount for you, and once it clicked it was genuinely impressive. You mark a property Replicated, wire up GetLifetimeReplicatedProps, and the engine ferries changes from server to clients for you. The CharacterMovementComponent already does client-side prediction and server reconciliation out of the box, which is the single hardest part, handed to you for free if you build on top of it.

void AThrowableCube::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(AThrowableCube, HeldBy);
    DOREPLIFETIME(AThrowableCube, ThrowVelocity);
}

The trouble is that the abstraction is so smooth you forget there is a wire underneath, right up until you do something it did not anticipate. My cube-throwing was that something. Picking up the cube needed to be a server action (the server owns the truth about who holds what), so it became a Server RPC. But I wanted the thrower to see the cube leave their hand instantly, before the server confirmed, so that needed prediction too. And the moment two players grabbed for the same cube on the same frame, both clients predicted success, both showed themselves holding it, and only one of them was right.

That last bug took me three evenings. The symptom was a cube that visibly existed in two places, held by two players, until the server updates arrived and one of them watched it rip out of their hands. The cause was that I had treated "can I pick this up" as a local question when it is fundamentally a global one. The fix was to stop predicting the pickup at all and accept the round-trip delay for that one action, while keeping prediction for movement where it actually matters. Knowing which lies you can afford to tell is most of the skill.

the things that bit me, briefly

A few that I will not forget:

  • Authority versus Remote confusion. Code that ran fine in the editor's single-player path quietly did the wrong thing the moment it ran on a remote client, because I had not checked HasAuthority() before mutating state I did not own.
  • Testing solo lies to you. Everything works perfectly at zero latency. Unreal's network emulation (inject 100ms and some packet loss) turned my smooth prototype into a twitching mess and showed me what was actually fragile.
  • RPCs are not guaranteed to be ordered the way you assume across different reliability settings, and I leaned on ordering I had not earned.

Debugging output on a screen

what i actually came away with

The prototype works now. Two players, a grey box, a glowing cube that you can grab and lob, and it holds together at 120ms with a bit of packet loss without anyone teleporting through a wall. It is ugly and it is tiny and I am stupidly proud of it, mostly because I now understand how little I understood at the start.

The humility was the point. I have spent years on backend systems where the network is something you handle with timeouts and retries and the occasional grim look at a flame graph. Games invert it. The network is not an obstacle between you and the result, it is the medium the entire experience swims in, and every frame is a negotiation between what is true, what is plausible, and what the player can be persuaded to believe. "Just send the position" is the kind of sentence that only survives until you try it.

I am keeping the prototype around as a humility benchmark. Next time something looks like a weekend, I will remember the cube that was in two places at once, and pad the estimate accordingly.