Ledge grab and climb
const LEDGE_CHECK_DISTANCE = 14.0 // forward reach
const CHEST_HEIGHT_OFFSET = -8.0 // relative to collider center, chest probe
const HEAD_HEIGHT_OFFSET = -20.0 // relative to collider center, head probe
const CLIMB_DURATION = 0.30 // seconds, kinematic tween up onto the ledge
const HANG_OFFSET = Vector2(-2.0, 4.0) // hang pose relative to ledge corner
enum LedgeState { NONE, HANGING, CLIMBING }
state ledge_state = LedgeState.NONE
state ledge_point = null // world-space position of the detected ledge corner
state climb_timer = 0.0
state climb_start_pos = Vector2(0, 0)
state climb_end_pos = Vector2(0, 0)
function check_ledge_grab_conditions():
if is_grounded or ledge_state != LedgeState.NONE:
return null
if velocity.y <= 0: // only grab on the way down
return null
if get_input_direction() != facing_direction: // must be pressing into the wall
return null
chest_hit = cast_forward(offset_y = CHEST_HEIGHT_OFFSET,
distance = LEDGE_CHECK_DISTANCE, dir = facing_direction)
head_hit = cast_forward(offset_y = HEAD_HEIGHT_OFFSET,
distance = LEDGE_CHECK_DISTANCE, dir = facing_direction)
if chest_hit == null or head_hit != null:
return null // no wall at chest height, or wall extends above head — not a ledge
// Found a wall at chest height with clearance above it — find the surface on top.
top_hit = cast_down(origin = chest_hit.point + Vector2(facing_direction * 4.0, -40.0),
distance = 60.0)
if top_hit == null:
return null
return top_hit.point // the ledge corner
function enter_ledge_hang(point):
ledge_state = LedgeState.HANGING
ledge_point = point
velocity = Vector2(0, 0)
position = ledge_point + HANG_OFFSET
// Gravity and normal collision integration are suspended while hanging —
// this state is fully kinematic, not physics-driven.
function update_ledge_hang(dt):
if input.was_pressed("jump") or input.is_down("move_up"):
start_climb()
elif input.was_pressed("move_down"):
ledge_state = LedgeState.NONE // drop off
function start_climb():
ledge_state = LedgeState.CLIMBING
climb_timer = 0.0
climb_start_pos = position
climb_end_pos = ledge_point + Vector2(facing_direction * 12.0, -16.0) // standing atop
function update_climb(dt):
climb_timer += dt
t = clamp(climb_timer / CLIMB_DURATION, 0.0, 1.0)
position = lerp(climb_start_pos, climb_end_pos, ease_out(t))
if t >= 1.0:
finish_climb()
function finish_climb():
ledge_state = LedgeState.NONE
position = climb_end_pos
velocity = Vector2(0, 0)
is_grounded = true
function update(dt):
if ledge_state == LedgeState.NONE:
point = check_ledge_grab_conditions()
if point != null:
enter_ledge_hang(point)
else:
// normal movement/jump/gravity update from earlier sections
apply_horizontal_acceleration(dt)
handle_jump_input()
apply_gravity(dt)
integrate_position(dt)
integrate_vertical(dt)
elif ledge_state == LedgeState.HANGING:
update_ledge_hang(dt)
elif ledge_state == LedgeState.CLIMBING:
update_climb(dt)
How it works¶
Detecting a grabbable ledge is a two-probe check, not one: a forward cast at chest height must hit something solid (there's a wall in front of you), while a forward cast at head height must hit nothing (the wall doesn't extend above your head). Only when both conditions hold do you know you're looking at a ledge rather than a tall wall you should just bonk into. A third cast, downward from just above the head probe's contact, finds the actual top surface of the ledge — that point is what everything else (hang position, climb target) is anchored to.
A few conditions gate the check besides the Raycasting themselves: only while airborne (you can't grab a ledge you're standing on), generally only while falling (velocity.y > 0 — grabbing mid-upward-jump usually looks and feels wrong), and only while the player is holding input into the wall, so ledges near a path the player is just passing don't grab unintentionally.
Once a grab is confirmed, the character enters a state that's explicitly outside the normal physics loop — no gravity, no collision integration, position and velocity are just held or scripted. This is the standard approach because simulating a believable climb-up motion with real physics is hard to make look good; instead, update_climb is a straightforward position tween (lerp with an ease-out curve) from the hang pose to a landing pose slightly up and forward of the ledge corner, over a fixed CLIMB_DURATION. The hang state also branches to a "drop off" transition (back to normal falling) so grabbing a ledge doesn't have to commit the player to climbing it.