Netcode for gameobjects: Prediction and Reconciliation

Hey,

I’ve been trying to create a system to predict movement for a client and then correct it, but honestly, I’m totally lost.

I read an article from Unity themselves about Client Anticipation (Client anticipation | Unity Multiplayer), but I don’t think it’s the solution for prediction and reconciliation. The basic idea is that the client sends its inputs to the server, while the client just does a prediction. The server also does the movement for the client, compares, and then corrects. In theory, it’s simple, but in practice, I’m not sure. I have also looked at other solution but they just confuse me even more.

I have code using Client Anticipation that works. The client can update its position and movement, but the client can easily change the movement speed and therefore cheat. However, I’ve managed to prevent the client from manipulating position coordinates.

Here is the code:

using Code.Scripts.Input;
using Unity.Cinemachine;
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEngine;

namespace Code.Scripts.Network
{
    public class FirstPersonNetworkController : NetworkBehaviour
    {
        [Header("Network")]
        public float smoothTime = 0.1f;
        public float smoothDistance = 3f;
        
        [Header("Player")]
        [Tooltip("Disable the player input and control")]
        public bool disableInput;
        [Tooltip("Move speed of the character in m/s")]
        public float moveSpeed = 4.0f; 
        [Tooltip("If the character is allowed to sprint")]
        public bool allowSprinting = true; 
        [Tooltip("Sprint speed of the character in m/s")]
        public float sprintSpeed = 6.0f; 
        [Tooltip("Rotation speed of the character")]
        public float rotationSpeed = 1.0f;
	    [Tooltip("Acceleration and deceleration")]
        public float speedChangeRate = 10.0f;
        
        [Space(10)]
        [Tooltip("If the character is allowed to jump")]
        public bool allowJumping = true; 
        [Tooltip("The height the player can jump")]
        public float jumpHeight = 1.2f; 
        [Tooltip("The character uses its own gravity value. The engine default is -9.81f")]
        public float gravity = -15.0f;
        
        [Space(10)]
        [Tooltip("Time required to pass before being able to jump again. Set to 0f to instantly jump again")]
        public float jumpTimeout = 0.1f; 
        [Tooltip("Time required to pass before entering the fall state. Useful for walking down stairs")]
        public float fallTimeout = 0.15f;
        
        [Header("Player Grounded")]
        [Tooltip("If the character is grounded or not. Not part of the CharacterController built in grounded check")]
        public bool grounded = true; 
        [Tooltip("Useful for rough ground")]
        public float groundedOffset = -0.14f; 
        [Tooltip("The radius of the grounded check. Should match the radius of the CharacterController")]
        public float groundedRadius = 0.5f; 
        [Tooltip("What layers the character uses as ground")]
        public LayerMask groundLayers;
        
        [Header("Cinemachine")]
        [Tooltip("The follow target set in the Cinemachine Virtual Camera that the camera will follow")]
        public GameObject cinemachineCameraTarget; 
        [Tooltip("How far in degrees can you move the camera up")]
        public float topClamp = 90.0f; 
        [Tooltip("How far in degrees can you move the camera down")]
        public float bottomClamp = -90.0f;
        
        // cinemachine
        private float _cinemachineTargetPitch;

        // player
        private float _speed;
        private float _rotationVelocity;
        private float _verticalVelocity;
        private const float TerminalVelocity = 53.0f;

        // timeout delta time
        private float _jumpTimeoutDelta;
        private float _fallTimeoutDelta;
        
        private const float Threshold = 0.01f;
        
        private InputSystemInputs _input;
        private CharacterController _controller;
        private CinemachineCamera[] _cinemachineCameras;
        private AnticipatedNetworkTransform _anticipatedNetworkTransform;

        private void Awake()
        {
            _input = GetComponent<InputSystemInputs>();
            _controller = GetComponent<CharacterController>();
            _cinemachineCameras = GetComponentsInChildren<CinemachineCamera>();
            _anticipatedNetworkTransform = GetComponent<AnticipatedNetworkTransform>();

            _input.enabled = false;
            _controller.enabled = false;
            foreach (var cinemachineCamera in _cinemachineCameras)
            {
                cinemachineCamera.enabled = false;
            }
        }

        public override void OnNetworkSpawn()
        {
            base.OnNetworkSpawn();
            
            if(!IsOwner) return;
            
            _input.enabled = true;
            _controller.enabled = true;
            foreach (var cinemachineCamera in _cinemachineCameras)
            {
                cinemachineCamera.enabled = true;
            }
        }

        private void FixedUpdate()
        {
            if (!NetworkManager.IsConnectedClient) return;
            if (!IsLocalPlayer) return;

            // Handle movement and physics updates
            JumpAndGravity();
            GroundedCheck();
            Move();
            CameraRotation();

            // Synchronize the position with the server
            ServerMoveRpc();
        }
        
