Double Jump

An extra jump in mid-air. Press jump again before landing to get additional height or change direction. The most common platformer upgrade.

Practice - what you do

The Feel

Double jump adds aerial control and recovery. It lets players correct mistakes, reach higher platforms, and feel more expressive in the air.

It's also a psychological safety net - players feel less committed to their first jump because they have a second chance.

Exposed Variables

Variable Type Default What it does
extraJumps int 1 Number of air jumps allowed
airJumpForce float 10.0 Force of air jump (often less than ground)
resetVelocity bool true Cancel downward velocity before air jump

Tuning Guide

For same-height double jump:

  • airJumpForce = same as jumpForce
  • resetVelocity = true

For diminishing jumps:

  • airJumpForce = 70-80% of jumpForce
  • Encourages using first jump wisely

Reference: Hollow Knight, Metroidvanias

For boosted air jump:

  • airJumpForce > jumpForce
  • First jump is setup, air jump is powerful

Perspective Scripts

Prerequisite: All scripts below require the InputReader to be set up in your scene.

DoubleJump2D.cs

2D double jump with configurable air jumps and distinct feedback for ground vs air jumps.

Setup:

  1. Add Rigidbody2D to your player
  2. Create a child "GroundCheck" at the feet
  3. Add this script and configure in Inspector
  4. Optional: assign airJumpEffect particle prefab
DoubleJump2D.cs
using UnityEngine;

/// <summary>
/// DOUBLE JUMP 2D - Air jump with resource management
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class DoubleJump2D : MonoBehaviour
{
    [Header("Ground Jump")]
    [Range(5f, 25f)]
    [SerializeField] private float jumpForce = 12f;

    [Header("Air Jump")]
    [Tooltip("Number of air jumps (1 = double jump, 2 = triple)")]
    [Range(1, 5)]
    [SerializeField] private int extraJumps = 1;

    [Range(5f, 25f)]
    [SerializeField] private float airJumpForce = 10f;

    [Tooltip("Cancel downward velocity before air jump")]
    [SerializeField] private bool resetVelocity = true;

    [Header("Gravity")]
    [Range(20f, 80f)]
    [SerializeField] private float gravity = 40f;

    [Range(1f, 4f)]
    [SerializeField] private float fallGravityMultiplier = 1.5f;

    [Header("Ground Detection")]
    [SerializeField] private Transform groundCheckPoint;
    [Range(0.05f, 0.5f)]
    [SerializeField] private float groundCheckRadius = 0.2f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Air Jump Effect (Optional)")]
    [SerializeField] private GameObject airJumpEffect;

    [Header("Debug")]
    [SerializeField] private bool showGroundCheck = true;
    [SerializeField] private bool debugMode = false;

    private Rigidbody2D rb;
    private float verticalVelocity;
    private bool isGrounded;
    private bool wasGrounded;
    private int jumpsRemaining;

    public int JumpsRemaining => jumpsRemaining;
    public bool CanJump => isGrounded || jumpsRemaining > 0;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        rb.gravityScale = 0f;
        jumpsRemaining = extraJumps;
    }

    private void OnEnable()
    {
        if (InputReader.Instance != null)
        {
            InputReader.Instance.OnJumpPressed += HandleJump;
        }
    }

    private void OnDisable()
    {
        if (InputReader.Instance != null)
        {
            InputReader.Instance.OnJumpPressed -= HandleJump;
        }
    }

    private void FixedUpdate()
    {
        CheckGrounded();
        ApplyGravity();
        ApplyVelocity();
    }

    private void CheckGrounded()
    {
        wasGrounded = isGrounded;

        isGrounded = Physics2D.OverlapCircle(
            groundCheckPoint.position,
            groundCheckRadius,
            groundLayer
        );

        if (isGrounded && verticalVelocity < 0)
        {
            verticalVelocity = 0f;
        }

        // Refresh jumps on landing
        if (isGrounded && !wasGrounded)
        {
            jumpsRemaining = extraJumps;
            if (debugMode) Debug.Log($"Landed! Air jumps: {jumpsRemaining}");
        }
    }

    private void HandleJump()
    {
        if (isGrounded)
        {
            // Ground jump
            verticalVelocity = jumpForce;
            if (debugMode) Debug.Log("Ground jump!");
        }
        else if (jumpsRemaining > 0)
        {
            // Air jump
            if (resetVelocity)
            {
                verticalVelocity = 0f;
            }
            verticalVelocity = airJumpForce;
            jumpsRemaining--;

            SpawnAirJumpEffect();
            if (debugMode) Debug.Log($"Air jump! Remaining: {jumpsRemaining}");
        }
    }

    private void SpawnAirJumpEffect()
    {
        if (airJumpEffect != null)
        {
            GameObject effect = Instantiate(airJumpEffect, transform.position, Quaternion.identity);
            Destroy(effect, 2f);
        }
    }

    private void ApplyGravity()
    {
        if (!isGrounded)
        {
            float gravityThisFrame = gravity;
            if (verticalVelocity < 0)
            {
                gravityThisFrame *= fallGravityMultiplier;
            }
            verticalVelocity -= gravityThisFrame * Time.fixedDeltaTime;
        }
    }

    private void ApplyVelocity()
    {
        Vector2 velocity = rb.linearVelocity;
        velocity.y = verticalVelocity;
        rb.linearVelocity = velocity;
    }

    // Call from external scripts (e.g., wall touch)
    public void RefreshAirJumps()
    {
        jumpsRemaining = extraJumps;
    }

    private void OnDrawGizmos()
    {
        if (showGroundCheck && groundCheckPoint != null)
        {
            Gizmos.color = isGrounded ? Color.green : Color.red;
            Gizmos.DrawWireSphere(groundCheckPoint.position, groundCheckRadius);
        }
    }
}

