Purrdicted Character Controller
by Shelby
Introduction
While PurrDiction's main flagship feature is supporting Rigidbody
controllers and interaction, we can still predict with the CharacterController
as well (as well as everything else)!
Before we begin, it's highly recommended to read through the PurrDiction docs to get a better understanding of how the system works.
For further reading, you should also check out a series of articles on Client Side Prediction by Neotime, which provides a far more in-depth look at Client Side Prediction principles.
This guide will assume you have a basic understandin of how PurrDiction works, as well as a basic understanding of PurrNet.
Getting Started
To get started, let's create a new script called PredictedCharacterController
and inherit from PredictedIdentity
. As well as that, let's create the STATE
and INPUT
structs, and implement the Simulate
and UpdateInput
methods.
UpdateInput
is called every frame on the client, and is where we will gather our input to later simulate. Simulate
is called every tick, and is where we will apply our input to the state of our player.
using PurrNet.Prediction;
using UnityEngine;
public struct PredictedCharacterControllerInput : IPredictedData<PredictedCharacterControllerInput>
{
public void Dispose() { }
}
public struct PredictedCharacterControllerState : IPredictedData<PredictedCharacterControllerState>
{
public void Dispose() { }
}
[RequireComponent(typeof(CharacterController))]
public class PredictedCharacterController : PredictedIdentity<PredictedCharacterControllerInput, PredictedCharacterControllerState>
{
protected override void UpdateInput(ref PredictedCharacterControllerInput input) {}
protected override void Simulate(PredictedCharacterControllerInput input, ref PredictedCharacterControllerState state, float delta) { }
}
INPUT
The INPUT
struct is what holds all of the input that we want to be able to check and use inside Simulate
. For now, let's keep things extremely simple and check for Movement, and jumping, Which we can do by adding a Vector3
for movement and a bool
for jumping.
public struct PredictedCharacterControllerInput : IPredictedData<PredictedCharacterControllerInput>
{
public Vector3 Movement;
public bool Jump;
public void Dispose() { }
}
STATE
The STATE
struct is what holds the current state of our player. This can be a lot of things, but since we are just working on simple movement, we can just store our current Velocity. You may wonder why we are storing Velocity instead of Position, and that is because we will be using the PredictedTransform
to handle our position and rotation state.
public struct PredictedCharacterControllerState : IPredictedData<PredictedCharacterControllerState>
{
public Vector3 Velocity;
public void Dispose() { }
}
Gathering Input
Now that we have our INPUT
and STATE
structs, we can begin gathering input to later simulate. To do this, we use the UpdateInput
method, which is called every frame on the client. You can gather input in any way you like, but for simplicity, we will use Unity's old input system.
protected override void UpdateInput(ref PredictedCharacterControllerInput input)
{
input.Movement = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
input.Jump = Input.GetKeyDown(KeyCode.Space);
}
Simulating with our Input
Now that we have gathered our input, we can simulate! To do this, we can override the Simulate
method, which is called every tick. If you are familiar with writing a basic CharacterController
, the rest of this will be very familiar to you!
This code is modified from the Unity documentation on CharacterController.Move.
// Variables for speed, gravity, and jump height
[SerializeField] float speed = 5f;
[SerializeField] float gravity = -10f;
[SerializeField] float jumpHeight = 1f;
protected override void Simulate(PredictedCharacterControllerInput input, ref PredictedCharacterControllerState state, float delta)
{
bool groundedPlayer = controller.isGrounded;
if (groundedPlayer && state.Velocity.y < 0)
{
state.Velocity.y = 0f;
}
// Read input
Vector3 move = new Vector3(input.Movement.x, 0, input.Movement.z);
move = Vector3.ClampMagnitude(move, 1f);
// Jump
if (input.Jump && groundedPlayer)
{
state.Velocity.y = Mathf.Sqrt(jumpHeight * -2.0f * gravity);
}
// Apply gravity
state.Velocity.y += gravity * delta;
// Combine horizontal and vertical movement
Vector3 finalMove = (move * speed) + (state.Velocity.y * Vector3.up);
controller.Move(finalMove * delta);
}
Final Script
Putting it all together, our final script looks like this:
using PurrNet.Prediction;
using UnityEngine;
public struct PredictedCharacterControllerInput : IPredictedData<PredictedCharacterControllerInput>
{
public Vector3 Movement;
public bool Jump;
public void Dispose() { }
}
public struct PredictedCharacterControllerState : IPredictedData<PredictedCharacterControllerState>
{
public Vector3 Velocity;
public void Dispose() { }
}
[RequireComponent(typeof(CharacterController))]
public class PredictedCharacterController : PredictedIdentity<PredictedCharacterControllerInput, PredictedCharacterControllerState>
{
[SerializeField] float speed = 5f;
[SerializeField] float gravity = -10f;
[SerializeField] float jumpHeight = 1f;
CharacterController controller;
protected void Awake()
{
controller = GetComponent<CharacterController>();
}
protected override void UpdateInput(ref PredictedCharacterControllerInput input)
{
input.Movement = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
input.Jump = Input.GetKeyDown(KeyCode.Space);
}
protected override void Simulate(PredictedCharacterControllerInput input, ref PredictedCharacterControllerState state, float delta)
{
bool groundedPlayer = controller.isGrounded;
if (groundedPlayer && state.Velocity.y < 0)
{
state.Velocity.y = 0f;
}
// Read input
Vector3 move = new Vector3(input.Movement.x, 0, input.Movement.z);
move = Vector3.ClampMagnitude(move, 1f);
// Jump
if (input.Jump && groundedPlayer)
{
state.Velocity.y = Mathf.Sqrt(jumpHeight * -2.0f * gravity);
}
// Apply gravity
state.Velocity.y += gravity * delta;
// Combine horizontal and vertical movement
Vector3 finalMove = (move * speed) + (state.Velocity.y * Vector3.up);
controller.Move(finalMove * delta);
}
}
Creating the Player Prefab
Inside of Unity, Create a new Empty
GameObject
and name it Player.Add the
PredictedCharacterController
component we just created, and also add thePredictedTransform
component mentioned earlier to handle the Position and Rotation state.Under the Player, Create a new
Capsule
and name it Graphics. This will be our graphical representation of our player. Make sure to remove theCapsuleCollider
from the Graphics, as having colliders on Graphical objects is not allowed.Finally, assign the Graphics GameObject to the
PredictedTransform
component'sGraphics
field.
Testing
Create a new scene and add a plane for the player to walk on and create a
Camera
in the scene so we can see our player.Create an empty
GameObject
and name it NetworkManager. Add theNetworkManager
component to it.Create an empty
GameObject
, and name it Prediction Manager. Add thePredictionManager
component to it. Click on New under thePredictedPrefabs
field to create a newPredictedPrefabs
asset.Add the
PredictedPlayerSpawner
component to the Prediction Manager and assign the Player prefab to thePlayer Prefab
field.Run the scene and confirm you can jump and move around!
Conclusion
Congratulations! You (hopefully) have a capsule that can run around and jump! Fire up a client and use the PurrTransport and try it out with real world latency!
As long as you always modify your state inside Simulate
, and gather your input inside UpdateInput
(or GetFinalInput
), you'll notice the workflow becomes very similar to writing singleplayer code.
If you have any questions, feel free to ask in the Discord server!
Last updated