Improve Annoying Jump Controls With Coyote Time and Jump Buffering - Unit Game development Tutorial

In this Unity game development tutorial we're going to look at how we can improve our jump controls using 'Coyote Time' and 'Jump Buffering'.

You can either watch the video version below or continue reading for written instructions.

In one of our previous tutorials, we showed how to add a basic jump to a character. The character is only allowed to jump when they are on the ground. This makes sense logically, but in practice this can make your game really annoying to play.

One problem is that the player may want to jump on landing and they press the button a fraction of a second too early. Or they may press the button a fraction of a second too late when trying to jump off a platform.

In these scenarios, it just feels like the controls are broken and it can be really frustrating. In this tutorial, we'll look at how to fix this with a couple of simple changes.

OK, so let's look at what’s currently happening in our script.

using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    public float speed;
    public float rotationSpeed;
    public float jumpSpeed;

    private CharacterController characterController;
    private float ySpeed;
    private float originalStepOffset;

    // Start is called before the first frame update
    void Start()
    {
        characterController = GetComponent<CharacterController>();
        originalStepOffset = characterController.stepOffset;
    }

    // Update is called once per frame
    void Update()
    {
        float horizontalInput = Input.GetAxis("Horizontal");
        float verticalInput = Input.GetAxis("Vertical");

        Vector3 movementDirection = new Vector3(horizontalInput, 0, verticalInput);
        float magnitude = Mathf.Clamp01(movementDirection.magnitude) * speed;
        movementDirection.Normalize();

        ySpeed += Physics.gravity.y * Time.deltaTime;

        if (characterController.isGrounded)
        {
            characterController.stepOffset = originalStepOffset;
            ySpeed = -0.5f;

            if (Input.GetButtonDown("Jump"))
            {
                ySpeed = jumpSpeed;
            }
        }
        else
        {
            characterController.stepOffset = 0;
        }

        Vector3 velocity = movementDirection * magnitude;
        velocity.y = ySpeed;

        characterController.Move(velocity * Time.deltaTime);

        if (movementDirection != Vector3.zero)
        {
            Quaternion toRotation = Quaternion.LookRotation(movementDirection, Vector3.up);

            transform.rotation = Quaternion.RotateTowards(transform.rotation, toRotation, rotationSpeed * Time.deltaTime);
        }
    }
}

At the moment, we're checking if the character is on the ground. Then we check if the jump button is pressed before setting the speed of the jump. 

We're going to make this a bit more forgiving by adding a grace period. So instead of checking if the player is on the ground this frame, we'll check if they were on the ground recently, and instead of checking if the jump button has been pressed this frame, we'll check if it has been pressed recently.

This way, if the player presses the button slightly too early or slightly too late, it will still count and make the character jump.

We'll start by adding a public field to hold the grace period. This will be the amount of seconds' leeway we'll give our player.

    ...

    public float speed;
    public float rotationSpeed;
    public float jumpSpeed;
    public float jumpButtonGracePeriod;

    ...

Then we need to store the time that the character was last on the ground, so we'll add a field for this.

    ...

    public float speed;
    public float rotationSpeed;
    public float jumpSpeed;
    public float jumpButtonGracePeriod;

    private CharacterController characterController;
    private float ySpeed;
    private float originalStepOffset;
    private float? lastGroundedTime;

    ...

The question mark after the float type indicates that this field is nullable, meaning it will either contain a float number value or it will have no value at all. We need it to be nullable so that it can be initially set to no value, and so that we can clear the value when our character jumps.

We'll add another field for the time that the jump button was last pressed, again using the nullable float type.

    ...

    private CharacterController characterController;
    private float ySpeed;
    private float originalStepOffset;
    private float? lastGroundedTime;
    private float? jumpButtonPressedTime;

    ...

Next, we'll edit our Update method to make use of these values.

    void Update()
    {
        float horizontalInput = Input.GetAxis("Horizontal");
        float verticalInput = Input.GetAxis("Vertical");

        Vector3 movementDirection = new Vector3(horizontalInput, 0, verticalInput);
        float magnitude = Mathf.Clamp01(movementDirection.magnitude) * speed;
        movementDirection.Normalize();

        ySpeed += Physics.gravity.y * Time.deltaTime;

        if (characterController.isGrounded)
        {
            lastGroundedTime = Time.time;
        }

        if (Input.GetButtonDown("Jump"))
        {
            jumpButtonPressedTime = Time.time;
        }

        ...
    }

