Tutorial Tuesday

Tutorials · By Game Design Jar ·

Coyote Time and Jump Buffering in Godot 4 (With Full Code)

Two small input tricks that make Godot 4 platformer jumps feel fair instead of frustrating. Complete CharacterBody2D script, tuning values, and common pitfalls.

If playtesters keep saying your platformer jump feels “off” but nobody can explain why, the problem usually is not your jump height or gravity. It is that the game is judging input more strictly than a human can play.

Two small forgiveness windows fix most of it: coyote time and jump buffering. This tutorial adds both to a Godot 4 CharacterBody2D, start to finish.

Engine: Godot 4.2+ (GDScript 2.0). Prerequisites: a scene with a CharacterBody2D, a CollisionShape2D, and an input action named jump in Project Settings → Input Map.

What these two techniques actually do

Coyote time gives the player a few frames to still jump after walking off a ledge. Named after the cartoon coyote who hangs in the air before falling, it covers the extremely common case where a player presses jump one or two frames late.

Jump buffering is the mirror image: if the player presses jump slightly before landing, the game remembers the press and fires the jump the moment they touch the ground, instead of silently dropping the input.

Neither makes the game easier in any way a player notices. They make it stop punishing timing that felt correct.

Step 1: Start from a standard movement script

Here is the baseline before any forgiveness — a normal Godot 4 character controller:

extends CharacterBody2D

@export var speed: float = 300.0
@export var jump_velocity: float = -400.0

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

func _physics_process(delta: float) -> void:
    if not is_on_floor():
        velocity.y += gravity * delta

    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_velocity

    var direction := Input.get_axis("move_left", "move_right")
    velocity.x = direction * speed

    move_and_slide()

The strictness lives in one line: Input.is_action_just_pressed("jump") and is_on_floor(). Both conditions must be true on the same physics frame. Miss by a frame and nothing happens.

Step 2: Add coyote time

Track how long it has been since the character was last on the floor, and treat “recently on the floor” as jumpable.

@export var coyote_time: float = 0.1

var _coyote_timer: float = 0.0

func _physics_process(delta: float) -> void:
    if not is_on_floor():
        velocity.y += gravity * delta
        _coyote_timer -= delta
    else:
        _coyote_timer = coyote_time

    if Input.is_action_just_pressed("jump") and _coyote_timer > 0.0:
        velocity.y = jump_velocity
        _coyote_timer = 0.0   # consume it, so you cannot double jump

    move_and_slide()

Resetting _coyote_timer to 0.0 immediately after jumping matters. Without it, a player who jumps on frame one still has coyote time left over and can jump again mid-air.

Step 3: Add jump buffering

Same idea in reverse. Record the moment jump was pressed, then check that record when landing.

@export var jump_buffer_time: float = 0.1

var _jump_buffer_timer: float = 0.0

func _physics_process(delta: float) -> void:
    if Input.is_action_just_pressed("jump"):
        _jump_buffer_timer = jump_buffer_time
    else:
        _jump_buffer_timer -= delta

Then the jump fires whenever both a buffered press and a valid ground state exist.

Step 4: The complete script

extends CharacterBody2D

@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
@export var coyote_time: float = 0.1
@export var jump_buffer_time: float = 0.1

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

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

func _physics_process(delta: float) -> void:
    # Gravity and coyote window
    if not is_on_floor():
        velocity.y += gravity * delta
        _coyote_timer -= delta
    else:
        _coyote_timer = coyote_time

    # Input buffer
    if Input.is_action_just_pressed("jump"):
        _jump_buffer_timer = jump_buffer_time
    else:
        _jump_buffer_timer -= delta

    # Fire the jump when a buffered press meets a valid ground state
    if _jump_buffer_timer > 0.0 and _coyote_timer > 0.0:
        velocity.y = jump_velocity
        _jump_buffer_timer = 0.0
        _coyote_timer = 0.0

    # Variable jump height: release early, rise less
    if Input.is_action_just_released("jump") and velocity.y < 0.0:
        velocity.y *= 0.5

    var direction := Input.get_axis("move_left", "move_right")
    velocity.x = direction * speed

    move_and_slide()

That last block is a free bonus. Cutting upward velocity when the player releases jump early gives you variable jump height, which pairs well with the two forgiveness windows.

Tuning values that work

Start at 0.1 seconds for both — roughly six frames at 60 FPS. From there:

  • Tight, precise platformers: 0.060.08. Enough to remove unfair deaths without making the character feel floaty.
  • Casual or younger audiences: 0.120.15. Noticeably forgiving, still not obviously “wrong”.
  • Above about 0.2: players start to feel the character hanging in the air. If you want that, commit to it as a design choice rather than a forgiveness window.

Because both values are @exported, you can tune them in the inspector while the game runs instead of editing code.

Common pitfalls

Forgetting to consume the timers. The single most common bug. If you do not zero both timers when the jump fires, one press can trigger multiple jumps.

Putting input in _process instead of _physics_process. Mixing frame-rate-dependent input checks with physics-rate movement causes dropped presses at some frame rates. Keep it all in _physics_process.

Applying coyote time after a jump. Coyote time should only cover walking off a ledge. Zeroing the timer on jump handles this correctly.

Testing only at 60 FPS. Because everything is delta-scaled, it should hold up — but run the game at 30 and 144 FPS before you trust it.

Try this in your own project

Drop the complete script onto a CharacterBody2D, then deliberately play badly: press jump a hair late off ledges, and a hair early while falling. Both should now do what you meant. Turn both values to 0.0 in the inspector and try again — the difference is much larger than the six frames suggest.

godotplatformergame feelgdscript