I recently spend quite a while on server-authoritative movement for my multiplayer game.
At first, I thought it wouldn’t be that hard: client simply sends input to server, which simulates object state. Server periodically broadcasts object state and client checks to make sure it’s close enough to the server state.
I soon realized this problem was much harder than I thought originally. Eventually I borrowed the code from the uLink snowball demo and used that as a starting point. After much experimentation I’d like to point out some gotchas that I encountered while working on it.
Movement code must be entirely deterministic
Given the same input, your movement code should have almost exactly the same output, every time.
In my case, my character physics script has all movement code (the entire update function, basically) wrapped in a public Simulate function. This function takes delta time as a parameter, which is very important as you want to make sure client and server call Simulate with the same delta (this is what makes it deterministic, aside from the occasional precision error)
Use FixedUpdate
This isn’t a strict necessity, but I highly recommend you use FixedUpdate for your movement code, because FixedUpdate is a lot more reliable than Update. FixedUpdate runs a fixed number of times per second, while Update is entirely dependent on framerate. For one, I use FixedUpdate to gather input and send it to the server, and using Update means the bandwidth will depend on framerate (network usage being linked to render speed is pretty fugly IMHO). For another, using Update means I have to transmit delta to the server, which opens me up to speedhacking (clients can specify a sufficiently large enough delta to teleport), using FixedUpdate means client and server have the same timestep and the server can just use Time.fixedDeltaTime
Make sure update order is deterministic.
For a while, I simply let my physics code run, and periodically grabbed the input state and sent it to the server. This led to frequent desyncs, and I modified my code to manually step the simulation after getting user input state. Now, it always runs in this order:
- Client gathers user input state
- Client steps the physics simulation with input state
- Client sends input to server (also stores input state so it can replay them later)
- Server steps the physics simulation with input state
This might seem obvious, but if you have your physics in a separate script you can easily end up with indeterministic tick order. In my case, I disabled the update function in my physics script and manually stepped it from my network controller.
Overview of my code
As a bit of an overview, here’s how my server-authoritative code works:
- Client gathers user input state
- Client steps the physics simulation with input state
- Client gets result of physics step (in my case, position)
- Client sends user input state and simulation result (position) to server
- Server steps the physics simulation with input state
- Server compares its own simulation result with result sent from client. If they differ too much, server sends correct state to client
- If client receives state correction, it goes back through stored input states back to the point of the “correct state” and replays them (in my case, I timestamp each input state with network time, so I find the one with the closest timestamp and replay all inputs from that point). This involves resetting the state to the correct state sent by the server, then iterating over each stored input stepping the physics simulation. This is necessary because the state correction sent by the server is old (due to network latency, it was sent some time in the past) so we need to re-apply all of our inputs since that time in order to stay current.
Basically, a lot of what I’m saying boils down to this: if you get a lot of rubber banding and desyncs happening in your code, that means you need to go back and make sure your client and server are running the EXACT same code, with the EXACT same inputs, in EXACTLY the same order. If it looks like they are, take a step back, drink a cup of coffee, and come back to it after taking a break. Chances are you’re missing something small somewhere that accounts for the difference (in my case it was usually code running in a different order between client and server)