Game Feel · Reference Card

Jump Tuning

Designer-facing jump parameters, the formulas that derive physics from them, and a symptom → knob diagnostic table.

The parameters

NameTypicalWhat the player perceives
jump_height60–140 pxHow far up I can reach. Set this against your tile size, not in the abstract.
time_to_peak0.30–0.45 sHow much time I get to read the situation and aim.
time_to_descent0.18–0.32 sHow decisive the landing feels. The single biggest lever on "floaty".
release_cut0.4–0.6Whether a tap and a hold are meaningfully different jumps.

These ranges are starting points for a Celeste-adjacent character at roughly 32 px tiles, not measured values from shipped games. Scale height with your tile size; the times barely change with scale, because they are perceptual, not spatial.

The formulas

Given jump height h, time to apex tup, and time to descend tdown:

launch speed v0 = 2htup
rising gravity gup = 2htup2
falling gravity gdown = 2htdown2

Going the other way

If you inherited a project that hard-codes gravity and impulse, recover the designer-facing numbers with:

h = v02 / (2g)  ·  tup = v0 / g

Rules of thumb

Diagnostics: symptom → knob

ComplaintMost likely cause and fix
"Floaty" time_to_descent too long. Shorten it first; do not touch height.
"Sluggish", "heavy" time_to_peak too long, so v0 is low. Shorten the rise, or raise height to raise launch speed.
"I can't do small hops" release_cut is 1.0 (disabled), or the release check is missing the velocity.y < 0 guard so it also cuts on the way down.
"The jump hits an invisible ceiling" release_cut too aggressive (< 0.3). The transition from tap to hold reads as a hard stop rather than a range.
"Hard to aim mid-air" time_to_peak too short. The rise is where the player thinks; lengthen it slightly before adding air control.
"Overshoots every platform" Horizontal move_speed vs total airtime, not the arc. Jump distance = move_speed × (t_up + t_down).
"Jump height is inconsistent" Gravity applied after the jump impulse (shaves a frame), or physics run in _process instead of _physics_process.
"Measured apex ≠ designed apex" Expected. Semi-implicit Euler at a fixed timestep overshoots slightly. If it bothers you, tune to the measured value — that is what the player sees.

Godot 4 — minimal implementation

@export var jump_height: float = 96.0
@export var jump_time_to_peak: float = 0.38
@export var jump_time_to_descent: float = 0.26
@export_range(0.0, 1.0) var release_cut: float = 0.5

@onready var jump_velocity: float = -2.0 * jump_height / jump_time_to_peak
@onready var jump_gravity:  float =  2.0 * jump_height / pow(jump_time_to_peak, 2)
@onready var fall_gravity:  float =  2.0 * jump_height / pow(jump_time_to_descent, 2)

func _physics_process(delta: float) -> void:
    # Y grows downward in Godot 2D: negative velocity.y is rising.
    velocity.y += (jump_gravity if velocity.y < 0.0 else fall_gravity) * delta

    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_velocity
    if Input.is_action_just_released("jump") and velocity.y < 0.0:
        velocity.y *= release_cut

    move_and_slide()

Non-negotiable: every one of these must be @exported and tuned with the game running. Feel is found by dragging, never by reasoning.