Game Feel · Lesson 0001 · ~20 minutes
Stop picking a gravity constant. Pick a height and a duration, and let the physics fall out of them.
Every 2D platformer you admire — Celeste, Hollow Knight, Super Meat Boy — has a jump that violates Newtonian physics on purpose. This lesson is about the single highest-leverage version of that violation, and by the end you will have three numbers you can paste into a Godot project.
A programmer's instinct when writing a jump is to reach for physics: pick a gravity, pick an upward impulse, integrate. That produces a parabola — perfectly symmetric, rise time equal to fall time. It is physically correct and it feels terrible.
It feels terrible because a jump is not a simulation, it is a designed
motion curve. What the player perceives is not gravity = 980. It is
how high can I get and how long am I stuck up there.
Those are the two quantities worth exposing as knobs — so derive gravity from them,
rather than the other way round.
Game feel is real-time control of virtual objects in a simulated space, with interactions emphasised by polish. Steve Swink, Game Feel, ch. 1
Swink's middle term — simulated space — is the one people misread. The space does not have to obey physics. It has to obey perception. Your job is to tune the simulation until it matches what the player's body expects, not what a physics engine computes. (source)
Below is a real jump running real Godot-shaped physics. Click the canvas, then hold Space. The red box uses the tuned model. The blue ghost is the same jump under textbook symmetric gravity with no release cut — the version you would have written by instinct.
Three things to try, in order:
0.15. Jump again. That
snap is the entire feeling of "tight".1.00 (off), then tap
Space briefly. Notice you no longer have a small hop available — you have
lost half your expressive range.What you just felt has a name: asymmetric gravity. Rising is slow enough to read and aim; falling is fast enough to feel decisive. The player never notices the asymmetry — they notice that the character feels athletic rather than balloon-like.
You want to specify a jump as "96 pixels high, 0.38 seconds up, 0.26 seconds down". Solve the constant-acceleration equations for that and you get three lines of setup. Let h be jump height, tup time to apex, and tdown time to fall back:
The squared term is the part worth internalising, because it is where intuition fails: time is quadratic, height is linear. Doubling your jump height doubles gravity. Doubling your time to apex divides gravity by four. When a jump feels wrong, reaching for the time knob moves things about four times as violently as reaching for the height knob.
Diagnostic shortcut. "Floaty" almost never means the jump is too high. It means tdown is too long. Fix the fall before you touch anything else.
Here is an arc taken from a jump that feels good. You cannot see its numbers. Drag the three sliders until your curve lies on top of it — 90 or better counts as a hit. Do it from the shape alone before you press reveal.
The whole model is a dozen lines on a CharacterBody2D. Note that Godot's
2D Y axis points down, so the launch velocity is negative and gravity is
positive.
extends CharacterBody2D
@export var move_speed: float = 180.0
@export var jump_height: float = 96.0 # pixels
@export var jump_time_to_peak: float = 0.38 # seconds, rising
@export var jump_time_to_descent: float = 0.26 # seconds, falling
@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 _get_gravity() -> float:
# velocity.y < 0 means moving up, because Godot's Y grows downward
return jump_gravity if velocity.y < 0.0 else fall_gravity
func _physics_process(delta: float) -> void:
velocity.y += _get_gravity() * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
# variable jump height: let go early, stop rising early
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= release_cut
velocity.x = Input.get_axis("move_left", "move_right") * move_speed
move_and_slide()
Two details that matter more than they look:
@exported. Feel is found by dragging a
slider while the game runs, not by reasoning in your head. If a feel parameter is a
constant in code, it will never get tuned.Starting numbers, if you want them. jump_height 96,
time_to_peak 0.38, time_to_descent 0.26,
release_cut 0.5, move_speed 180. These are a reasonable
Celeste-adjacent baseline. Treat them as a place to start disagreeing from.
Answer from memory. Scrolling back up first turns this into recognition practice, which builds far weaker retention than getting it wrong does.
Primary source: Steve Swink, Game Feel, Chapter 1: "Defining Game Feel" (free PDF, ~20 pages). It is the foundational text for everything in this workspace, and chapter 1 is the whole argument in miniature — the three building blocks and the 100 ms correction cycle. Read it once now; you will re-read it later and get more out of it.
Optional, if the Godot side interested you more than the theory: Maddy Thorson on Celeste and TowerFall physics — how the best-feeling platformer of its generation actually moves its characters.