DoubleJump3D.cs

3D double jump for third-person, first-person, and isometric. Works with CharacterController.

Setup:

  1. Add CharacterController to your player
  2. Add this script and configure in Inspector
  3. Optional: assign airJumpEffect particle prefab
DoubleJump3D.cs
using UnityEngine;

/// <summary>
/// DOUBLE JUMP 3D - Air jump for all 3D perspectives
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class DoubleJump3D : MonoBehaviour
{
    [Header("Ground Jump")]
    [Range(5f, 25f)]
    [SerializeField] private float jumpForce = 10f;

    [Header("Air Jump")]
    [Range(1, 5)]
    [SerializeField] private int extraJumps = 1;

    [Range(5f, 25f)]
    [SerializeField] private float airJumpForce = 8f;

    [SerializeField] private bool resetVelocity = true;

    [Header("Gravity")]
    [Range(20f, 80f)]
    [SerializeField] private float gravity = 30f;

    [Range(1f, 4f)]
    [SerializeField] private float fallGravityMultiplier = 2f;

    [Header("Ground Detection")]
    [SerializeField] private Vector3 groundCheckOffset = new Vector3(0, -0.9f, 0);
    [SerializeField] private float groundCheckRadius = 0.3f;
    [SerializeField] private LayerMask groundLayer;

    [Header("Movement (Optional)")]
    [SerializeField] private float moveSpeed = 8f;
    [Range(0f, 1f)]
    [SerializeField] private float airControl = 0.5f;
    [SerializeField] private bool useBuiltInMovement = true;

    [Header("Air Jump Effect (Optional)")]
    [SerializeField] private GameObject airJumpEffect;

    [Header("Debug")]
    [SerializeField] private bool showGroundCheck = true;
    [SerializeField] private bool debugMode = false;

    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;
    private bool wasGrounded;
    private int jumpsRemaining;
    private Transform cameraTransform;

    public int JumpsRemaining => jumpsRemaining;
    public bool CanJump => isGrounded || jumpsRemaining > 0;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
        cameraTransform = Camera.main?.transform;
        jumpsRemaining = extraJumps;
    }

    private void OnEnable()
    {
        if (InputReader.Instance != null)
        {
            InputReader.Instance.OnJumpPressed += HandleJump;
        }
    }

    private void OnDisable()
    {
        if (InputReader.Instance != null)
        {
            InputReader.Instance.OnJumpPressed -= HandleJump;
        }
    }

    private void Update()
    {
        CheckGrounded();

        if (useBuiltInMovement)
        {
            HandleMovement();
        }

        ApplyGravity();
        ApplyVelocity();
    }

    private void CheckGrounded()
    {
        wasGrounded = isGrounded;

        Vector3 checkPosition = transform.position + groundCheckOffset;
        isGrounded = Physics.CheckSphere(checkPosition, groundCheckRadius, groundLayer);

        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f;
        }

        // Refresh jumps on landing
        if (isGrounded && !wasGrounded)
        {
            jumpsRemaining = extraJumps;
            if (debugMode) Debug.Log($"Landed! Air jumps: {jumpsRemaining}");
        }
    }

    private void HandleMovement()
    {
        Vector2 input = InputReader.Instance != null ? InputReader.Instance.MoveInput : Vector2.zero;

        Vector3 forward = cameraTransform != null ? cameraTransform.forward : transform.forward;
        Vector3 right = cameraTransform != null ? cameraTransform.right : transform.right;
        forward.y = 0f;
        right.y = 0f;
        forward.Normalize();
        right.Normalize();

        Vector3 moveDirection = (forward * input.y + right * input.x);

        float currentSpeed = isGrounded ? moveSpeed : moveSpeed * airControl;
        velocity.x = moveDirection.x * currentSpeed;
        velocity.z = moveDirection.z * currentSpeed;

        if (moveDirection.magnitude > 0.1f)
        {
            Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
            transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, 10f * Time.deltaTime);
        }
    }

    private void HandleJump()
    {
        if (isGrounded)
        {
            velocity.y = jumpForce;
            if (debugMode) Debug.Log("Ground jump!");
        }
        else if (jumpsRemaining > 0)
        {
            if (resetVelocity)
            {
                velocity.y = 0f;
            }
            velocity.y = airJumpForce;
            jumpsRemaining--;

            SpawnAirJumpEffect();
            if (debugMode) Debug.Log($"Air jump! Remaining: {jumpsRemaining}");
        }
    }

    private void SpawnAirJumpEffect()
    {
        if (airJumpEffect != null)
        {
            GameObject effect = Instantiate(airJumpEffect, transform.position, Quaternion.identity);
            Destroy(effect, 2f);
        }
    }

    private void ApplyGravity()
    {
        if (!isGrounded)
        {
            float gravityThisFrame = gravity;
            if (velocity.y < 0)
            {
                gravityThisFrame *= fallGravityMultiplier;
            }
            velocity.y -= gravityThisFrame * Time.deltaTime;
        }
    }

    private void ApplyVelocity()
    {
        controller.Move(velocity * Time.deltaTime);
    }

    public void SetHorizontalVelocity(Vector3 horizontalVelocity)
    {
        velocity.x = horizontalVelocity.x;
        velocity.z = horizontalVelocity.z;
    }

    public void RefreshAirJumps()
    {
        jumpsRemaining = extraJumps;
    }

    private void OnDrawGizmos()
    {
        if (showGroundCheck)
        {
            Vector3 checkPosition = transform.position + groundCheckOffset;
            Gizmos.color = isGrounded ? Color.green : Color.red;
            Gizmos.DrawWireSphere(checkPosition, groundCheckRadius);
        }
    }
}

Key Implementation Details

  • Counter management: Track jumpsRemaining, decrement on air jump, reset on landing
  • Velocity reset: Optional - makes air jump height consistent regardless of fall speed
  • Distinct feedback: Air jump MUST look/sound different from ground jump
  • RefreshAirJumps(): Call from wall touch or other refresh triggers

Common Issues

"Double jump doesn't feel different"
Add distinct feedback: particle effect, different sound, animation change. Players need to know they used their resource.

"Infinite jumps"
Make sure you only reset jumpsRemaining on landing (wasGrounded check), not every frame.

"Air jump barely helps when falling"
Set resetVelocity to true for consistent air jump height.

Design Decisions

Question Options
Coyote jump uses air jump? Most videogames: no. Coyote is grace, not air jump.
Reset on wall touch? Yes for wall-jump videogames (enables infinite climbing)
Reset on taking damage? Generous design - prevents unfair deaths

Combine With

  • Variable Jump - both jumps can be variable height
  • Coyote Time - coyote shouldn't consume air jump
  • Dash - dash + double jump for maximum air mobility

Related

Glossary Terms