Same jump as lesson 2, but it stops feeling broken. Coyote time, jump buffering and variable height. Flip them off to feel why they exist.
Loading the game engine. This takes a moment the first time.
Every other lesson gives you something you could not do before. This one takes a jump you already have and makes it stop feeling broken. That is most of what game "polish" actually is, and it is worth knowing that the difference between a game that feels cheap and one that feels good is usually about fifteen lines like these.
You ran off the ledge and pressed jump a few hundredths of a second later. The strict rule says you must be touching the floor, and you were not any more. Coyote time keeps the jump available for a moment after you leave the ground. Players never notice it is there. They only notice when it is missing.
You pressed just BEFORE touching down. Same rule, opposite side. Buffering remembers the press for a moment and fires it on landing.
Holding and tapping did the same thing, so you could never make a small hop. Letting go early now cuts the jump short.
Press the button to turn forgiveness OFF, then try the course below. The two brown platforms with the gap are built to catch you out: run off the left one and jump late. With forgiveness ON you make it. With it OFF you drop in the pit, and it feels like the game ignored you, even though the game did exactly what you told it to.
■ UI node · ■ 2D node · % unique name · .tscn instanced scene
extends Node2D
@onready var player: CharacterBody2D = %JumpPlayer
func _ready() -> void:
%HUD.forgiving_toggled.connect(_on_forgiving_toggled)
# The player starts forgiving. The HUD's button label starts matching it.
player.forgiving = true
func _on_forgiving_toggled(enabled: bool) -> void:
player.forgiving = enabledextends CanvasLayer
signal forgiving_toggled(enabled: bool)
@onready var toggle_button: Button = %ForgivingButton
@onready var explain_label: Label = %ExplainLabel
var _forgiving: bool = true
func _ready() -> void:
toggle_button.pressed.connect(_on_toggle_pressed)
%HomeButton.pressed.connect(_on_home_pressed)
_refresh()
func _on_toggle_pressed() -> void:
_forgiving = not _forgiving
forgiving_toggled.emit(_forgiving)
_refresh()
func _refresh() -> void:
if _forgiving:
toggle_button.text = "Forgiveness: ON"
explain_label.text = "Coyote time, jump buffering and variable height are all on."
else:
toggle_button.text = "Forgiveness: OFF"
explain_label.text = "Strict jump. You must be touching the floor on the exact frame you press."
# Loads the lesson list screen, which replaces this whole scene.
func _on_home_pressed() -> void:
get_tree().change_scene_to_file("res://shared/ui/lesson_select.tscn")extends CharacterBody2D
@export var speed: float = 240.0
@export var jump_velocity: float = -620.0
@export var gravity: float = 1800.0
## FIX 1: how long after walking off a ledge you can still jump.
@export var coyote_time: float = 0.12
## FIX 2: how early you can press jump before landing and still get it.
@export var jump_buffer_time: float = 0.12
## FIX 3: how much of your upward speed is left if you tap instead of hold.
## 1.0 means tapping does nothing. 0.0 means tapping stops you dead.
@export var tap_jump_cut: float = 0.4
# Flipped by the button in the HUD. jump_feel.gd sets it.
var forgiving: bool = true
# These two count DOWN in seconds. Above zero means "still allowed".
var _coyote_left: float = 0.0
var _buffer_left: float = 0.0
func _physics_process(delta: float) -> void:
_apply_gravity(delta)
_tick_timers(delta)
_try_jump()
_cut_jump_if_released()
velocity.x = Input.get_axis("move_left", "move_right") * speed
move_and_slide()
# The warrior picture only faces right. Walking left mirrors it.
if velocity.x != 0.0:
$Sprite2D.flip_h = velocity.x < 0.0
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
func _tick_timers(delta: float) -> void:
# COYOTE. Standing on the ground keeps the timer topped up. The moment you
# step off a ledge it starts draining, and for those few hundredths of a
# second you can still jump. Named after the cartoon coyote who keeps
# running past the cliff edge before he notices.
if is_on_floor():
_coyote_left = coyote_time
else:
_coyote_left -= delta
# BUFFER. Pressing jump does not jump straight away. It starts this timer.
# If you land while it is still running, the jump comes out. That is what
# rescues a press made a fraction of a second too early.
if Input.is_action_just_pressed("jump"):
_buffer_left = jump_buffer_time
else:
_buffer_left -= delta
func _try_jump() -> void:
var wants_jump: bool
var can_jump: bool
if forgiving:
wants_jump = _buffer_left > 0.0 # pressed recently
can_jump = _coyote_left > 0.0 # on the ground recently
else:
# The strict version, exactly as Platformer Movement writes it:
# the press and the landing have to happen on the SAME frame.
wants_jump = Input.is_action_just_pressed("jump")
can_jump = is_on_floor()
if wants_jump and can_jump:
velocity.y = jump_velocity
# Spend both timers so one press can never become two jumps.
_coyote_left = 0.0
_buffer_left = 0.0
# FIX 3: VARIABLE HEIGHT. Let go early while still going up and we throw most
# of the remaining upward speed away, so a tap is a hop and a hold is a leap.
# "velocity.y < 0.0" means still rising: in Godot, up is negative.
func _cut_jump_if_released() -> void:
if not forgiving:
return
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= tap_jump_cut