Variable Jump

A jump where height depends on how long you hold the button. Tap for a short hop, hold for a full jump. This is the standard for modern platformers - it gives players expressive control over their arc.

Practice - what you do

The Feel

Variable jump creates a sense of responsive precision. The player feels like the character is listening to their intention, not just executing a canned animation.

Compare:

  • Fixed jump: "I pressed jump and it happened"
  • Variable jump: "I shaped that jump"

The difference is ownership.

Exposed Variables

Variable Type Default What it does
minJumpForce float 8.0 Jump height if you tap (minimum)
maxJumpForce float 16.0 Jump height if you hold (maximum)
jumpCutMultiplier float 0.5 Velocity multiplier when releasing early
gravity float 40.0 Downward acceleration
fallGravityMultiplier float 1.5 Extra gravity when falling

Tuning Guide

For a floaty, forgiving feel:

  • Lower gravity (25-30)
  • Lower fallGravityMultiplier (1.0-1.2)
  • Higher jumpCutMultiplier (0.7-0.8)

Reference: Kirby, Little Big Planet

For a tight, precise feel:

  • Higher gravity (45-60)
  • Higher fallGravityMultiplier (1.8-2.5)
  • Lower jumpCutMultiplier (0.3-0.5)

Reference: Celeste, Super Meat Boy

Perspective Scripts

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

VariableJump2D.cs

Classic 2D variable jump. Hold to go higher, release early to cut the jump short.

Setup:

  1. Add Rigidbody2D to your player (Freeze Rotation Z)
  2. Add a Collider2D
  3. Create an empty child GameObject at the feet, name it "GroundCheck"
  4. Add this script and configure in Inspector
VariableJump2D.cs
using UnityEngine;

