// personal reference · hang & climb sideways · Tomb Raider style · corner handling
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.
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".
// 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).
These are the situations the leading hand can run into while shimmying right. Each maps to one outcome.
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.
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.
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.
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.
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.
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.
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 name | origin | direction | fires | answers |
|---|---|---|---|---|---|
| 1 | SIDE | chest height | sideways (+right), length ≈ 1 step | every step | is my sideways path blocked? (Case 2) |
| 2 | WALL | chest, offset sideways 1 step | forward (into wall) | every step | is there still wall to hug? |
| 3 | LEDGE | above head, offset sideways 1 step + forward | down | every step | is there still a top edge to grip? |
| 4a | INNER-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) |
| 4b | INNER-B | from 4a hit point | sideways then down | only if 4a hits | |
| 5a | OUTER-A | chest, past where face ended | sideways (+right) | only if wall=NO | does a new outer face exist? (Case 5 vs dead-end) |
| 5b | OUTER-B | from 5a end point | forward (back toward block) | only if side path clear |
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
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.
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 limit | meaning | action |
|---|---|---|
| slope ≤ MAX_DIAGONAL | gentle diagonal | CONTINUE — shimmy across AND adjust char Y by step_dy |
| slope > MAX_DIAGONAL | too steep — edge drops/rises sharply | treat as BLOCKED → CASE 2/3 (stop) |
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.
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.
# 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
The two core booleans give you four buckets. The corner probe only fires to disambiguate the two "mixed" rows.
| wall ahead | ledge ahead | corner probe | outcome |
|---|---|---|---|
| YES | YES | — | CONTINUE — normal shimmy |
| YES | NO | inner check | INNER_CORNER (case 4, hole) if edge turns in, else BLOCKED (cut) |
| NO | YES | outer check | OUTER_CORNER (case 5, box) if new face, else BLOCKED |
| NO | NO | — | BLOCKED — 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
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.