doc 9 of 10

Stage 07 — Walls

You will build: wall sliding and wall jumping, and — if you choose it — stamina-based climbing.

You'll learn: detecting a wall you aren't quite touching · neutral versus directional wall jumps · forcing input away from a wall · design decision 4 comes due.

Warm-up — from memory:

  1. From stage 06: what two conditions must both hold for dash speed retention to apply?
  2. From stage 03: what does "consuming" the coyote timer prevent?
  3. What's the difference between a collision layer and a collision mask?

Decision 4, first#

Do you build climbing?

Wall slide plus wall jump is roughly a third of the work and gives you most of what walls offer: vertical traversal, a way to recover from a missed jump, shafts as level geometry.

Climbing adds grab, climb up and down, a stamina meter that drains, an exhausted state, and climb jumps that cost stamina — a system about the size of the dash, with its own tuning pass.

Dropping it is a legitimate scope decision. Decide now, write it in the architecture document with your reason, and don't change your mind halfway through the stage. Sections A–C are for everyone; section D is climbing.

Why this exists#

Walls are where a platformer's collision code gets tested honestly. Two problems appear immediately:

The wall you're "on" isn't the wall you're touching. is_on_wall() is only true after move_and_slide() has actually collided. Push away from a wall for one frame and you're no longer on it — but the player still thinks they are, and expects a wall jump. Celeste checks for a wall 3 pixels away rather than requiring contact, which is why its wall jumps feel generous.

A wall jump you can immediately cancel isn't a wall jump. Jump away from a wall while still holding toward it and normal air control pulls you straight back. Celeste forces move_x away from the wall for 0.16 s after a wall jump — your steering is overridden briefly, and that's what makes the arc land where you aimed.

Build it#

A — Tuning#

@export_group("Wall")
@export var wall_check_distance: float = 3.0
@export var wall_jump_h_speed: float = 130.0
@export var wall_jump_force_time: float = 0.16
## Wall sliding STARTS at this speed and degrades toward max_fall.
@export var wall_slide_start_max: float = 20.0
@export var wall_slide_time: float = 1.2

wall_jump_h_speed of 130 is not arbitrary: it's max_speed (90) + jump_h_boost (40). A wall jump gives you the same momentum bonus a running jump does.

B — Detecting a wall you aren't touching (spec)#

Write a helper on the player:

## Returns -1 (wall on the left), 1 (wall on the right), or 0.
func wall_direction() -> int:
	# your code
	pass

Requirements:

  • Detect a wall within wall_check_distance pixels, not only on contact.
  • Return a direction, not a bool — every caller needs to know which side.
  • Check both sides; prefer the side you're pressing toward if somehow both are true.

test_move() does this cleanly: it asks "would I collide if I moved by this offset?" without actually moving. RayCast2D children are the other reasonable answer, with the caveat that a single ray misses a wall your corner touches — you'd want one at each of the body's vertical extremes.

Predict before you build: with a 3-pixel reach, can the player wall jump off a wall while standing on the floor beside it? Should they be able to? Decide deliberately — Celeste allows it, and it matters more than it sounds.

C — Wall slide and wall jump#

Wall slide is the counterintuitive one, and most implementations get it backwards.

You might expect a constant slow slide speed. Celeste starts you at 20 px/s — very slow — and then degrades toward normal fall speed (160) over 1.2 seconds:

	# inside the WallSlide state — tuning lives on the body, reached through the injection
	var t: float = _wall_slide_timer / player.tuning.wall_slide_time
	var max_speed: float = lerpf(player.tuning.max_fall, player.tuning.wall_slide_start_max, t)

So a wall catches you almost completely at first, then progressively lets go. You can pause briefly to line up a jump, but you can't camp on a wall — the wall stops helping if you overstay. That's a level-design tool as much as a movement one.

Wall jump, on the other hand, is short:

  • velocity.y = jump_velocity — the same as a normal jump.
  • velocity.x = -wall_direction() * wall_jump_h_speed — away from the wall.
  • Start a wall_jump_force_time timer during which horizontal input is overridden to point away from the wall.

That last point is the whole feel of it. Implement it as a forced input rather than as disabled input: for 0.16 s, your run code reads "away from the wall" no matter what the player holds. The player keeps full control of everything else, and gets the arc they aimed for.

D — Climbing (only if you chose it)#

@export_group("Climb")
@export var climb_max_stamina: float = 110.0
@export var climb_up_speed: float = -45.0
@export var climb_down_speed: float = 80.0
@export var climb_up_cost: float = 45.45      # per second, ~2.4s of climbing
@export var climb_still_cost: float = 10.0    # per second, ~11s of hanging
@export var climb_jump_cost: float = 27.5     # flat, exactly 4 from full

The costs encode a design: climbing up is expensive, hanging is cheap, and climbing down is free. Retreating never costs you, so a player who realises they've misjudged can back out. That asymmetry is deliberate generosity — the same instinct as coyote time.

Stamina refills fully on landing. Below climb_tired_threshold (20) Celeste tints the character to warn you, which is worth copying: an invisible resource that kills you is a bad resource.

Requirements: a grab button, climb up and down at those speeds, drain per second, an exhausted state when it hits zero, full refill on landing.

E — Wallboost, if you built climbing#

Celeste's source comments this one directly: "If you climb jump and then do a sideways input within this timer, switch to wall jump."

A neutral climb jump (grab held, no direction) costs 27.5 stamina. Press away from the wall within 0.2 s and it retroactively becomes a wall jump — you get the full 130 horizontal speed and the stamina is refunded.

This exists because the two inputs are 0.2 s apart in a move players think of as one action. Without it, the game punishes you for a keypress order you didn't know mattered. It's the same philosophy as jump buffering, applied to a compound move.

F — Commit#

git add . && git commit -m "stage 07: wall slide, wall jump, forced input window"

Checkpoint — definition of done#

  • You can wall jump without being exactly flush against the wall
  • Wall jumping while holding toward the wall still carries you away from it
  • Sliding starts nearly stopped and speeds up the longer you hold the wall
  • You can climb a shaft by alternating wall jumps between two walls
  • (If built) stamina drains while climbing up, not while climbing down, and refills on landing
  • Zero warnings; committed; decision 4 recorded
  • You can explain: why is horizontal input forced away from the wall rather than simply disabled?

Stretch (no instructions)#

Celeste has WallSpeedRetentionTime = 0.06, commented in its source as: "If you hit a wall, start this timer. If coast is clear within this timer, retain h-speed." Hitting a wall stores your horizontal speed; if the obstruction clears within 0.06 s, you get it back rather than having been stopped dead. Work out which situation this rescues — it involves a wall you only clipped.

If you get stuck#

  • Wall jump only works pressed flush → you're using is_on_wall() instead of the distance check. That's the whole point of section B.
  • Wall jump immediately pulls back to the wall → the force timer isn't overriding input, or the run code reads raw input directly instead of going through your override.
  • Sliding is instantly fast → your lerpf arguments are swapped. t starts at 1 (full timer) and must map to the slow speed.
  • Character sticks to walls → you're zeroing horizontal velocity while sliding and the input keeps pushing in. Decide who owns velocity.x during a wall slide.
  • Stamina drains while climbing down → the drain is unconditional; it should depend on the vertical input direction.

Next: Stage 08 — two pixels, the last one, about the smallest detail in the project and the honest limits of doing this in Godot.