NGO with AnticipationNetworkTransform struggle

Hello,

I recently started to try myself into multiplayer and so in NGO. After managing to get a client-authoritative version running, I wanted to get a server-authoritative instead but with prediction/reconciliation, and so I stumble upon the AnticipatedNetworkTransform, yay! Built-in solution!
I’ve been reading the documentation many times, and it seems like everything was done so we can run the same code wether it’s on server-side or client-side. And I did manage to make it work but if the client-owning is smooth, and host as well (host is never the problem), other clients on client-owning are really choppy; also client can get out of sync with server and won’t correct position. I think the way I reconciliate is total mess.

  • I’m using the latest version of NGO (2.1.1) and Unity 6000.0
  • I’m using the CharacterController as no reel physics will be implied and to manage collisions easily.
  • Working with the new InputSystem which sendMessage to a custom InputResolver
  • Character changes its state with a custom StateMachine (inspired by Unity ChopChop project) which runs on both server and client, and is fed input by a custom InputData struct set in the InputResolver (see the MoveActionSo.cs below)
  • Finally, a ClientCharacter script which is responsible to apply computed velocity and rotation calculated by the stateMachine, and so anticipate the movement and reconciliate

Any help to demystify this AnticipatedNetworkTransform component or insight on how to implement a good server-authority along with a client anticipation is welcome.

Have a nice day and sorry if it’s spaghetti !

ClientCharacter script :

namespace Gameplay.GameplayObjects.Character
{
    public class ClientCharacter : NetworkBehaviour
    {
        [FormerlySerializedAs("k_lerpTime")]
        [Header("Interpolation Settings")]
        [SerializeField] private float kLerpTime = 0.08f;
        
        [SerializeField] private ServerCharacter serverCharacter;
        [SerializeField] private CharacterController characterController;
        [SerializeField] private CinemachineCamera cinemachineCamera;
        [SerializeField] private AnticipatedNetworkTransform anticipatedNT;
        
        [Tooltip("Physics used by the state machine")]
        public float gravity = -15.0f;

        public float inputX;
        public float inputY;
        public float velocityX;
        public float velocityY;
        public float velocityZ;
        public Quaternion calculatedRotation;
        private AnticipatedNetworkTransform.TransformState _previousAnticipatedNetworkTransform;

        private void Awake()
        {
            enabled = false;
        }

        public override void OnNetworkSpawn()
        {
            enabled = true;
            anticipatedNT.StaleDataHandling = StaleDataHandling.Ignore;
            if (!IsOwner) return;
            cinemachineCamera.Priority = 20;
        }

        public override void OnNetworkDespawn()
        {
            enabled = false;
        }


// Is being called in the OnUpdate() of my stateMachine action "ApplyMovement"
        public void UpdateTransform()
        {
            characterController.Move(new Vector3(velocityX, velocityY, velocityZ) * Time.deltaTime);
            
            AnticipatedNetworkTransform.TransformState transformState = new()
            {
                Position = transform.position,
                Rotation = transform.rotation,
                Scale = new Vector3(1, 1, 1)
            };
            if (IsServer)
            {
                UpdateAllClientRpc();
            }

            if (IsOwner && IsClient)
            {
                _previousAnticipatedNetworkTransform = transformState;
                anticipatedNT.AnticipateState(transformState);
            }
        }

        [ClientRpc]
        private void UpdateAllClientRpc()
        {
            transform.position = Vector3.Lerp(transform.position, anticipatedNT.AuthoritativeState.Position, Time.deltaTime * kLerpTime);
        }

        public override void OnReanticipate(double lastRoundTripTime)
        {
            anticipatedNT.Smooth(anticipatedNT.PreviousAnticipatedState, anticipatedNT.AuthoritativeState, .01f);
        }
    }
}

MoveActionSO :

namespace Gameplay.Actions
{
    [CreateAssetMenu(fileName = "MoveAction", menuName = "State Machines/Actions/Move Action")]
    public class MoveActionSO : StateActionSO
    {
        protected override StateAction CreateAction() => new MoveAction();
        public float sprintSpeed = 10f;
        public float moveSpeed = 5f;
        public float speedChangeRate = 10.0f;
    }
    
    public class MoveAction : StateAction
    {
        protected new MoveActionSO OriginSO => (MoveActionSO)base.OriginSO;
        private InputResolver _inputResolver;
        private ClientCharacter _clientCharacter;
        private Transform _cameraTransform;
        public override void Awake(StateMachine stateMachine)
        {
            _inputResolver = stateMachine.GetComponent<InputResolver>();
            _clientCharacter = stateMachine.GetComponent<ClientCharacter>();
            _cameraTransform = UnityEngine.Camera.main?.transform;
        }
        
