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!