Game Feel · Reference Card
Designer-facing jump parameters, the formulas that derive physics from them, and a symptom → knob diagnostic table.
| Name | Typical | What the player perceives |
|---|---|---|
jump_height | 60–140 px | How far up I can reach. Set this against your tile size, not in the abstract. |
time_to_peak | 0.30–0.45 s | How much time I get to read the situation and aim. |
time_to_descent | 0.18–0.32 s | How decisive the landing feels. The single biggest lever on "floaty". |
release_cut | 0.4–0.6 | Whether 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.
Given jump height h, time to apex tup, and time to descend tdown:
If you inherited a project that hard-codes gravity and impulse, recover the designer-facing numbers with:
| Complaint | Most 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. |
@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.