/// <summary>
/// VARIABLE JUMP 2D - Height depends on hold duration
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class VariableJump2D : MonoBehaviour
{
    [Header("Jump Feel")]
    [Tooltip("Jump force if you tap (minimum height)")]
    [Range(5f, 15f)]
    [SerializeField] private float minJumpForce = 8f;

    [Tooltip("Jump force if you hold (maximum height)")]
    [Range(10f, 25f)]
    [SerializeField] private float maxJumpForce = 16f;

    [Tooltip("Velocity multiplier when releasing early")]
    [Range(0.1f, 1f)]
    [SerializeField] private float jumpCutMultiplier = 0.5f;

    [Tooltip("Gravity while rising and falling")]
    [Range(20f, 80f)]
    [SerializeField] private float gravity = 40f;

    [Tooltip("Extra gravity when falling")]
    [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("Debug")]
    [SerializeField] private bool showGroundCheck = true;

    private Rigidbody2D rb;
    private float verticalVelocity;
    private bool isGrounded;
    private bool isJumping;
    private bool jumpHeld;

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

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

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

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

    private void CheckGrounded()
    {
        isGrounded = Physics2D.OverlapCircle(
            groundCheckPoint.position,
            groundCheckRadius,
            groundLayer
        );

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

    private void HandleJumpPressed()
    {
        if (isGrounded)
        {
            verticalVelocity = minJumpForce;
            isJumping = true;
            jumpHeld = true;
        }
    }

    private void HandleJumpReleased()
    {
        jumpHeld = false;

        // Cut the jump if still rising
        if (isJumping && verticalVelocity > 0)
        {
            verticalVelocity *= jumpCutMultiplier;
        }
    }

    private void HandleJumpHold()
    {
        // While holding and rising, boost toward max
        if (isJumping && jumpHeld && verticalVelocity > 0)
        {
            // Smoothly increase force while holding
            verticalVelocity = Mathf.MoveTowards(
                verticalVelocity,
                maxJumpForce,
                (maxJumpForce - minJumpForce) * Time.fixedDeltaTime * 10f
            );
        }
    }

    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;
    }

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

VariableJump3D.cs

Third-person variable jump with camera-relative movement. Hold for higher jumps.

Setup:

  1. Add CharacterController to your player
  2. Add this script and configure in Inspector
  3. Set Ground Layer to your environment's layer
VariableJump3D.cs
using UnityEngine;

/// <summary>
/// VARIABLE JUMP 3D - Height depends on hold duration
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class VariableJump3D : MonoBehaviour
{
    [Header("Jump Feel")]
    [Range(5f, 15f)]
    [SerializeField] private float minJumpForce = 6f;

    [Range(10f, 25f)]
    [SerializeField] private float maxJumpForce = 12f;

    [Range(0.1f, 1f)]
    [SerializeField] private float jumpCutMultiplier = 0.5f;

    [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")]
    [SerializeField] private float moveSpeed = 8f;
    [Range(0f, 1f)]
    [SerializeField] private float airControl = 0.5f;

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

    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;
    private bool isJumping;
    private bool jumpHeld;
    private Transform cameraTransform;

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

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

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

    private void Update()
    {
        CheckGrounded();
        HandleMovement();
        HandleJumpHold();
        ApplyGravity();
        ApplyVelocity();
    }

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

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

    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 HandleJumpPressed()
    {
        if (isGrounded)
        {
            velocity.y = minJumpForce;
            isJumping = true;
            jumpHeld = true;
        }
    }

    private void HandleJumpReleased()
    {
        jumpHeld = false;

        if (isJumping && velocity.y > 0)
        {
            velocity.y *= jumpCutMultiplier;
        }
    }

    private void HandleJumpHold()
    {
        if (isJumping && jumpHeld && velocity.y > 0)
        {
            velocity.y = Mathf.MoveTowards(
                velocity.y,
                maxJumpForce,
                (maxJumpForce - minJumpForce) * Time.deltaTime * 10f
            );
        }
    }

    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);
    }

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

VariableJumpFPS.cs

First-person variable jump. Less common in FPS but useful for platforming-focused games like Titanfall.

Setup:

  1. Add CharacterController to your player
  2. Create a child Camera
  3. Add this script and assign the Camera reference
VariableJumpFPS.cs
using UnityEngine;

/// <summary>
/// VARIABLE JUMP FPS - First-person variable jump
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class VariableJumpFPS : MonoBehaviour
{
    [Header("Jump Feel")]
    [Range(5f, 15f)]
    [SerializeField] private float minJumpForce = 6f;

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

    [Range(0.1f, 1f)]
    [SerializeField] private float jumpCutMultiplier = 0.5f;

    [Range(20f, 80f)]
    [SerializeField] private float gravity = 25f;

    [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")]
    [SerializeField] private float moveSpeed = 6f;
    [Range(0f, 1f)]
    [SerializeField] private float airControl = 0.3f;

    [Header("Mouse Look")]
    [SerializeField] private Transform playerCamera;
    [SerializeField] private float gamepadLookSpeed = 150f;
    [SerializeField] private float mouseSensitivity = 0.15f;
    [Range(45f, 90f)]
    [SerializeField] private float maxLookAngle = 85f;

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

    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;
    private bool isJumping;
    private bool jumpHeld;
    private float cameraPitch;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
    }

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

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

    private void Update()
    {
        CheckGrounded();
        HandleLook();
        HandleMovement();
        HandleJumpHold();
        ApplyGravity();
        ApplyVelocity();
    }

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

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

    private void HandleLook()
    {
        if (InputReader.Instance == null) return;

        Vector2 lookInput = InputReader.Instance.LookInput;

        float lookSpeedX, lookSpeedY;
        if (InputReader.Instance.IsUsingGamepad)
        {
            lookSpeedX = gamepadLookSpeed * Time.deltaTime;
            lookSpeedY = gamepadLookSpeed * Time.deltaTime;
        }
        else
        {
            lookSpeedX = mouseSensitivity;
            lookSpeedY = mouseSensitivity;
        }

        transform.Rotate(Vector3.up * lookInput.x * lookSpeedX);

        cameraPitch -= lookInput.y * lookSpeedY;
        cameraPitch = Mathf.Clamp(cameraPitch, -maxLookAngle, maxLookAngle);

        if (playerCamera != null)
        {
            playerCamera.localRotation = Quaternion.Euler(cameraPitch, 0f, 0f);
        }
    }

    private void HandleMovement()
    {
        Vector2 input = InputReader.Instance != null ? InputReader.Instance.MoveInput : Vector2.zero;
        Vector3 moveDirection = transform.forward * input.y + transform.right * input.x;

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

    private void HandleJumpPressed()
    {
        if (isGrounded)
        {
            velocity.y = minJumpForce;
            isJumping = true;
            jumpHeld = true;
        }
    }

    private void HandleJumpReleased()
    {
        jumpHeld = false;

        if (isJumping && velocity.y > 0)
        {
            velocity.y *= jumpCutMultiplier;
        }
    }

    private void HandleJumpHold()
    {
        if (isJumping && jumpHeld && velocity.y > 0)
        {
            velocity.y = Mathf.MoveTowards(
                velocity.y,
                maxJumpForce,
                (maxJumpForce - minJumpForce) * Time.deltaTime * 10f
            );
        }
    }

    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);
    }

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

VariableJumpIsometric.cs

Isometric variable jump. Useful for action games like Hades where vertical movement adds depth.

Setup:

  1. Add CharacterController to your player
  2. Set up isometric camera
  3. Add this script and configure movement mode
VariableJumpIsometric.cs
using UnityEngine;

/// <summary>
/// VARIABLE JUMP ISOMETRIC - Isometric variable jump
/// VG101 Code Bank - Requires InputReader from Input Setup
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class VariableJumpIsometric : MonoBehaviour
{
    public enum MovementMode { WorldAxis, Isometric45 }

    [Header("Jump Feel")]
    [Range(5f, 15f)]
    [SerializeField] private float minJumpForce = 8f;

    [Range(10f, 25f)]
    [SerializeField] private float maxJumpForce = 14f;

    [Range(0.1f, 1f)]
    [SerializeField] private float jumpCutMultiplier = 0.5f;

    [Range(20f, 80f)]
    [SerializeField] private float gravity = 35f;

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

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

    [Header("Movement")]
    [SerializeField] private MovementMode movementMode = MovementMode.Isometric45;
    [SerializeField] private float moveSpeed = 7f;
    [Range(0f, 1f)]
    [SerializeField] private float airControl = 0.5f;
    [SerializeField] private bool rotateToMovement = true;
    [SerializeField] private float rotationSpeed = 720f;

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

    private CharacterController controller;
    private Vector3 velocity;
    private bool isGrounded;
    private bool isJumping;
    private bool jumpHeld;

    private static readonly Matrix4x4 isoMatrix = Matrix4x4.Rotate(Quaternion.Euler(0, 45, 0));

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

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

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

    private void Update()
    {
        CheckGrounded();
        HandleMovement();
        HandleJumpHold();
        ApplyGravity();
        ApplyVelocity();
    }

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

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

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

        Vector3 inputDirection = new Vector3(input.x, 0, input.y);
        Vector3 moveDirection = movementMode == MovementMode.Isometric45
            ? isoMatrix.MultiplyVector(inputDirection)
            : inputDirection;

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

        if (rotateToMovement && moveDirection.magnitude > 0.1f)
        {
            Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
            transform.rotation = Quaternion.RotateTowards(
                transform.rotation,
                targetRotation,
                rotationSpeed * Time.deltaTime
            );
        }
    }

    private void HandleJumpPressed()
    {
        if (isGrounded)
        {
            velocity.y = minJumpForce;
            isJumping = true;
            jumpHeld = true;
        }
    }

    private void HandleJumpReleased()
    {
        jumpHeld = false;

        if (isJumping && velocity.y > 0)
        {
            velocity.y *= jumpCutMultiplier;
        }
    }

    private void HandleJumpHold()
    {
        if (isJumping && jumpHeld && velocity.y > 0)
        {
            velocity.y = Mathf.MoveTowards(
                velocity.y,
                maxJumpForce,
                (maxJumpForce - minJumpForce) * Time.deltaTime * 10f
            );
        }
    }

    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);
    }

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

Key Differences from Basic Jump

  • Two force values: minJumpForce and maxJumpForce define the range
  • Hold tracking: We track whether the button is held
  • Jump cut: Releasing early multiplies velocity by jumpCutMultiplier
  • Real-time shaping: The arc is determined during the jump, not at the start

Common Issues

"The jump feels mushy"
Increase fallGravityMultiplier. The descent should be snappier than the ascent.

"I can't do small hops"
Lower minJumpForce or decrease jumpCutMultiplier.

"InputReader.Instance is null"
Make sure you have the InputReader in your scene. See Input Setup.

Combine With


Related

  • Basic Jump - simpler version without variable height
  • Double Jump - extending the variable jump vocabulary
  • Input Setup - required InputReader system
  • Gesture - the variable jump as a complete gesture

Glossary Terms