Quick answer: is_on_floor() returns false the very first frame after walking off a ledge, so a jump pressed at a ledge edge silently fails. Fix this with coyote time: track how long ago is_on_floor() was last true (typically 0.10–0.15 s), and allow a jump within that window. Immediately reset the timer to zero when a jump is consumed so coyote time can’t be exploited as a double-jump. Add jump buffering (0.10–0.15 s window before landing) for maximum responsiveness.

Players rarely know what coyote time is by name, but they feel its absence immediately: jumps that seem to “miss” at ledge edges, a character that occasionally refuses to jump for no apparent reason, a platformer that feels vaguely slippery despite correct velocity code. These reports look like bugs — and from the player’s perspective they are — but the underlying cause is a collision detection timing issue, and the fix is a well-known game feel technique borrowed from classic platformers of the 1980s and 1990s.

Why is_on_floor() Fails at Ledge Edges

Godot’s CharacterBody2D.move_and_slide() updates the is_on_floor() state each physics frame based on the collision results of that frame’s movement. The frame the character walks off a ledge, the character’s physics body is no longer in contact with a floor surface, so is_on_floor() returns false immediately.

At 60 FPS, a physics frame is approximately 16.7 milliseconds. Human reaction time to a visual stimulus is typically 150–300 ms. A player who sees a ledge and presses jump “in time” is pressing it within their perceptual window — but the physics engine has already moved the character off the ledge one or two frames before the input event arrives. The jump check fails, nothing happens, and the player experiences it as the game not responding.

This is not a Godot limitation; it’s a fundamental consequence of discrete physics simulation. Every well-regarded 2D platformer — Celeste, Hollow Knight, Shovel Knight — implements coyote time to paper over this gap.

Implementing Coyote Time with a Float Timer

The implementation is straightforward: maintain a coyote_time_left float. Every physics frame where is_on_floor() is true, reset it to the coyote duration. When airborne, decrement it by delta. Allow a jump if coyote_time_left > 0 rather than if is_on_floor(). Crucially, reset coyote_time_left to 0 the moment a jump is consumed — otherwise the player can trigger a second jump using the remaining coyote window.

# CharacterBody2D movement with coyote time
extends CharacterBody2D

const SPEED        = 200.0
const JUMP_VELOCITY = -420.0
const COYOTE_TIME  = 0.12  # seconds

var coyote_time_left: float = 0.0
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta: float) -> void:
    # Update coyote timer
    if is_on_floor():
        coyote_time_left = COYOTE_TIME
    else:
        coyote_time_left -= delta

    # Apply gravity when airborne
    if not is_on_floor():
        velocity.y += gravity * delta

    # Jump: allow if coyote window is open
    if Input.is_action_just_pressed("jump") and coyote_time_left > 0.0:
        velocity.y = JUMP_VELOCITY
        coyote_time_left = 0.0  # consume the coyote window immediately

    # Horizontal movement
    var direction = Input.get_axis("move_left", "move_right")
    velocity.x = direction * SPEED

    move_and_slide()

The key line is coyote_time_left = 0.0 immediately after the jump. Without it, consider this scenario: the player walks off a ledge (coyote timer starts counting down), jumps mid-air (uses one jump), and then before the timer reaches zero, presses jump again — the second press would find coyote_time_left > 0 still and grant a free mid-air jump. Resetting to zero on jump consumption prevents this entirely.

Adding Jump Buffering

Jump buffering is the complementary technique. While coyote time forgives a jump pressed fractionally too late (after leaving the floor), jump buffering forgives a jump pressed fractionally too early (before landing). The player presses jump while still airborne; the press is queued for a short window; if the character lands within that window, the jump fires immediately.

# Add to the same CharacterBody2D script
const JUMP_BUFFER_TIME = 0.12  # seconds
var jump_buffer_left: float = 0.0

func _physics_process(delta: float) -> void:
    # Coyote time (same as before)
    if is_on_floor():
        coyote_time_left = COYOTE_TIME
    else:
        coyote_time_left -= delta

    # Jump buffer: start countdown on press, even if airborne
    if Input.is_action_just_pressed("jump"):
        jump_buffer_left = JUMP_BUFFER_TIME
    else:
        jump_buffer_left -= delta

    # Gravity
    if not is_on_floor():
        velocity.y += gravity * delta

    # Execute jump if both conditions are met
    if jump_buffer_left > 0.0 and coyote_time_left > 0.0:
        velocity.y = JUMP_VELOCITY
        coyote_time_left = 0.0   # consume coyote window
        jump_buffer_left = 0.0   # consume buffer

    var direction = Input.get_axis("move_left", "move_right")
    velocity.x = direction * SPEED
    move_and_slide()

By combining both counters in a single condition — jump_buffer_left > 0 and coyote_time_left > 0 — the code elegantly handles all four cases: jump pressed while on floor (both immediate), jump pressed just after leaving ledge (coyote covers it), jump pressed just before landing (buffer covers it), and jump pressed while genuinely mid-air (neither counter active, jump correctly refused).

Tuning the Timer Values

The “correct” coyote and buffer durations depend on your game’s feel. For a fast-paced action platformer (think Celeste-style), 0.08–0.10 seconds feels tight but fair. For a slower, more casual platformer, 0.12–0.15 seconds is more forgiving. Values above 0.15–0.20 seconds start to feel exploitable — players can clearly walk off a ledge and jump from mid-air in a way that feels like cheating rather than polish.

Export the constants as @export variables so you can tune them in the Godot Editor Inspector without recompiling:

@export var coyote_duration: float = 0.12
@export var jump_buffer_duration: float = 0.12

# Replace the const references with these variables
# Then adjust live in the Inspector during a play session

Playtest with actual players, not just developers. Developers have a different tolerance for input imprecision because they understand why misses happen; players experience only the miss.

Common Pitfalls and Edge Cases

The most important pitfall is already covered above — not resetting coyote time on jump, enabling infinite mid-air jumps. But there are two more worth calling out:

Resetting coyote time when falling off vs. jumping off: If you jump normally from the floor, is_on_floor() goes false the very next frame (the character is now moving upward). At that point the coyote timer starts counting down from COYOTE_TIME. This means if the player presses jump again within 0.12 seconds of leaving the floor by jumping, they’ll get a second jump. The fix is to reset coyote_time_left = 0.0 whenever a jump is consumed, whether via buffer or direct press — which the code above already does.

One-way platforms and ceiling collisions: If your character can collide with a ceiling or pass through one-way platforms, check that is_on_floor() is not briefly returning true from an unintended collision. Use the Remote inspector during play to observe is_on_floor() and coyote_time_left in real time to confirm they transition as expected. Godot’s is_on_floor() only counts surfaces within the floor_max_angle (default 45 degrees), so steep slopes won’t accidentally grant coyote time.

“Coyote time is the difference between a platformer that players call tight and one they call slippery — and it’s twelve lines of code. There’s no excuse to ship without it.”

If players are reporting that your jumps “sometimes don’t work,” coyote time and jump buffering will fix 90% of those reports overnight — add them before you spend time looking for any other cause.