Godot 4 · 3D · GDScript · Game Systems

Ledge Shimmy
System Concept

// personal reference · hang & climb sideways · Tomb Raider style · corner handling

01 What is Ledge Shimmy

After the player climbs up and grabs a ledge, they hang from it and can move sideways — left or right — hand over hand along the edge. This is the classic Tomb Raider / Uncharted "shimmy". The hard part is not the sideways movement itself; it is deciding, every step, whether the player can keep going and what happens when the ledge changes shape.

Core Idea Every frame you shimmy, cast probes ahead of the leading hand and ask two yes/no questions: is there still a wall to hug, and is there still a ledge to grip? The combination of those two answers — plus a corner check — selects the outcome.
Reuses What You Built This is the same "probe → measure → decide" pattern as the stair step-up. The down-cast that keeps you gripping the edge as you slide is the same idea as the stair snap re-finding the floor each frame.

02 The Two Core Probes

Both probes are offset sideways in the shimmy direction — they ask "what is at the spot I am about to move into", not "where I am now".

WALL probe ray forward (into wall), offset sideways "is there wall to hug?"
LEDGE probe ray down from above, offset sideways + forward "is there a top edge to grip?"
wall probe ledge probe corner probe blocked / no hit solid geometry

// All diagrams below are top-down views. Grey = solid wall the player hangs on. The dot is the player; the arrow is the shimmy direction (right).

03 The Five Cases

These are the situations the leading hand can run into while shimmying right. Each maps to one outcome.

01 NORMAL — keep going
wall = YES   ledge = YES
SOLID WALL (ledge edge continues) player both hit ✓
02 WALL BLOCKS — stop
wall = YES (jutting out)   ledge = YES but no room
BLOCK side blocked ✗
03 CUT — gap, stop
wall = YES   ledge = NO (edge ended)
no top edge ledge gone ✗
04 INNER — square HOLE
wall = YES   ledge = NO ahead   → turns into corner
SOLID (hole cut into it) wall ✓ edge turns down → rotate in
05 OUTER CORNER — square BOX (wrap around outside, 270°)
wall = NO straight ahead (face ended)   → block's next outer face found, wrap around
SOLID BLOCK (freestanding) open air ✗ face ended here new outer face found → wrap around corner
Cases 2 & 3 Both = "Stop" From the player's view they feel different (blocked by a side wall vs reaching a gap), but mechanically both end the shimmy in that direction. You can give them the same "reach and stop" animation, or two different ones for polish.

04 Corners: Inner vs Outer

The corners are the only cases that need a second probe in sequence. A single ray cannot see "around a corner" — you cast once to the edge, then cast again from that new point in a perpendicular direction.

// Case 4 — Inner corner (concave · inside a square HOLE)

The player hangs inside a hole cut into the wall. Shimmying along one inner face, they reach the inside corner of the hole, and the edge turns to follow the perpendicular inner face. The wall stays continuous (it wraps the inside of the hole), so straight ahead reads wall = YES, but the ledge no longer continues straight. Cast forward into the corner, then sideways along the new inner face. The player rotates into the corner and keeps shimmying.

// Case 5 — Outer corner (convex · around a square BOX)

The player hangs on the outside of a freestanding block. Shimmying along one face, they reach the block's corner — the face simply ends, so straight ahead reads wall = NO. Cast sideways past where the face stopped, then forward; you find the block's next outer face. The player wraps around the outside corner onto that adjacent face.

Probe Order Matters Inner/hole (case 4) = forward-then-sideways. Outer/box (case 5) = sideways-then-forward. Mixing them up is the #1 reason a corner system "sometimes turns the wrong way".

05 Disambiguation — the tricky pairs

Some cases give the same reading on the two core probes, so the core probes alone cannot tell them apart. A second probe (the corner check) is what actually picks within each pair. This is the single most important part to get right — a system that skips the second probe will "sometimes turn, sometimes stop" because it is guessing on insufficient information.

Key Realisation The core probes narrow the situation down to a pair. The corner / side probe is what resolves which of the pair it actually is.

// Fork A — Case 3 (cut) vs Case 4 (inner / hole)

Both read wall = YES, ledge = NO straight ahead. Identical on the core probes. The tie-breaker is the inner corner probe: cast forward into the corner, then sideways along the new perpendicular inner face, then down to look for a ledge top there.

FORK A   wall=YES, ledge=NO   → run inner corner probe
left: probe finds ledge on side face (CASE 4)  ·  right: probe finds nothing (CASE 3)
ledge found → CASE 4 HOLE no top edge nothing → CASE 3

// Fork B — Case 2 (wall blocks) vs Case 5 (outer / box) vs dead-end

Here the first thing to test is whether your sideways path is physically blocked. That single side probe separates Case 2 from everything else before the core probes even run.

FORK B   → run side probe first, then outer corner probe
left: side path blocked (CASE 2)  ·  right: side clear, outer face found (CASE 5)
BLOCK side blocked → CASE 2 BOX air face ends new face → CASE 5

06 Ray Inventory — how many, where, why

You need 5 ray casts total to handle all cases, but they are not all fired every frame. Three run every shimmy step; the corner pair only fires to break a tie. Directions assume shimmying right — mirror the sideways offset for left.

