Jump gravity and coyote time
const GRAVITY = 1200.0 // units/sec^2
const MAX_FALL_SPEED = 900.0 // units/sec, terminal velocity
const JUMP_VELOCITY = 480.0 // units/sec, upward impulse magnitude
const COYOTE_TIME = 0.10 // seconds
state is_grounded = false
state coyote_timer = 0.0
function check_grounded():
// Shapecast/raycast a small distance downward from the collider's feet.
return cast_down(distance = 2.0) != null
function update_coyote_timer(dt):
if is_grounded:
coyote_timer = COYOTE_TIME
else:
coyote_timer = max(0.0, coyote_timer - dt)
function handle_jump_input():
if input.was_pressed("jump") and coyote_timer > 0.0:
velocity.y = -JUMP_VELOCITY
coyote_timer = 0.0 // consume it — no double jumping off the same fall
is_grounded = false
function apply_gravity(dt):
velocity.y = min(velocity.y + GRAVITY * dt, MAX_FALL_SPEED)
function integrate_vertical(dt):
position.y += velocity.y * dt
if resolve_collision(axis = "y"):
if velocity.y > 0:
is_grounded = true
velocity.y = 0
function update(dt):
is_grounded = check_grounded()
update_coyote_timer(dt)
apply_horizontal_acceleration(dt) // from section 1
handle_jump_input()
apply_gravity(dt)
integrate_position(dt) // horizontal, from section 1
integrate_vertical(dt)
How it works¶
Gravity - apply_gravity runs every frame regardless of grounded state; the collision resolution in integrate_vertical is what actually zeroes it out and sets is_grounded back to true.
The detail that matters most here is consuming the timer (coyote_timer = 0.0) the moment it's used. Without that, a player who jumps, then falls for COYOTE_TIME seconds without touching ground, could jump again off nothing, because the timer hasn't been explicitly invalidated — only decremented. Setting it to zero on use closes that hole and keeps coyote time feeling like forgiveness rather than a free extra jump.
Update-order matters too: grounded state and the coyote timer must be refreshed before you read jump input for the frame, otherwise you're checking against last frame's grounded state.