Stage 02 — The jump is three mechanisms
You will build: a jump whose height depends on how long you hold the button, that hangs slightly at the top, and that gives you a speed boost when you jump while running.
You'll learn: variable jump height done the way Celeste actually does it (not the way most tutorials teach) · the apex trick · why one button needs three separate pieces of code.
Warm-up — from memory:
- What does the third argument to
move_towardmean? - From stage 01: which is bigger,
run_accelorrun_reduce, and when does the smaller one apply? - In Godot's 2D coordinates, is a negative
velocity.yup or down?
Why this exists#
A jump is one button, so it looks like one feature. It is at least three, and they're independent enough that games ship with one or two of them and feel subtly wrong:
- Variable height — a tap hops, a hold leaps.
- The apex — a moment of reduced gravity at the top of the arc, which reads as "hang time" and gives the player a beat to aim.
- Momentum transfer — jumping while running should give you more horizontal speed, not the same amount.
We'll build them separately so you can feel each one appear.
The variable-height mechanism, and the myth about it#
Ask the internet how variable jump height works and you'll be told one of these:
- "Cut the velocity when the player releases the button" —
velocity.y *= 0.5on release. - "Apply less gravity while the button is held."
Both produce a variable jump. Neither is what Celeste does, and the difference is worth understanding because Celeste's version is smoother and easier to reason about.
Celeste applies gravity normally, every frame, always. Then, while jump is held and a timer is running, it clamps the velocity back up to the value it had when the jump started:
if var_jump_timer > 0:
if jump held: velocity.y = min(velocity.y, var_jump_speed)
else: var_jump_timer = 0
So while you hold, gravity is pulling you down and the clamp keeps shoving you back to launch speed — a net constant upward velocity. The instant you release, the timer zeroes and gravity takes over from wherever you are, with no discontinuity at all.
That's why it's smoother than a cut. A velocity cut produces a visible kink in the arc: the character is moving up fast, then suddenly slower. The clamp never has a jump in it — releasing just stops holding it up.
Constants: jump_velocity = -105, variable_jump_time = 0.2 seconds.
Build it#
A — Extend the tuning resource#
Add to movement_tuning.gd:
@export_group("Jump")
## Initial upward velocity. Negative is up.
@export var jump_velocity: float = -105.0
## How long the hold can keep sustaining the rise.
@export var variable_jump_time: float = 0.2
## Horizontal speed ADDED when jumping while holding a direction.
@export var jump_h_boost: float = 40.0
## Below this |velocity.y|, gravity halves — the apex hang.
@export var half_gravity_threshold: float = 40.0
Add a jump action in the Input Map: Space, and gamepad A if you have one.
B — Variable height (worked — this is the counterintuitive one)#
In player.gd:
var _var_jump_timer: float = 0.0
var _var_jump_speed: float = 0.0
func _jump() -> void:
velocity.y = tuning.jump_velocity
# remember the launch speed — the clamp below restores it every frame we keep holding
_var_jump_speed = velocity.y
_var_jump_timer = tuning.variable_jump_time
# momentum transfer: ADD to horizontal speed, don't assign it
var input_x: float = Input.get_axis("move_left", "move_right")
velocity.x += tuning.jump_h_boost * input_x
And in _fall(), after gravity has been applied:
# sustain the rise while the button is held and the window is open
if _var_jump_timer > 0.0:
_var_jump_timer -= delta
if Input.is_action_pressed("jump"):
velocity.y = minf(velocity.y, _var_jump_speed)
else:
_var_jump_timer = 0.0 # released — gravity owns the arc from here
Then call _jump() when jump is just-pressed and is_on_floor().
minf and not maxf: up is negative, so the smaller number is the faster rise. Getting this
backwards produces a jump that slams you into the ground, which is at least an unmistakable
symptom.
Note that velocity.x += jump_h_boost * input_x adds. Jump while running at 90 and you
leave the ground at 130. It's a small number with a large effect — it's what makes running
jumps feel committed instead of like a standing jump that happens to be moving.
C — The apex hang (yours to write)#
Two lines in _fall(), before the gravity line. The rule:
When
|velocity.y|is belowhalf_gravity_thresholdand the jump button is held, halve the gravity for this frame.
Work out the shape yourself. Two things to get right:
- It's not time-based. Nothing measures "how long since the jump". It's purely about currently moving slowly vertically — which is what "near the apex" actually means.
- The button check is part of the condition. Celeste only gives you the hang if you're still asking for height. Release early and you get a normal, sharper arc.
Small enough to fit on one line if you like. It changes the feel more than its size suggests.
Predict before you run: with the apex code in, will a tapped jump hang at the top? Work it out from the two conditions before you test. Then test.
D — Feel each piece#
Comment out one piece at a time and jump around:
- Without variable height: every jump identical. You'll immediately want it back.
- Without the apex: the arc feels pointy. Fine, but less time to think at the top.
- Without the h-boost: running jumps feel like the character forgot they were running.
Then tune: jump_velocity to -160 (floaty moon jump), variable_jump_time to 0.05 (the
hold barely matters), half_gravity_threshold to 200 (the whole arc is slow — obviously
wrong, and it teaches you what the threshold is doing).
E — Commit#
git add . && git commit -m "stage 02: variable jump height, apex hang, momentum transfer"
Checkpoint — definition of done#
- A tap gives a small hop; holding gives a jump roughly twice as tall
- Releasing mid-rise transitions smoothly — no visible kink in the arc
- There's a perceptible float at the top of a held jump, and none on a tapped one
- Jumping while running goes further than jumping from standing
- Jumping while airborne does nothing
- Zero warnings; committed
- You can explain: why does clamping velocity produce a smoother arc than cutting it on release?
Stretch (no instructions)#
Celeste has CeilingVarJumpGrace = 0.05. Bonk your head more than 0.05 s after jumping and
the variable-jump window is cancelled; bonk within it and you keep it. Implement that, then work
out what it's forgiving. (Hint: it's about jumps that start under a low ceiling.)
If you get stuck#
- Jump barely moves you → is
jump_velocitynegative? Positive sends you down. - Jump height doesn't respond to hold length → print
_var_jump_timereach frame. Never above zero means_jump()isn't setting it; stuck at max means you're not subtractingdelta. - Character rockets upward forever → you used
maxfinstead ofminf, so the clamp keeps choosing the more upward value and gravity never wins. - Infinite jumping → your
is_on_floor()check is missing, or you're calling_jump()from somewhere that doesn't test it. - The apex feels wrong on the way down → check whether your condition allows it while falling. Consider whether it should, then decide deliberately rather than by accident.
Next: Stage 03 — forgiveness, which makes the jump fair — and is the stage that teaches you why players say a game feels "tight".