doc 5 of 10

Stage 03 — Forgiveness

You will build: coyote time and jump buffering — two short timers that make jumps you meant to make actually happen.

You'll learn: why "tight controls" is mostly forgiveness rather than precision · reading input intent separately from acting on it.

Warm-up — from memory:

  1. What does _var_jump_timer do while it's above zero?
  2. From stage 01: at 1000 px/s² of deceleration, how long to stop from 90 px/s?
  3. What's the difference between Input.is_action_pressed and is_action_just_pressed?

Why this exists#

Nothing will look different at the end of this stage. Everything will feel better. That gap is the lesson.

Two moments break every unforgiving platformer:

You run off a ledge and press jump a heartbeat late. You were airborne for four frames. The game says no. You know you pressed jump, and you blame the game — correctly, as it turns out.

You press jump just before landing. You're 30 ms above the ground. The press is discarded because you weren't grounded, you land, and nothing happens. You press again, and now you've lost your rhythm.

Both are the same mistake in the code: treating the exact frame of the button press as the only moment that counts. Human timing is not frame-accurate, and asking it to be doesn't make a game hard — it makes it feel broken.

The fix is two timers. Celeste calls them JumpGraceTime (0.1 s) and the input buffer (0.08 s). Six frames and roughly five frames at 60 fps. Small enough to be invisible, large enough to change everything.

Maddy Thorson wrote about this directly in Celeste & Forgiveness — worth reading after this stage.

Coyote time#

Named for the cartoon coyote who runs off a cliff and hangs there until he notices. The rule:

Being on the floor sets a timer to coyote_time. Off the floor, the timer counts down. A jump is allowed while the timer is above zero, not just while grounded. Jumping consumes the timer immediately.

That last line is the one people forget, and its absence gives you a free double jump — walk off a ledge, jump (timer still running), and jump again before the timer expires.

Jump buffering#

The mirror image:

Pressing jump sets a timer to jump_buffer_time, which counts down. Every frame, if the buffer is above zero and a jump is currently possible, jump and clear the buffer.

Rather than asking "was jump pressed this exact frame", you ask "has jump been pressed recently, and can we honour it yet".

There's a second, quieter reason buffering helps. Input.is_action_just_pressed() is true for one frame of processing — and _physics_process doesn't necessarily run once per rendered frame. Under load or with a mismatched physics tick rate, edge-triggered checks can be observed oddly. A buffer that survives several frames makes the question moot. (Godot's documentation doesn't discuss this specific interaction, so treat it as a good reason rather than a documented one; the forgiveness argument stands on its own regardless.)

Build it#

A — Tuning#

@export_group("Assist")
@export var coyote_time: float = 0.1
@export var jump_buffer_time: float = 0.08

B — The timers (skeleton — fill in the bodies)#

var _coyote_timer: float = 0.0
var _jump_buffer_timer: float = 0.0


func _update_timers(delta: float) -> void:
	# grounded refills coyote; airborne drains it
	pass

	# the buffer always drains, whatever else is happening
	pass


func _try_jump() -> void:
	# a jump happens when there is a buffered press AND coyote time remains
	# both are consumed — leaving either behind is the double-jump bug
	pass

Call _update_timers(delta) at the top of _physics_process, and record the press wherever you currently handle jump input:

	if Input.is_action_just_pressed("jump"):
		_jump_buffer_timer = tuning.jump_buffer_time

Notice the restructure. The press no longer causes a jump — it records an intention. Something else decides when that intention becomes a jump. Separating intent from action is the actual technique here, and it generalises far beyond jumping: dash buffering, attack queueing, and combo systems are all this same shape.

C — Test it deliberately#

These are hard to verify by feel. Test them on purpose:

  1. Coyote: walk off a ledge and press jump as late as you can while still jumping. You should get a clearly usable window, not a frame-perfect one.
  2. Coyote abuse: walk off a ledge, jump, and immediately try to jump again mid-air. If you get a second jump, you didn't consume the timer.
  3. Buffer: jump toward the ground and press jump again while still clearly airborne — roughly a body-length up. You should jump the instant you land.
  4. Buffer abuse: press jump repeatedly in mid-air over a long fall. You should land and jump once, not several times.

Predict before you run: you hold jump continuously from mid-air through landing. Do you jump on landing? Reason it out from the code first — the answer depends on whether the buffer is set by just-pressed or pressed, and this is exactly the choice that decides whether holding the button auto-bounces.

D — Find your own numbers#

Set both timers to 0.0. Play for a minute. Set them back to 0.1 and 0.08. Play again.

The difference is much larger than 100 ms has any right to be, and this is the most useful minute in the whole project. Then try 0.3 — now it's too forgiving, jumps fire when you didn't mean them, and you can feel where the boundary sits.

E — Commit#

git add . && git commit -m "stage 03: coyote time and jump buffering"

Checkpoint — definition of done#

  • You can jump shortly after walking off a ledge
  • You cannot double jump by exploiting coyote time
  • A jump pressed just before landing fires on landing
  • Mashing jump through a long fall produces exactly one jump on landing
  • Setting both timers to zero makes the game feel noticeably worse
  • Zero warnings; committed
  • You can explain: why must _try_jump() consume both timers, and what specifically appears if it consumes only one?

Stretch (no instructions)#

Add a debug overlay — a Label in a CanvasLayer — showing both timers live, plus is_on_floor(). Then use it to measure your coyote window: walk off, count the frames before the jump stops working, and confirm it matches the number you set. Being able to verify timing claims rather than trust them is a skill this whole project depends on.

If you get stuck#

  • Double jumping → _coyote_timer isn't zeroed when a jump happens.
  • Coyote never works → is the timer refilled every frame you're grounded, or only on the frame you land? It should be every frame.
  • Buffer feels sticky, auto-jumps while held → you're setting the buffer from is_action_pressed rather than is_action_just_pressed.
  • Sometimes a press is dropped entirely → is the press recorded in _physics_process or _process? If they run at different rates, one of them can observe a press the other misses. Pick one place and be consistent.

Next: Stage 04 — a room to fall in. The character is done for a while; now it gets somewhere to be.