        [Rpc(SendTo.Server)]
        private void ServerMoveRpc()
        {
            var currentPosition = _anticipatedNetworkTransform.AnticipatedState;

            // Synchronize the movement with the server
            _anticipatedNetworkTransform.Smooth(currentPosition, _anticipatedNetworkTransform.AuthoritativeState, smoothTime);
            _anticipatedNetworkTransform.AnticipateMove(transform.position);
        }

        public override void OnReanticipate(double lastRoundTripTime)
        {
            // Have to store the transform's previous state because calls to AnticipateMove() and
            // AnticipateRotate() will overwrite it.
            var previousState = _anticipatedNetworkTransform.PreviousAnticipatedState;

            if (smoothTime == 0.0) return;
            
            var sqDist = Vector3.SqrMagnitude(previousState.Position - _anticipatedNetworkTransform.AnticipatedState.Position);
            if (sqDist <= 0.25 * 0.25)
            {
                // This prevents small amounts of wobble from slight differences.
                _anticipatedNetworkTransform.AnticipateState(previousState);
            }
            else if (sqDist < smoothDistance * smoothDistance)
            {
                // Server updates are not necessarily smooth, so applying reanticipation can also result in
                // hitchy, unsmooth animations. To compensate for that, we call this to smooth from the previous
                // anticipated state (stored in "anticipatedValue") to the new state (which, because we have used
                // the "Move" method that updates the anticipated state of the transform, is now the current
                // transform anticipated state)
                _anticipatedNetworkTransform.Smooth(previousState, _anticipatedNetworkTransform.AnticipatedState, smoothTime);
            }
        }
        
        private void GroundedCheck()
        {
            // set sphere position, with offset
            var spherePosition = new Vector3(transform.position.x, transform.position.y - groundedOffset, transform.position.z);
            grounded = Physics.CheckSphere(spherePosition, groundedRadius, groundLayers, QueryTriggerInteraction.Ignore);
        }

        private void CameraRotation()
        {
            if (!(_input.look.sqrMagnitude >= Threshold)) return;
			
            _cinemachineTargetPitch += -_input.look.y * rotationSpeed;
            _rotationVelocity = _input.look.x * rotationSpeed;

            // clamp our pitch rotation
            _cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, bottomClamp, topClamp);

            // Update Cinemachine camera target pitch
            cinemachineCameraTarget.transform.localRotation = Quaternion.Euler(_cinemachineTargetPitch, 0.0f, 0.0f);

            // rotate the player left and right
            transform.Rotate(Vector3.up * _rotationVelocity);
            _anticipatedNetworkTransform.AnticipateRotate(transform.rotation);
        }
        
        private void Move()
        {
            // set target speed based on move speed, sprint speed and if sprint is pressed
            var targetSpeed = _input.sprint && allowSprinting ? sprintSpeed : moveSpeed;

            // a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon

            // note: Vector2's == operator uses approximation so is not floating point error-prone, and is cheaper than magnitude
            // if there is no input, set the target speed to 0
            if (_input.move == Vector2.zero) targetSpeed = 0.0f;

            // a reference to the players current horizontal velocity
            var currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;

            const float speedOffset = 0.1f;

            // accelerate or decelerate to target speed
            if (currentHorizontalSpeed < targetSpeed - speedOffset || currentHorizontalSpeed > targetSpeed + speedOffset)
            {
                // creates curved result rather than a linear one giving a more organic speed change
                // note T in Lerp is clamped, so we don't need to clamp our speed
                _speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * _input.move.magnitude, Time.deltaTime * speedChangeRate);

                // round speed to 3 decimal places
                _speed = Mathf.Round(_speed * 1000f) / 1000f;
            }
            else
            {
                _speed = targetSpeed;
            }

            // normalise input direction
            var inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;

            // note: Vector2's != operator uses approximation so is not floating point error-prone, and is cheaper than magnitude
            // if there is a move input rotate player when the player is moving
            if (_input.move != Vector2.zero)
            {
                // move
                inputDirection = transform.right * _input.move.x + transform.forward * _input.move.y;
            }

