Using Client-Side Prediction

This work flow is specific to Prediction v1. Version 2 will be significantly easier to use. Version 2 is nearing completion and a new guide will be made upon release.

There are several code examples in this guide. To see the complete script visit the Final Script.

When using CSP there is a very important detail to remember at all times. If something can affect your objects movement, it must be synchronized between the client and server. For example, if you want to jump then the character must be on the ground. Both the client and the server must check if the character is grounded before running the jump logic. As stated before though, with CSP the server and client run the same actions, so this is actually quite easy to do.

Due to the requirement of the client and server running the same logic, their timing must also be synchronized as well. This ensures the client and server processes input at the same rate. To accomplish this is quite easy, you must add the TimeManager component to your NetworkManager object, and set the Physics Mode to TimeManager.

You can learn more about TimeManager settings here.

Before providing an example of jumping using CSP I'm going to cover my actions structure. You can name this whatever you like, in my example it's called MoveData and is a structure to avoid garbage collection. Notice this also implements IReplicateData, this is required for datas which will used within replicate methods.

public struct MoveData : IReplicateData
{
    public bool Jump;
    
    /* Everything below this is required for
    * the interface. You do not need to implement
    * Dispose, it is there if you want to clean up anything
    * that may allocate when this structure is discarded. */
    private uint _tick;
    public void Dispose() { }
    public uint GetTick() => _tick;
    public void SetTick(uint value) => _tick = value;
}

By the end our MoveData is going to contain more information, but for now let's keep it simple. In a client authoritative or single player game here's what your code may look like if you were to try and jump.

private CharacterController _characterController;

private void Awake()
{
    _characterController = GetComponent<CharacterController>();
}

private void Update()
{
    if (!base.IsOwner)
        return;	
    if (Input.GetKeyDown(KeyCode.Space) && _characterController.isGrounded)
        Jump();
}

This code however is problematic when using CSP. The logic is run in Update when it should be performed in TimeManager_OnTick; I'll discuss this further in a moment. Additionally, it's all being executed on the client, so the server isn't even performing the actions. As discussed earlier CSP must run on both the client and the server.

Only the owning client and server must run the actions.

Part of the magic which makes CSP possible is the internal tick system of Fish-Networking. I'm not going to provide details on why the tick system exist or how it works, but it's important to remember that CSP must function within ticks.

To utilize the tick system we're going to subscribe to the OnTick event. Here's an example of how to do so, keeping in mind this script inherits from NetworkBehaviour.

public override void OnStartNetwork()
{
    base.OnStartNetwork();
    base.TimeManager.OnTick += TimeManager_OnTick;
}

public override void OnStopNetwork()
{
    base.OnStopNetwork();
    if (base.TimeManager != null)
        base.TimeManager.OnTick -= TimeManager_OnTick;
}

private void TimeManager_OnTick()
{ }

If you are not familiar with the NetworkBehaviour Callbacks it's worth reviewing those now.

The TimeManager does not tick every frame as Update does. Because of this, you must cache 'KeyDown' inputs in Update, and read those values in OnTick. Here's an example of doing so:

private bool _jumpQueued;

private void Update()
{
    if (!base.IsOwner)
        return;
    if (Input.GetKeyDown(KeyCode.Space) && _characterController.isGrounded)
        _jumpQueued = true;
}

Now when the TimeManager_OnTick method is called we know if the owner had performed the steps needed to jump. This is a more complete example of utilizing the information just talked about.

private CharacterController _characterController;
private bool _jumpQueued;

private void Awake()
{
    _characterController = GetComponent<CharacterController>();
}

public override void OnStartNetwork()
{
    base.OnStartNetwork();
    if (base.IsServer || base.IsClient)
        base.TimeManager.OnTick += TimeManager_OnTick;
}

public override void OnStopNetwork()
{
    base.OnStopNetwork();
    if (base.TimeManager != null)
        base.TimeManager.OnTick -= TimeManager_OnTick;
}

private void Update()
{
    if (!base.IsOwner)
        return;
    if (Input.GetKeyDown(KeyCode.Space) && _characterController.isGrounded)
        _jumpQueued = true;
}


private void TimeManager_OnTick()
{
    if (base.IsOwner)
    {
        BuildActions(out MoveData md);
    }
}

private void BuildActions(out MoveData moveData)
{
    moveData = default;
    moveData.Jump = _jumpQueued;

    //Unset queued values.
    _jumpQueued = false;
}

As of now we are setting our MoveData which will determine what actions the client and server will run. Neither however are actually running these actions yet. Here is an incomplete way to perform the actions.

private void TimeManager_OnTick()
{
    if (base.IsOwner)
    {
        BuildActions(out MoveData md);
        Move(md, false);
    }
}

[Replicate]
private void Move(MoveData moveData, bool asServer, Channel channel = Channel.Unreliable, bool replaying = false)
{
    //If jumping move the character up one unit.
    if (moveData.Jump)
        _characterController.Move(new Vector3(0f, 1f, 0f));
}

There is a lot of new information in relation to CSP within the code snippet above. Knowing how the method parameters work is crucial. Your naming of the parameters does not matter but they must be in the same order. You will also notice the Replicate attribute above the method. Placing the Replicate attribute indicates that the logic will be run on both the server and the client. There's quite a bit more going on behind the scenes, but nothing you will need to interact with.

There's also a problem with the code shown because the server is trusting the client can jump. The client checks for the KeyDown, and also if they are grounded; the server does neither. The server does not need to know if the client pressed the key or not, but it must be checking if grounded.

This is where things get fun! To make the provided code server authoritative we're going to make some changes.

private void Update()
{
    if (!base.IsOwner)
        return;
    if (Input.GetKeyDown(KeyCode.Space))
        _jumpQueued = true;
}

[Replicate]
private void Move(MoveData moveData, bool asServer, Channel channel = Channel.Unreliable, bool replaying = false)
{
    //If jumping move the character up one unit.
    if (moveData.Jump && _characterController.isGrounded)
        _characterController.Move(new Vector3(0f, 1f, 0f));
}

These changes move the grounded check out of Update and into the Move method. This means that moveData.Jump could still be set even if the player is not able to jump, but the jump itself will not be performed unless the CharacterController is grounded. Keeping in mind that replicate runs on the client and server, they both are now performing the same actions and checks! We now successfully have a server authoritative jump with client-side prediction.

CharacterController.isGrounded may return incorrect results at times; this is a Unity bug. For more accurate ground checks physics cast are recommended.

If you do use your own means to set grounded be certain to update the grounded check/state during replicate.

We've established that our moveData contains the actions the client intends to perform. Actions is not a technical term, it's more-so a catch-all for the clients inputs that tick. As you can clearly see though, there are an additional two booleans in our Move method: asServer and replaying.

You may be familiar enough with Fish-Networking to already know that asServer means the method is being called on the server side. This can be useful for server only checks.

Replaying is true if the inputs are being run an additional time through client-side prediction. What causes a replay has not been discussed yet but will be done later on. For now just keep in mind that if replaying is true then the inputs have already been processed at least once already by the client. Replaying will also never be true on the server, the server does not use replaying.

Let's see an example of how we can use asServer and replaying.

[SerializeField]
private AudioSource _jumpAudio;

[Replicate]
private void Move(MoveData moveData, bool asServer, Channel channel = Channel.Unreliable, bool replaying = false)
{
    //If jumping move the character up one unit.
    if (moveData.Jump && _characterController.isGrounded)
    {
        _characterController.Move(new Vector3(0f, 1f, 0f));
        if (!asServer && !replaying)
            _jumpAudio.Play();
    }
}

A serialized field named _jumpAudio has been added. In addition, whenever the character can jump the jump audio is also played. But notice how the audio is only played if not running as the server, and not replaying.

The server itself does not need to play audio, and we also do not want to play audio if replaying is true. Discussed just above, replaying is true if the inputs have already been run by the client once, and are being run again. Depending on latency the same inputs may be performed multiple times in a single tick. This can be confusing at first but I'll discuss the subject more later.

Imagine if the client pressed jump for the first time. Replaying will be false, and we will play the audio. But if the client is replaying the same jump input perhaps even over next few ticks depending on latency, you don't want the audio to play every time the input is replayed. You may also only want to display visual effects, or a range of other things when inputs are processed the first time; the replaying boolean can be very useful.

We aren't quite done with the Replicate method, but to understand upcoming changes Reconcile needs to be discussed first. It's also worth noting that the logic previously shown in our TimeManager_OnTick method is flawed, but that's another change that must be done after Reconcile is covered.

Reconciliation is the act of restoring the client to a state created by the server. For example, let's say the client passed jump checks and performed a jump, the server however believes the client cannot jump, whatever the reason may be. According to our code the client has jumped and is now 1 unit higher in the air from it. The server however, did not perform the jump and the client has not moved. This is where a de-synchronization occurs, but also where reconciliation comes in. It's worth noting that if your CSP code is proper the odds of a de-synchronization are fairly low, and even when one does occur the client will very rarely ever see corrections as they are smoothed overtime.

Like with Replicate, we are going to make another structure for Reconcile. This structure will contain the information which the client should reset. Like MoveData, the name can be whatever you like. Your reconcile data will implement it's interface, IReconcileData.

public struct ReconcileData : IReconcileData
{
    public Vector3 Position;
    
    /* Everything below this is required for
    * the interface. You do not need to implement
    * Dispose, it is there if you want to clean up anything
    * that may allocate when this structure is discarded. */
    private uint _tick;
    public void Dispose() { }
    public uint GetTick() => _tick;
    public void SetTick(uint value) => _tick = value;
}

Above is a new structure named ReconcileData, and the only value reset currently is the Position. The Replicate(or Move) method only changes the transforms position, and since it's a character controller that's the only state that really matters at this time. If you were using a Rigidbody however, you will likely need to also include velocities; I'll explain this a tad more in a few.

Now that we know position is the only value which can possibly de-synchronize in our code, let's make a method for correcting the value.

[Reconcile]
private void Reconcile(ReconcileData recData, bool asServer, Channel channel = Channel.Unreliable)
{
    //Reset the client to the received position. It's okay to do this
    //even if there is no de-synchronization.
    transform.position = recData.Position;
}

With our reconcile method in place we now need to call it from both the server and client. The replicate method also needs to be called from the server, which has not been done yet. Here's an update to the TimeManager_OnTick method with the necessary changes.

//BEFORE CHANGES
private void TimeManager_OnTick()
{
    if (base.IsOwner)
    {
        BuildActions(out MoveData md);
        Move(md, false);
    }
}

//AFTER CHANGES
private void TimeManager_OnTick()
{
    if (base.IsOwner)
    {
        Reconcile(default, false);
        BuildActions(out MoveData md);
        Move(md, false);
    }
    if (base.IsServer)
    {
        Move(default, true);
        ReconcileData rd = new ReconcileData()
        {
            Position = transform.position
        };
        Reconcile(rd, true);
    }
}

There's a few new lines of code but what is happening can be explained very easily.

On the server, for rigidbodies you will want to run your Replicate in OnTick and send the Reconcile in OnPostTick. This is because the simulation runs after OnTick but before OnPostTick. By sending Reconcile within OnPostTick you are sending the latest values.

Check out the TransformPrediction example for more notes.

You already know that only the owner and server should call the Replicate method Move; this has been covered. However, the owner must also call Reconcile with default as the first argument, and false as the second. Doing so will correct any de-synchronizations that may have occurred before processing new actions. Default is used as the first argument because the values are received from the server and already set using behind the scenes magic. False is the second argument to indicate the method is being called from the client.

There is also a new block within the if (base.IsServer) statement. For the server to also perform the replicate method it must call the method, Move, using default values as the first argument and a true value as the second. Like the client, the server already knows which values to run for the MoveData, this is more automated Fish-Networking logic. And the boolean must be true to indicate the method is running as the server. You perhaps noticed that you never set the replaying value; this is also performed automatically.

After the server has performed its side of replication, you have to build a new ReconcileData. As discussed we're only concerned with the position. Once the ReconcileData is made the server calls Reconcile(theDataMade, true). The server never actually runs any logic inside the Reconcile method; but when it's called with asServer true the passed in data is relayed to the client.

I mentioned before that with Rigidbodies you likely will want to include velocities as well. Imagine if your jump is velocity driven and you only reset the position but not the velocity. The client would correct its position but still have the incorrect upward velocity. As a result the client would aggressively de-synchronize until the jump completed locally for them. The importance of making sure you reset all values which may affect movement is substantial!

Now we need to discuss replaying a little more in-depth. While you do not particularly need to know how it works, having the knowledge still may help you down the road.

After a reconcile is performed previous cached inputs are replayed; this only occurs on the client. Inputs become cached due to latency, but this is also in-part how the client stays synchronized with the server. Let's say the client is moving every tick, and sends input on ticks 10, 11, 12, 13, ect. The server gets the first actions from the clients tick 10, runs the actions, and sends reconcile data. By the time the client receives the reconcile data they have already sent 11, 12, 13, ect. These additionally sent values are cached on the client internally. When the client does receive the reconcile they will make any corrections necessary in the Reconcile method, and then replay any actions which are cached. In this case, those actions being tick 11, 12, 13, ect. This is why client actions will almost definitely perform multiple times on the client. Once for when they are processed locally, and again whenever they are replayed.

If you are interested in knowing what tick the client is reconciling on you can make a custom serializer for your ReconcileData. See Prediction Serializers for more information on this.

While the last few paragraphs are a bit of reading, understanding the underlying mechanics could help you overcome potential problems at a later date.

Good news everyone! This covers the majority of using client-side prediction! Setting up your object still needs to be discussed, and there are a few more examples to talk about, but you've completed the hard part.

Last updated