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.
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
VariableJump2D.cs
Classic 2D variable jump. Hold to go higher, release early to cut the jump short.
Setup:
- Add
Rigidbody2Dto your player (Freeze Rotation Z) - Add a
Collider2D - Create an empty child GameObject at the feet, name it "GroundCheck"
- 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:
- Add
CharacterControllerto your player - Add this script and configure in Inspector
- 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:
- Add
CharacterControllerto your player - Create a child Camera
- 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:
- Add
CharacterControllerto your player - Set up isometric camera
- 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
- Coyote Time - grace period makes variable jump more forgiving
- Input Buffer - catch jump presses just before landing
- Squash Stretch - visual feedback that matches the jump's force
- Double Jump - extend the vocabulary mid-air
Why This Matters
Variable jump is where students first encounter input as expression. The same button press can mean different things based on how it's performed. This is fundamental to understanding how videogames create nuanced control.
Teaching Sequence
- Start with Basic Jump - establish baseline
- Switch to Variable Jump with same base parameters
- Have students feel the difference before explaining
- Then explain the mechanism
Common Student Misconceptions
"Variable jump is just two jump heights": Students sometimes implement it as binary (tap = short, hold = tall). The real technique is continuous shaping.
"The player decides at jump start": No - the player decides during the jump. The arc is shaped in real-time. This is what makes it feel responsive.
Assessment
- Can they explain why jumpCutMultiplier matters for responsiveness?
- Can they tune for a specific feel (floaty vs. snappy) deliberately?
- Can they articulate when basic jump would be better than variable jump?
Heritage Notes
Variable jump emerged in the early 1980s as designers realized fixed jumps felt unresponsive. Super Mario Bros. (1985) is the canonical example, but the technique appeared earlier in various forms.
The problem it solved: fixed jumps force level design to accommodate the jump, not the player. With variable jump, the player adapts to the level.
The Expression Problem
Variable jump raises a design question: how much should the player be able to express through a single input?
A single button press can produce many different outcomes based on:
- How long it's held
- When it's pressed (coyote time)
- What direction is held simultaneously
- What state the character is in
This is the beginning of Gesture as a concept - the player's input and the videogame's response form a complete expressive unit.
The Celeste Refinement
Celeste (2018) represents the current state of the art for variable jump. It combines variable height, generous coyote time, input buffering, distinct fall gravity, and air dash integration. The result feels precise but forgiving.
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
- Coyote Time - grace period for jump input
- Input Buffer - remembering inputs for grace
- Game Feel - the experience of controlling a videogame