TileVania, Coyote Time, can't get rid of Double Jump

Hi,
I wanted to upgrade jumps a little bit by adding the coyote time that let me jump for short period of time after touching the ground. Unfortunately I don’t know why there is still double jump available even though I made some bool to check if the player has jumped. Maybe you know what is wrong here:

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerMovement : MonoBehaviour
{
    Rigidbody2D myRigidbody;
    Vector2 moveInput;
    Animator animator;
    CapsuleCollider2D myCollider;
    [SerializeField] float moveSpeed = 5f;
    [SerializeField] float jumpHeight = 15f;
    [SerializeField] float climbingSpeed = 5f;
    [SerializeField] float coyoteTime = 0.2f;
    bool isGrounded;
    bool hasJumped;
    float coyoteTimeCounter;
    float myRigidbodyGravityAtStart;
    void Start()
    {
        myCollider = GetComponent<CapsuleCollider2D>();
        myRigidbody = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();
        myRigidbodyGravityAtStart = myRigidbody.gravityScale;
    }

    
    void Update()
    {
        CheckForGround();
        Run();
        FlipSprite();
        ClimbLadder();
    }
    void OnMove(InputValue value)
    { 
        moveInput = value.Get<Vector2>();
        Debug.Log(moveInput);
    }
    void OnJump(InputValue value)
    {
        
            if (value.isPressed)
            {

                if (isGrounded)
                {
                    Jump();
                    hasJumped = true;                    
                }


                else if (coyoteTimeCounter > 0 && !hasJumped)
                {                    
                    Jump();
                    hasJumped = true;
                }
                
            }
     }   
        
        
    void Jump()
    {
        myRigidbody.velocity += new Vector2(0f, jumpHeight);
    }
    void CheckForGround()
    {
        isGrounded = myCollider.IsTouchingLayers(LayerMask.GetMask("Ground"));
        if (isGrounded)
        {
            coyoteTimeCounter = coyoteTime;
            hasJumped = false;
        }
        else
        {
            coyoteTimeCounter -= Time.deltaTime;
        }
    }
    void Run()
    {
        Vector2 playerVelocity = new Vector2(moveInput.x * moveSpeed, myRigidbody.velocity.y);
        myRigidbody.velocity = playerVelocity;
        
    }
    void FlipSprite()
    {
        bool playerHasHorizontalSpeed = Mathf.Abs(myRigidbody.velocity.x) > Mathf.Epsilon;
        if (playerHasHorizontalSpeed)
        {
            transform.localScale = new Vector2(Mathf.Sign(myRigidbody.velocity.x), 1f);
            animator.SetBool("isRunning", true);
        }
        else
        {
            animator.SetBool("isRunning", false);
        }
    }
    void ClimbLadder()
    {
        if (!myCollider.IsTouchingLayers(LayerMask.GetMask("Climbing")))
        {
            myRigidbody.gravityScale = myRigidbodyGravityAtStart;
            animator.SetBool("isClimbing", false);
            return; 
        }
            
        Vector2 climbingVelocity = new Vector2(moveInput.x * moveSpeed, moveInput.y * climbingSpeed);
        myRigidbody.velocity = climbingVelocity;
        myRigidbody.gravityScale = 0f;
        bool playerHasVerticalSpeed = Mathf.Abs(myRigidbody.velocity.y) > Mathf.Epsilon;
        animator.SetBool("isClimbing", playerHasVerticalSpeed);
        
        
    }
  
}

1 Like

I can’t say what the exact reason is, but it’s likely because you are mixing physics updates with normal updates. Physics updates only happen 60 times per second (by default) but normal updates can happen up to 300 times or more. This means that the grounded check can happen several times before the physics is actually updated. In this case, you could have pressed jump, setting the rigidbody’s velocity and the hasJumped flag, but then the grounded check happened again before the rigidbody even processed (because it’s a physics operation), resetting the hasJumped to false because it finds that you are still on the ground.
It’s also difficult to say what the fix would be. I suspect you’d want to do the CheckForGround() in the physics loop, so perhaps move it to FixedUpdate() and also change Time.deltaTime to Time.fixedDeltaTime (which is just 1 / 60 or 0.016666....7).
I can’t test any of this so there’s no way of really knowing how to fix it, or even what the exact cause is

