Skip to content

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.