#ray nameorigindirectionfiresanswers
1SIDE chest height sideways (+right), length ≈ 1 step every step is my sideways path blocked? (Case 2)
2WALL chest, offset sideways 1 step forward (into wall) every step is there still wall to hug?
3LEDGE above head, offset sideways 1 step + forward down every step is there still a top edge to grip?
4aINNER-A chest, offset sideways 1 step forward (into corner) only if wall=YES, ledge=NO does the ledge turn INTO the corner? (Case 4 vs 3)
4bINNER-B from 4a hit point sideways then down only if 4a hits
5aOUTER-A chest, past where face ended sideways (+right) only if wall=NO does a new outer face exist? (Case 5 vs dead-end)
5bOUTER-B from 5a end point forward (back toward block) only if side path clear
Count Summary 3 rays always (SIDE, WALL, LEDGE). The inner check adds 2 (INNER-A + INNER-B). The outer check adds 2 (OUTER-A + OUTER-B). Because inner and outer checks are mutually exclusive (one needs wall=YES, the other wall=NO), the worst case in any single frame is 5 rays, never 7.
Why corners take 2 rays each A single ray travels in a straight line and cannot "see around" a corner. The first ray reaches the edge; the second fires perpendicular from that point to test what continues around it. Inner = forward-then-sideways/down. Outer = sideways-then-forward.
func classify_shimmy(dir):
    # --- 3 core rays, every step ---
    if side_probe(dir):              # ray 1
        return CASE_2_WALL_BLOCKS       # side path blocked

    var wall  = wall_probe(dir)      # ray 2
    var ledge = ledge_probe(dir)     # ray 3

    if wall and ledge:
        return CASE_1_CONTINUE

    # --- wall ahead, no ledge: cut OR inner corner (rays 4a, 4b) ---
    if wall and not ledge:
        if inner_corner_probe(dir):
            return CASE_4_INNER         # hole
        return CASE_3_CUT

    # --- no wall ahead: outer corner OR dead end (rays 5a, 5b) ---
    if outer_corner_probe(dir):
        return CASE_5_OUTER             # box
    return DEAD_END

07 Diagonal Ledges SWITCHABLE

Real ledges are rarely perfectly level — they drift up or down a little as you shimmy. We do not need a new ray for this. The down-facing LEDGE probe (ray 3) already reports the height of the edge ahead. Comparing that height to the player's current grip height tells us the slope of the next step.

Core Idea The LEDGE probe's hit Y is reused for two jobs: (1) presence — is there an edge at all; (2) slope — how far up/down is it versus where I grip now. No extra cast.

// The slope measurement

Each step, take the vertical difference between the new edge height and the current grip height:

var step_dy = ledge_hit.position.y - current_grip_y
var slope = abs(step_dy)
slope vs limitmeaningaction
slope ≤ MAX_DIAGONALgentle diagonalCONTINUE — shimmy across AND adjust char Y by step_dy
slope > MAX_DIAGONALtoo steep — edge drops/rises sharplytreat as BLOCKED → CASE 2/3 (stop)
Don't Forget The Y Update A diagonal shimmy moves the character sideways and vertically. If you only move sideways, the hands detach from the rising/falling edge. Apply step_dy to the character's Y every step so the grip tracks the real edge — the same "re-grip each frame" idea as the stair snap.

// Why "too steep" collapses into Case 2/3

An extreme diagonal is, in practice, the ledge ending or turning into a wall — there is no longer a sensible horizontal edge to hand-over-hand along. Rather than inventing a sixth case, we fold it into the existing "stop" outcomes: it reads like a cut (Case 3) or a block (Case 2) and uses the same reach-and-stop animation.

SIDE VIEW — shimmying right, edge drifts down small dy ✓ CONTINUE + shift Y dy > limit ✗ too steep → STOP (case 2/3)
# Max vertical change per shimmy step before the ledge counts as
# "too steep" and we stop. Tune to taste. (metres)
@export var MAX_DIAGONAL: float = 0.25

08 Decision Truth Table

The two core booleans give you four buckets. The corner probe only fires to disambiguate the two "mixed" rows.

wall aheadledge aheadcorner probeoutcome
YESYESCONTINUE — normal shimmy
YESNOinner checkINNER_CORNER (case 4, hole) if edge turns in, else BLOCKED (cut)
NOYESouter checkOUTER_CORNER (case 5, box) if new face, else BLOCKED
NONOBLOCKED — end of ledge
func check_shimmy(dir):   # dir = +1 right, -1 left
    var wall  = probe_wall(dir)    # ray forward, offset sideways
    var ledge = probe_ledge(dir)   # ray down, offset sideways

    if wall and ledge:
        return CONTINUE

    if wall and not ledge:
        if probe_inner_corner(dir):
            return INNER_CORNER      # case 4 (hole)
        return BLOCKED                  # case 3 (cut)

    if not wall and ledge:
        if probe_outer_corner(dir):
            return OUTER_CORNER      # case 5 (box)
        return BLOCKED

    return BLOCKED                      # case 2

09 Outcomes & Animation

Each outcome drives both movement and a clip. The two corners move in a complex way (rotate + translate around the edge) and look best with root motion; CONTINUE and BLOCKED can be driven by position directly.

CONTINUE move sideways one step shimmy_right / shimmy_left loop
BLOCKED stop, hold position hang_idle / reach-and-stop
OUTER_CORNER rotate ~90° out, reposition onto new face corner_out root motion
INNER_CORNER rotate ~90° in, reposition into corner corner_in root motion
Why Root Motion for Corners The hand contact point must land on the actual new edge. If you lerp position manually, the hands float or clip. Letting the corner animation carry the body (root motion) keeps the grip looking planted, the same reason pro parkour uses it.

10 Implementation Notes

Build Order Suggestion 1) straight shimmy + BLOCKED only (cases 1–3). 2) add outer corner. 3) add inner corner last — it is the fiddliest. Each step is testable on its own.