First, we’re checking if the character is on the ground. If it is, we set the grounded time field to Time.time. This is the number of seconds since the game started.

Then we're doing the same for the button press. We check if the jump button has been pressed. If it has, we set the jump button pressed time.

Now we can replace our original checks with checks against these values. 

        ...

        if (characterController.isGrounded)
        {
            lastGroundedTime = Time.time;
        }

        if (Input.GetButtonDown("Jump"))
        {
            jumpButtonPressedTime = Time.time;
        }

        if (Time.time - lastGroundedTime <= jumpButtonGracePeriod)
        {
            characterController.stepOffset = originalStepOffset;
            ySpeed = -0.5f;

            if (Time.time - jumpButtonPressedTime <= jumpButtonGracePeriod)
            {
                ySpeed = jumpSpeed;                
            }
        }
        
        ...

So instead of checking if the character is on the ground, we’re checking if it was on the ground within the grace period. This technique is often referred to as 'Coyote Time'

We're then doing the same for the button press check. We're getting the amount of time that has passed since the button has been pressed. Then we're checking if it is less or equal to the grace period. This technique is often referred to as 'Jump Buffering'

Finally, we'll reset our nullable fields when the character jumps. We need to do this so that it doesn't jump repeatedly while in the grace period.

        ...

        if (Time.time - lastGroundedTime <= jumpButtonGracePeriod)
        {
            characterController.stepOffset = originalStepOffset;
            ySpeed = -0.5f;

            if (Time.time - jumpButtonPressedTime <= jumpButtonGracePeriod)
            {
                ySpeed = jumpSpeed;
                jumpButtonPressedTime = null;
                lastGroundedTime = null;
            }
        }
        
        ...

The final script is as follows

using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    public float speed;
    public float rotationSpeed;
    public float jumpSpeed;
    public float jumpButtonGracePeriod;

    private CharacterController characterController;
    private float ySpeed;
    private float originalStepOffset;
    private float? lastGroundedTime;
    private float? jumpButtonPressedTime;

    void Start()
    {
        characterController = GetComponent<CharacterController>();
        originalStepOffset = characterController.stepOffset;
    }

    void Update()
    {
        float horizontalInput = Input.GetAxis("Horizontal");
        float verticalInput = Input.GetAxis("Vertical");

        Vector3 movementDirection = new Vector3(horizontalInput, 0, verticalInput);
        float magnitude = Mathf.Clamp01(movementDirection.magnitude) * speed;
        movementDirection.Normalize();

        ySpeed += Physics.gravity.y * Time.deltaTime;

        if (characterController.isGrounded)
        {
            lastGroundedTime = Time.time;
        }

        if (Input.GetButtonDown("Jump"))
        {
            jumpButtonPressedTime = Time.time;
        }

        if (Time.time - lastGroundedTime <= jumpButtonGracePeriod)
        {
            characterController.stepOffset = originalStepOffset;
            ySpeed = -0.5f;

            if (Time.time - jumpButtonPressedTime <= jumpButtonGracePeriod)
            {
                ySpeed = jumpSpeed;
                jumpButtonPressedTime = null;
                lastGroundedTime = null;
            }
        }
        else
        {
            characterController.stepOffset = 0;
        }

        Vector3 velocity = movementDirection * magnitude;
        velocity.y = ySpeed;

        characterController.Move(velocity * Time.deltaTime);

        if (movementDirection != Vector3.zero)
        {
            Quaternion toRotation = Quaternion.LookRotation(movementDirection, Vector3.up);

            transform.rotation = Quaternion.RotateTowards(transform.rotation, toRotation, rotationSpeed * Time.deltaTime);
        }
    }
}

Back in Unity, we'll select our character in the hierarchy and set the grace period to 0.2 seconds.

Now we can repeatedly jump consistently and we're much less likely to accidentally press too early. 

Also, we're much more likely to succeed when trying to jump off a platform.

That covers everything for this tutorial. We hope that you found it useful. Please leave any questions or feedback in the comments below, and don't forget to subscribe to get notified when we publish our next post.

Thanks.

Comments

Popular posts from this blog

Rotating a Character in the Direction of Movement - Unity Game Development Tutorial

Changing the Colour of a Material - Unity Game Development Tutorial

Creating Terrain from Heightmaps - Unity Game Development Tutorial