2 Likes

Thank you for your answer and explaining the differences in updates. I tried putting CheckForGround() in the fixed update but still got the problem. The double jump occurs when I start jumping from the “ground”
if jump from air using coyote time then I cannot perform double jump. So I think it still makes hasJumped flag to false after I jumped even though its moved to FixedUpdate.

1 Like

I’m not seeing any issues but the one @bixarrio described, it probably has something to do with the Input System running before any of the Updates, causing your CheckForGround to set your hasJumped variable to false allowing the double jump, you’ll have to test if that’s is the case.

To test it just comment or delete the following line of code:

    void CheckForGround()
    {
        isGrounded = myCollider.IsTouchingLayers(LayerMask.GetMask("Ground"));
        if (isGrounded)
        {
            coyoteTimeCounter = coyoteTime;
            hasJumped = false; // <---- This Line
        }
    }

After commenting that line, try to replicate the bug, if you can double jump, then that code isn’t the issue, it is something else, if you can’t, then you’ll have to restructure your logic a little bit because it is an Update issue.

I think the best way to solve this is to play with the Coyote Timer, I’m not 100% sure if this will work because I don’t know how you setup your input, but it just might do the trick.

void OnJump(InputValue value)
{
    if (value.isPressed)
    {
        if (isGrounded || coyoteTimeCounter > 0)
        {
            Jump();
            coyoteTimeCounter = 0;         
        }
    }
}   

void CheckForGround()
{
    isGrounded = myCollider.IsTouchingLayers(LayerMask.GetMask("Ground"));
    if (isGrounded && coyoteTimeCounter < 0)
    {
        coyoteTimeCounter = coyoteTime;
    }
    else
    {
        coyoteTimeCounter -= Time.deltaTime;
    }
}

I removed your hasJumped variable so the coyoteTimeCounter is the responsible of all the jumping logic.

What the if statement below is doing, is giving you a one frame window to prevent the Update issue.

    if (isGrounded && coyoteTimeCounter < 0)

Hope this helps.

I copied your code into a small test and it’s definitely still finding itself on the ground after the first jump which resets hasJumped back to false. Here’s a few snippets of my console
image
Here you can see after the jump there’s another ‘grounded’ check that finds the player on the ground - which reset hasJumped back to false;
image
Then, when the second jump happens, it’s in the air, but hasJumped is false because it was grounded before it actually got off the ground.

I’m still checking what can be done

Have you tried setting the Input System update type to manual and using the InputSystem.Update to rearrange the methods and prevent the racing issue? I think that’s the easier solution, but I don’t know if that will affect the following parts of the course.