        public override void OnUpdate()
        {
            InputData input = _inputResolver.ProcessedInputData;
            float targetSpeed = input.Sprint ? OriginSO.sprintSpeed : OriginSO.moveSpeed;

            if (input.Move.sqrMagnitude > 0.01f)
            {
                Vector3 forward = _clientCharacter.transform.forward;
                Vector3 right = _clientCharacter.transform.right;

                forward.Normalize();
                right.Normalize();

                Vector3 direction = (forward * input.Move.y + right * input.Move.x).normalized;
                _clientCharacter.velocityX = direction.x * targetSpeed;
                _clientCharacter.velocityZ = direction.z * targetSpeed;
            }
            else
            {
                _clientCharacter.velocityX = Mathf.Lerp(_clientCharacter.velocityX, 0, Time.deltaTime * 10f);
                _clientCharacter.velocityZ = Mathf.Lerp(_clientCharacter.velocityZ, 0, Time.deltaTime * 10f);
            }
            _clientCharacter.inputX = _inputResolver.ProcessedInputData.Move.x;
            _clientCharacter.inputY = _inputResolver.ProcessedInputData.Move.y;
            _clientCharacter.animationBlend = Mathf.Lerp(_clientCharacter.animationBlend, targetSpeed, Time.deltaTime * OriginSO.speedChangeRate);
        }
    }
}

InputResolver script :

namespace Gameplay.UserInput
{
    public class InputResolver : NetworkBehaviour
    {
        public InputData ProcessedInputData;
        [SerializeField] private PlayerInput playerInput;

        public bool isCurrentDeviceMouse;
        
        [Header("Internal Data")]
        private InputData _localInputData;
        private InputData _lastInputDataSent;
        private InputData _lastInputDataServer;
        
        private float _lastSendTime;
        private const float SendRate = 0.01f; 
        
        [Header("Mouse Cursor Settings")]
        public bool cursorLocked = true;
        public bool cursorInputForLook = true;
        
        public void OnMove(InputValue value) => ProcessedInputData.Move = value.Get<Vector2>();
        public void OnJump(InputValue value) => ProcessedInputData.Jump = value.isPressed;
        public void OnSprint(InputValue value) => ProcessedInputData.Sprint = value.isPressed;
        public void OnLook(InputValue value)
        {
            if(cursorInputForLook)
                ProcessedInputData.Look = value.Get<Vector2>();
        }

        public override void OnNetworkSpawn()
        {
            if (IsClient && IsOwner)
            {
                isCurrentDeviceMouse = playerInput.currentControlScheme == "KeyboardMouse";
            }
            else
            {
                playerInput.enabled = false;
                enabled = false;
            }
        }

        private void Update()
        {
            if (!(Time.time - _lastSendTime > SendRate) || ProcessedInputData.Equals(_lastInputDataSent)) return;
            
            _lastSendTime = Time.time;
            _lastInputDataSent = ProcessedInputData;
            UpdateInputServerRpc(ProcessedInputData);
        }

        [ServerRpc]
        private void UpdateInputServerRpc(InputData inputData)
        {
            ProcessedInputData = inputData;
            _lastInputDataServer = inputData;
        }
        
        private void OnApplicationFocus(bool hasFocus)
        {
            SetCursorState(cursorLocked);
        }

        private void SetCursorState(bool newState)
        {
            Cursor.lockState = newState ? CursorLockMode.Locked : CursorLockMode.None;
        }
    }
}

InputData structure :

namespace Gameplay.UserInput
{
    public struct InputData : INetworkSerializable, IEquatable<InputData>
    {
        public Vector2 Move;
        public Vector2 Look;
        public bool Jump;
        public bool Sprint;

        public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
            serializer.SerializeValue(ref Move);
            serializer.SerializeValue(ref Look);
            serializer.SerializeValue(ref Jump);
            serializer.SerializeValue(ref Sprint);
        }

        public override int GetHashCode()
        {
            return HashCode.Combine(Move, Look, Jump, Sprint);
        }

        public bool Equals(InputData other)
        {
            return Move.Equals(other.Move) && Look.Equals(other.Look) && Jump == other.Jump && Sprint == other.Sprint;
        }

        public void ConsumeInput(InputType inputType, bool value = false)
        {
            switch (inputType)
            {
                case InputType.Jump:
                    Jump = value;
                    break;
                case InputType.Sprint:
                    Sprint = value;
                    break;
                default:
                    throw new InvalidOperationException($"InputType {inputType} is not a boolean property or cannot be set.");
            }
        }

        #region Match InputType with InputData variables

        private static readonly Dictionary<InputType, Func<InputData, object>> InputAccessors =
            new Dictionary<InputType, Func<InputData, object>>
            {
                { InputType.Move, input => input.Move },
                { InputType.Look, input => input.Look },
                { InputType.Jump, input => input.Jump },
                { InputType.Sprint, input => input.Sprint }
            };

        public object GetInputValue(InputType inputType)
        {
            if (InputAccessors.TryGetValue(inputType, out var accessor))
            {
                return accessor(this);
            }

            throw new ArgumentException($"InputType {inputType} is not mapped in InputData.");
        }

        #endregion
    }
        
    public enum InputType
    {
        Move,
        Look,
        Jump,
        Sprint
    }
}