Seamless fast paced multiplayer in Unity3D. Implementing client-side prediction.

Christian Tucker
6 min readApr 11, 2020

--

NOTE: This post is more-so to go over the implementation in Unity3D than it is to explain the concepts of client-side prediction. Gabriel Gambetta does an amazing job explaining these system in his blog post Fast paced multiplayer games and I highly recommend you read it before continuing.

When developing multiplayer games there’s two key component that all developers need to take into consideration when designing their games. The user experience and cheating. The best method to preventing cheats in online games is to have what is called dumb clients. The idea is that whenever a client wants to do something in the game, it sends information to the server expressing what action it wants to take. The server will either then accept or deny that request, perform the action, and send the results back to the player. This prevents common cheats that you see in most single-player games. Speed hacking, god mode, increased damage, etc. The issue with this approach is that when you’re waiting on confirmation from the server for your actions the game can feel anywhere from sluggish to completely unresponsive depending on the client’s latency to the server. So how do we combat this? Through client-side prediction.

The first thing that we need to do is ensure that there’s no level of randomization when it comes to calculating our characters movement in-game, any randomization will cause the client & server to become out of sync and require a correction, which if it’s too extreme will be noticeable by the client. This is what we want to avoid at all costs.

Next, we need to make sure that any code that alters the client’s movement is shared between the server and client, any variation in this code will lead to client miss-predictions which will need to be corrected during the reconciliation stage.

For our example, we’re going to assume that we have a basic character in our game world that’s represented by our CharacterController class. For the sake of this post, we’re going to keep things quite simple, while using psuedo-implementations to account for networking. These implementations will be

In order for us to implement client-side prediction there’s two Message types that we need to implement. One that contains the character’s current Transform state SimuationState, and one that contains the client’s current input state ClientInputState. The ClientInputState is a collection of keys that user has either pressed, has held down, or has released. While the SimulationState simply contains Position and Velocity data for the character’s Transform. Both of these messages will contain the simulationFrame which will be used to perform reconciliation on the client when the server sends back the true state of the game.

In our Character controller we need a few things. First we need to create a handler for incoming messages containing the server’s simulations. Second, we need to create and capture the ClientInputState every frame on the client.

In the code above, we register a handler to receive simulation state messages from the server as-well as create the input state. The input state’s simulationFrame is set in the FixedUpdate function which is called at a fixed time-step, regardless of the client’s rendered FPS.

The next thing that we need to do is create a simple function that will act on the inputState that we’ve created to cause the character to move.

defaultInputState is declared as

private static ClientInputState defaultInputState = new ClientInputState();

You may be wondering why ProcessInputs takes a ClientInputState as a parameter if we already have access to it within the scope of the CharacterController class. That’s because this function will also be called from the server, which will not be running the Update() function to generate inputs from the keyboard. This movement script is extremely simple, moving the client by 1 unit in 45 degree increments, depending on which keys are being pressed. Now we just need to update the FixedUpdate method to call ProcessInputs and send the input to the server.

Awesome, so now that we’re moving our player we need to start keeping track of when the player moves and which input’s caused the movement. To do this we’re going to create a function that returns a SimulationState. This function will take ClientInputState as a parameter for the same reason as ProcessInputs. This function simply gets the position, rotation, and velocity of the character and creates a ClientInputState from it.

Next, we need to modify FixedUpdate to obtain the SimulationState and apply it to a cache. To do this, we first need to declare the cache in the CharacterController.

private const int STATE_CACHE_SIZE = 1024;private SimulationState[] simulationStateCache 
= new SimulationState[STATE_CACHE_SIZE];
private ClientInputState[] inputStateCache
= new ClientInputState[STATE_CACHE_SIZE];

We can then modify the FixedUpdate function to look like this:

Take a deep breath, the client-portion of this is /almost/ over. The last thing we need to do is setup reconciliation. Whenever we send the input state to the server, it is going to simulate the movement of the player based on the code in ProcessInputs and create a SimulationState just like the client does. It’s going to send this information back to the client containing the simulationFrame of the ClientInputState that was submitted to cause that movement. This is going to be made available in our OnServerSimulationFrameReceived handler, but we want to cache that value and only use the most recent frame from the server per frame.

Once we have the data from the server, we use the cache that we set up above to look up the client’s SimulationState and make sure that it matches the servers, if it does not then we need to go back in time and correct the client’s position. To do this we need to change the cached state to match the servers, and then process all of the cached inputs between the simulationFrame the server just responded with to now, where the client currently is.

To do this we need to create a variable for the server’s simulation state and assign it in our registered message handler.

Now, let’s create a function called Reconciliate which will compare the serverSimulationState against our cached simulation state, determine if a correction needs to be made, and if it does, cycle through the cached inputs to determine our “current” predicted state. NOTE: Create the following variable in your CharacterController.

private int lastCorrectedFrame;

We’re going to want to call this method right before we obtain our CurrentSimulationState in the FixedUpdate method… Our FixedUpdate should look like this:

That’s it, everything we need for the Client. If you’ve been following along, your CharacterController should look something like this.

Now… ONTO THE SERVER. We’re almost done, I promise.

The first thing we want to do here is create another MonoBehaviour that will handle our server’s simulation for us. This script should ONLY run on the server, so we will make sure of that by issuing a DestroyImmediate if the server is not active.

The next thing we need to do, very similar to on the client, is register a handler for the incoming ClientInputState messages. For the sake of time and clarity, in this example we’re going to assume that NetworkConnection#controller is a link established between the CharacterController on the server and the player.

We’re going to create a Dictionary that maps CharacterController's to a Queue<ClientInputState> for processing. Whenever we receive a new ClientInputState from a client, we’re going to add the state to the appropriate queue.

You should end up with something like this:

Next, in the FixedUpdate function, we’re going to loop over all of the registered CharacterController’s and call ProcessInputs on them for any queued inputs the client may have, once we call ProcessInputs we need to obtain the SimulationState and send that back to the client.

Once you’re done, you should have something similar to this:

That’s all there is to it!

Keep in mind that this is a very simple implementation of client-side prediction and that depending on your game you will need to modify the code quite a bit. I purposely used a CharacterController in this example that did not require Physics simulation, however stay tuned for my next post about predicting Rigidbodies.

Credits

GDC Talk, Overwatch Gameplay Architecture and Netcode.
GDC Talk, It IS Rocket Science. The Physics of Rocket League, Detailed.
Gabriel Gambetta, Fast Paced Multiplayer.
Christian Tucker, Implementation and Author.

--

--

Christian Tucker

Fan of everything react-native. The cloud is taking over. Artificial Intelligence is getting out of hand.