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++;
}
}
}
}