@Yee I’ve tried your suggestion above and it made no difference, but a combination of the two suggestions did seem to work. If I make your changes and move CheckForGrounded() into FixedUpdate() the problem seems to go away. At least in my test it did. I also changed the coyoteTimeCounter to reduce by Time.fixedDeltaTime. Here’s the updated code

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerMovement : MonoBehaviour
{
    Rigidbody2D myRigidbody;
    Vector2 moveInput;
    Animator animator;
    CapsuleCollider2D myCollider;
    [SerializeField] float moveSpeed = 5f;
    [SerializeField] float jumpHeight = 15f;
    [SerializeField] float climbingSpeed = 5f;
    [SerializeField] float coyoteTime = 0.2f;
    bool isGrounded;
    float coyoteTimeCounter;
    float myRigidbodyGravityAtStart;
    void Start()
    {
        myCollider = GetComponent<CapsuleCollider2D>();
        myRigidbody = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();
        myRigidbodyGravityAtStart = myRigidbody.gravityScale;
    }

    
    void Update()
    {
        Run();
        FlipSprite();
        ClimbLadder();
    }
    void FixedUpdate()
    {
        CheckForGround();
    }
    void OnMove(InputValue value)
    { 
        moveInput = value.Get<Vector2>();
        Debug.Log(moveInput);
    }
    void OnJump(InputValue value)
    {
        if (value.isPressed)
        {
            if (isGrounded || coyoteTimeCounter > 0f)
            {
                Jump();
                coyoteTimeCounter = 0f; 
            }
        }
     }   
        
        
    void Jump()
    {
        myRigidbody.velocity += new Vector2(0f, jumpHeight);
    }
    void CheckForGround()
    {
        isGrounded = myCollider.IsTouchingLayers(LayerMask.GetMask("Ground"));
        if (isGrounded && coyoteTimeCounter < 0f)
        {
            coyoteTimeCounter = coyoteTime;
        }
        else
        {
            coyoteTimeCounter -= Time.fixedDeltaTime;
        }
    }
    void Run()
    {
        Vector2 playerVelocity = new Vector2(moveInput.x * moveSpeed, myRigidbody.velocity.y);
        myRigidbody.velocity = playerVelocity;
        
    }
    void FlipSprite()
    {
        bool playerHasHorizontalSpeed = Mathf.Abs(myRigidbody.velocity.x) > Mathf.Epsilon;
        if (playerHasHorizontalSpeed)
        {
            transform.localScale = new Vector2(Mathf.Sign(myRigidbody.velocity.x), 1f);
            animator.SetBool("isRunning", true);
        }
        else
        {
            animator.SetBool("isRunning", false);
        }
    }
    void ClimbLadder()
    {
        if (!myCollider.IsTouchingLayers(LayerMask.GetMask("Climbing")))
        {
            myRigidbody.gravityScale = myRigidbodyGravityAtStart;
            animator.SetBool("isClimbing", false);
            return; 
        }
            
        Vector2 climbingVelocity = new Vector2(moveInput.x * moveSpeed, moveInput.y * climbingSpeed);
        myRigidbody.velocity = climbingVelocity;
        myRigidbody.gravityScale = 0f;
        bool playerHasVerticalSpeed = Mathf.Abs(myRigidbody.velocity.y) > Mathf.Epsilon;
        animator.SetBool("isClimbing", playerHasVerticalSpeed);
        
        
    } 
}
1 Like

Tried this but doesn’t work either. But thank you for your commitment in helping me guys.

1 Like

Can you share your project somewhere? May be easier to just look at it directly

1 Like

Sure: https://github.com/xSerj111/Unity-Course-TileVania

So, I had a quick look at it. It’s a difficult problem to solve. The normal frame rate I’m getting on my machine is over 700fps. This means there are easily 10+ ground checks before the character even starts to jump. I could fix the problem with a tiny hack; give the jump a cooldown that is just long enough to ignore ground checks - 0.02f in my test. It’s only a tiny bit more than 1 / 60, but also short enough to not affect the response time (the character stays in the air for far longer than that).

I added a serialized value under the “Jump” heading

    [Header("Jump")]
    [SerializeField] float jumpHeight = 15f;
    [SerializeField] float coyoteTime = 0.1f;
    [SerializeField] float jumpCooldown = 0.02f; // <-- Here

I also added a ‘counter’ to keep track of the time

    float coyoteTimeCounter;
    float jumpCooldownCounter; // <-- Here

Then, in the jump button event, I set the counter to the configured time

    void OnJump(InputValue value)
    {
        if (!isAlive) { return; }
        if (value.isPressed)
        {
            if (isGrounded || coyoteTimeCounter > 0f)
            {
                Jump();
                coyoteTimeCounter = 0f;
                jumpCooldownCounter = jumpCooldown; // <-- Here
            }
        }
    }

and processed it in CheckForGround()

    void CheckForGround()
    {
        isGrounded = myBoxCollider.IsTouchingLayers(LayerMask.GetMask("Ground", "Climbing"));
        if (isGrounded && coyoteTimeCounter < 0f && jumpCooldownCounter < 0f) // <-- Check here
        {
            coyoteTimeCounter = coyoteTime;
        }
        else
        {
            coyoteTimeCounter -= Time.fixedDeltaTime;
            jumpCooldownCounter -= Time.fixedDeltaTime; // <-- Reduce here
        }
    }

All of this ensured that the ground ‘reset’ only starts happening 0.02f seconds after the jump was pressed, giving the rigidbody time to actually leave the ground. After that, the normal ground check will prevent another jump until the character is on the ground again. It’s not pretty, but it works.


Edit
I’m seeing your Swim issue as well and it looks like you may want to start looking into Finite State Machines. In this ‘jump’ scenario, you could move the player into a jump state that does not respond to jump buttons. That would immediately fix the double jump problem. States would eliminate most issues where one thing, eg. swimming, interferes with something else

1 Like

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms