Unity Netcode Interpolation Problem in Client Prediction

Hello Unity community, I’m currently working on a multiplayer game, and I’m encountering an issue with interpolation in client prediction.
When I don’t interpolate the client’s movement (simply set the transform position every tick), the client prediction works perfectly. Although whenever I do try to interpolate the movement, the server reconciles way too often as the interpolation can’t get the client to the position it should be at in time, which in turn causes movement to be horribly jittery.

Interpolation is very important for this as the movement updates 30 times per second (tps = 30), which looks bad with high fps.

Thank you in advance for any help or guidance you can provide. I appreciate your time and expertise!

Player movement code (contains client prediction and server reconciliation):

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;

public struct InputPayload : INetworkSerializable
{
    public int tick;
    public Vector2 inputVector;

    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref tick);
        serializer.SerializeValue(ref inputVector);
    }
}

public struct StatePayload : INetworkSerializable
{
    public int tick;
    public Vector2 position;

    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        serializer.SerializeValue(ref tick);
        serializer.SerializeValue(ref position);
    }
}


public class PlayerMovement : NetworkBehaviour
{
    InputActions inputActions;
    EntityStats entityStatsComponent;

    //Shared
    private int currentTick;
    private float minTimeBetweenTicks;
    private float serverTickRate;
    private const int BUFFER_SIZE = 1024;

    //Client specific
    private StatePayload[] clientStateBuffer;
    private InputPayload[] inputBuffer;
    private StatePayload latestServerState;
    private StatePayload lastProcessedState;
    Vector2 movementInput;

    //Server specific
    private StatePayload[] serverStateBuffer;
    private Queue<InputPayload> inputQueue;

    private void Awake()
    {
        serverTickRate = NetworkManager.Singleton.NetworkTickSystem.TickRate;

        entityStatsComponent = GetComponent<EntityStats>();
    }

    void Start()
    {
        minTimeBetweenTicks = 1f / serverTickRate;

        clientStateBuffer = new StatePayload[BUFFER_SIZE];
        inputBuffer = new InputPayload[BUFFER_SIZE];

        serverStateBuffer = new StatePayload[BUFFER_SIZE];
        inputQueue = new Queue<InputPayload>();

        if (IsOwner)
        {
            //update current tick locally for owner and server
            NetworkManager.NetworkTickSystem.Tick += OnTickClient;
            NetworkManager.NetworkTickSystem.Tick += () => currentTick++;
        }
        else if (IsServer)
        {
            NetworkManager.NetworkTickSystem.Tick += OnTickServer;
            NetworkManager.NetworkTickSystem.Tick += () => currentTick++;
        }
    }

    void OnEnable()
    {
        inputActions = new InputActions();
        inputActions.Enable();
    }

    void OnDisable()
    {
        inputActions.Disable();
    }

    void Update()
    {
        if (!IsOwner || !Application.isFocused) return;

        movementInput = inputActions.Player.Move.ReadValue<Vector2>().normalized;
    }

    //Run on server
    void OnClientInput(InputPayload inputPayload)
    {
        inputQueue.Enqueue(inputPayload);
    }

    //Run on client
    void OnServerMovementState(StatePayload serverState)
    {
        latestServerState = serverState;
    }

    [ServerRpc]
    void SendToServerServerRpc(InputPayload inputPayload)
    {
        OnClientInput(inputPayload);
    }

    [ClientRpc]
    void SendToClientClientRpc(StatePayload statePayload)
    {
        OnServerMovementState(statePayload);
    }

    bool ShouldReconcile()
    {
        bool isNewServerState = !latestServerState.Equals(default(StatePayload));
        bool isLastStateUndefinedOrDifferent = lastProcessedState.Equals(default(StatePayload)) ||
            !latestServerState.Equals(lastProcessedState);

        return isNewServerState && isLastStateUndefinedOrDifferent;
    }

    void OnTickClient()
    {
        if (ShouldReconcile())
        {
            HandleServerReconciliation();
        }

        int bufferIndex = currentTick % BUFFER_SIZE;

        //Add payload to inputBuffer
        InputPayload inputPayload = new InputPayload
        {
            tick = currentTick,
            inputVector = movementInput
        };

        inputBuffer[bufferIndex] = inputPayload;

        //Add payload to stateBuffer
        clientStateBuffer[bufferIndex] = ProcessMovement(inputPayload);

        //Send input to server
        SendToServerServerRpc(inputPayload);
    }