            // move the player
            _controller.Move(inputDirection.normalized * (_speed * Time.deltaTime) + new Vector3(0.0f, _verticalVelocity, 0.0f) * Time.deltaTime);
            _anticipatedNetworkTransform.AnticipateMove(transform.position);
        }
        
        private void JumpAndGravity()
        {
            if (grounded)
            {
                // reset the fall timeout timer
                _fallTimeoutDelta = fallTimeout;

                // stop our velocity dropping infinitely when grounded
                if (_verticalVelocity < 0.0f)
                {
                    _verticalVelocity = -2f;
                }

                // Jump
                if (_input.jump && _jumpTimeoutDelta <= 0.0f && allowJumping)
                {
                    // the square root of H * -2 * G = how much velocity needed to reach desired height
                    _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
                }

                // jump timeout
                if (_jumpTimeoutDelta >= 0.0f)
                {
                    _jumpTimeoutDelta -= Time.deltaTime;
                }
            }
            else
            {
                // reset the jump timeout timer
                _jumpTimeoutDelta = jumpTimeout;

                // fall timeout
                if (_fallTimeoutDelta >= 0.0f)
                {
                    _fallTimeoutDelta -= Time.deltaTime;
                }

                // if we are not grounded, do not jump
                _input.jump = false;

                // Check for ceiling collision
                if (_controller.collisionFlags == CollisionFlags.Above)
                {
                    _verticalVelocity = -2f;
                }
            }

            // apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
            if (_verticalVelocity < TerminalVelocity)
            {
                _verticalVelocity += gravity * Time.deltaTime;
            }
        }
		
        private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
        {
            if (lfAngle < -360f) lfAngle += 360f;
            if (lfAngle > 360f) lfAngle -= 360f;
            return Mathf.Clamp(lfAngle, lfMin, lfMax);
        }

        private void OnDrawGizmosSelected()
        {
            var transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
            var transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);

            Gizmos.color = grounded ? transparentGreen : transparentRed;

            // when selected, draw a gizmo in the position of, and matching radius of, the grounded collider
            Gizmos.DrawSphere(new Vector3(transform.position.x, transform.position.y - groundedOffset, transform.position.z), groundedRadius);
        }
    }
}

So, I’m asking for a solution on how I can implement prediction and reconciliation without the clients being able to modify values like jump height, etc.

Thanks!

What version of NGO are you using currently?

I am using NGO 2.1.1 (latest).

You should not worry about the client cheating. The idea is the authority will tell the client that it must correct. If the client refuses, then it desyncs. It disadvantages itself. The important thing is the authority invalidates the client”s data and no other client is aware of the invalid data.

I don’t know how NGO works in your examples but this is how I would approach it using any netcode solution.

However, reading through the article you mentioned, I don’t know if unity’s anticipation architecture is sufficient for your needs to address cheating. The article literally says it does not implement reconciliation. I think they should have called it client smoothing instead, because that appears to be all it really accomplishes.

Another concern is if you want to implement full reconciliation, can you? The article literally says NGO does not support reconciliation. I don’t know if they mean it doesn’t offer it out of box or if it isn’t possible to support it at all. Perhaps someone at Unity can clarify the intent of that statement.

I want to create a competitive game and by my understanding client anticipation the article I linked will not suffice. Client network transform is no solution due to cheating. RPCs are then the only real solution however there is not much info about that topic, creating a server-side prediction and reconciliation.

So let’s step back a bit and define a couple of terms. Client prediction is simply the client reacting to player input before the authority tells it to react to the input. By not waiting for the authority’s response, the player feels his input is responsive. The authority must still adjudicate the input, but the client is free to do what it wants immediately. Can the client cheat? Yes. That is NOT the problem you want to solve.

The client should send both the input and the predicted response to the authority. The authority then must adjudicate the response to the input - that is, it must decide if the response is valid. If not, it must determine the proper response (which becomes the authoritative response) and send that to everyone. This is called server reconciliation.

Then the authority’s adjudicated response to the input must be implemented by the client. Can the client ignore the response (and thus cheat)? Yes. Again, that is not the problem you want to solve. If it fails to implement the reconciled response from the authority, it desyncs and runs off into the woods by itself to its own disadvantage.

All other clients never see the invalid responses, they only see the authoritative responses.

Those are the concepts. The implementation involves the complexities of having a rollback buffer by which you can go back to a known valid state, apply the input at the proper time, progress to the current state with a valid response, etc.

Finally, there is an article, i don’t have the link available at the moment, that talks about prediction and reconciliation and why it isn’t adopted by all though it is a standard solution. I myself do not plan to implement it, because it opens a door for cheating. But I am still exploring the alternatives and they have short comings also.

I edited the above for more clarity. If you understand the concept, you should be able to implement the solution yourself. The article you linked in the OP was clear NGO does not implement any of this. The thing I would suggest is think of how you want to implement this solution yourself and do not rely on NGO behavior to help you. Use it strictly as a replication framework and nothing else.

BTW, the problem you are trying to solve is maintain an authoritative state that validates future notifications from the clients. If the cheating client says it moved, but the authority said the movement was impossibly too far, then the authoritative state would hold a more valid final position. If the client then shot another client’s player and the authoritative state says that was impossible given the authoritative position, the result is no shots hit. This authoritative state is all you need to maintain. Producing this state is the problem you want to solve.

Thank you, never look at my problem that way.