Client-side Prediction Multiplayer
I recently read two excellent and fascinating articles on client-server architectures for multiplayer games by Yahn W. Bernier and Gabriel Gambetta. I was also simultaneously learning how to use Godot/GDScript, and had a cool idea for a co-op racing game, where teams of 2 players compete, one driving, and one shooting, attacking the other vehicle. I pounced on the opportunity and implemented:
- Client-side Prediction
- Server Reconciliation
- Entity Interpolation
This protects the game integrity from cheating players, but still allows for non-cheating players to get instant feedback on their inputs, and smooth (but delayed) visuals for other players’ inputs. This is ideal for fast-paced, twitchy control games, like FPSs or racing games. Below is my implementation, where a server and two clients are running locally, and messages have a synthetic random latency in between 0 and 100ms for testing:
In this demo, the clients are sending state updates to the server every 100ms. Every message is:
[
{
"movement": Vector3 # The user input, converted to world space.
"time_delta": int # The number of milliseconds between inputs
"time": int # The UNIX timestamp in milliseconds
}, ...
]
where “uncommitted” inputs (i.e. inputs not yet accepted by the server) populate the array. This makes the message protocol resilient to an out-of-order, unreliable network protocol like UDP.
You can imagine that a hardened server would examine movement
relative to time_delta
to make sure
a player hasn’t cheated to give themselves super speed, and perhaps kick players who haven’t sent
messages in a while, or have a UNIX timestamp that’s very off.
Upon executing user inputs and updating the game world, the server broadcasts the following game state every 100ms:
{
"players": {
${client_id: int} {
"entity": string # The visual asset to use
"position": Vector3 # World position
"heading": Vector3 # Forward vector (assuming global up is player up)
"last_time": int # The last input timestamp committed by the server
"past": [ # The last few states of this player, for interpolating
...
]
}
}
}
Thus, players are able to stop sending inputs they realize are acknowledged by inspecting last_time
,
and are able to interpolate remote players’ state with the last few recorded states.
There is of course some room for improvement in this protocol: relatively static information, like the visual asset could be transmitted in a separate message; a smaller bit timestamp could be used if a more recent reference point than the UNIX epoch is agreed upon; a more packed encoding like MessagePack could be used; only remote players in the local player’s view could be transmitted.
The demo still needs some more work to get closer to the game concept, but here’s what it might look like, based off of an earlier Unity-based demo. Enjoy! 😃