    void OnTickServer()
    {
        //Process the input queue
        int bufferIndex = -1;

        while (inputQueue.Count > 0)
        {
            InputPayload inputPayload = inputQueue.Dequeue();

            bufferIndex = inputPayload.tick % BUFFER_SIZE;

            StatePayload statePayload = ProcessMovement(inputPayload);
            serverStateBuffer[bufferIndex] = statePayload;
        }

        if (bufferIndex != -1)
        {
            SendToClientClientRpc(serverStateBuffer[bufferIndex]);
        }
    }

    IEnumerator LerpPosition(Vector2 finalPos)
    {
        Vector2 startVal = transform.position;
        float t = 0f;
        float wa = 0;

        while (t < 1)
        {
            wa += Time.deltaTime;
            t += Time.deltaTime / (minTimeBetweenTicks - 0.01f);
            transform.position = Vector3.Lerp(startVal, finalPos, t);
            yield return null;
        }
    }

    StatePayload ProcessMovement(InputPayload input)
    {
        Vector3 addPos = (entityStatsComponent.MovementSpeed.Value / 2f) * minTimeBetweenTicks * input.inputVector; // /2f in order to scale the movement speed better
        Vector2 finalPos = transform.position + addPos;

        //transform.position = finalPos;

        StartCoroutine(LerpPosition(finalPos));

        return new StatePayload()
        {
            tick = input.tick,
            position = finalPos,
        };
    }

    void HandleServerReconciliation()
    {
        lastProcessedState = latestServerState;

        int serverStateBufferIndex = latestServerState.tick % BUFFER_SIZE;
        float positionError = Vector3.Distance(latestServerState.position, clientStateBuffer[serverStateBufferIndex].position);

        if (positionError > 0.01f)
        {
            Debug.LogWarning($"We have to reconcile (error {positionError})");
            Debug.Log($"current tick: {currentTick}");

            //Rewind & Replay
            transform.position = latestServerState.position;

            //Update buffer at index of latest server state
            clientStateBuffer[serverStateBufferIndex] = latestServerState;

            //Now re-simulate the rest of the ticks up to the current tick on the client
            int tickToProcess = latestServerState.tick + 1;

            while (tickToProcess < currentTick)
            {
                int bufferIndex = tickToProcess % BUFFER_SIZE;

                //Process new movement with reconciled state
                StatePayload statePayload = ProcessMovement(inputBuffer[bufferIndex]);

                //Update buffer with recalculated state
                clientStateBuffer[bufferIndex] = statePayload;

                tickToProcess++;
            }
        }
    }
}

I will need to review over your code a bit more, but after first pass glance I noticed that via OnTickClient invoking ProcessMovement(inputPayload) it appears that you create a coroutine each time?
You might think about just queue’ing the finalPos on the client side, during the normal Update, processing the queue (i.e. make it a FIFO queue) where you set local private Vector2 property (i.e. m_TargetPos) from the first item in the queue until you have lerped to that target position…then grabbing the next…lerping toward that… etc.

The other thing I noticed is that you might think about just using a NetworkVariable that is already tick synchronized.

Was there a specific reason as to why you are using your own tick count as opposed to the one provided by NGO?

The last question is: Do you really want to only send a single inputPayload from the client to the server each tick or do you want to send input continually or perhaps a sum of the input over a tick period? I ask this because it looks like the input is being set per Update and so the client is only sending the very last movementInput set via Update…which depending upon what inputActions.Player.Move.ReadValue() is on the Update prior to the next tick is the only value sent to the server (just asking because I don’t know what is driving that value).

1 Like

Firstly, thank you so much for your response!
Now for the answers:

  1. Yes, I am invoking the coroutine every time in OnTickClient. What I’m trying to do is lerp the positions between the ticks so by the time the next tick begins to execute, the previous coroutine would end and a new one would start.
  2. There is no specific reason as to why I’m using my own tick count actually, I didn’t realize NGO provided it.
  3. I’m not sure what the best approach for handling the input is. Currently I’m just using the input in the update prior to the next tick as you mentioned. I’d love to hear suggestions if there are better ways to do it.

Also, could you explain what you meant by queueing the finalPos on the client during Update? Maybe modify the code to show how it would look like?

Thanks in advance

Also I’m not sure what I did wrong, but the position only updates for the server and not for the other clients (not related to interpolation)