doc 8 of 10

Stage 06 — The dash

You will build: an eight-direction air dash with freeze frames, speed retention, and a ground refill. The move the whole genre is built around.

You'll learn: why the dash preserves speed instead of setting it · freeze frames as feedback · design decision 2 comes due.

Warm-up — from memory:

  1. From stage 01: when does run_reduce apply instead of run_accel? (This matters today.)
  2. From stage 05: where did you decide move_and_slide() gets called?
  3. What does move_toward do when the target is already reached?

Why this exists#

A dash sounds simple: point a direction, go fast for a moment. Build it that way and it feels like a teleport — technically correct, completely flat.

Celeste's dash is doing five things at once, and each is a small piece of code:

  1. The game freezes for 0.05 s at the moment of input.
  2. Velocity is set to 240 px/s in one of eight directions, held for 0.15 s.
  3. If you were already moving faster horizontally in that direction, you keep your speed.
  4. At the end, speed drops to 160, and upward components are cut to 0.75 of that.
  5. Dashes refill when you land.

Number 3 is the one that turns a movement ability into a skill ceiling, and it's the one most implementations miss.

Why speed retention matters#

Naive: velocity = direction * DASH_SPEED. You dash right at 240 — but if you'd built up 300 from a previous dash, that line just slowed you down. The mechanic that's supposed to make you fast punishes being fast.

Celeste's rule:

If you were already moving in the dash's horizontal direction, and faster than the dash would give you, keep your speed.

Now dashes chain. Combined with run_reduce from stage 01 — which bleeds excess speed slowly rather than snapping back to 90 — the extra speed survives long enough to use. Those two pieces are why players can build velocity across a room, and neither works without the other. This is the payoff for building run_reduce back in stage 01 when it did nothing visible.

Build it#

A — Tuning#

@export_group("Dash")
@export var dash_speed: float = 240.0
@export var dash_time: float = 0.15
@export var dash_cooldown: float = 0.2
@export var dash_refill_cooldown: float = 0.1
@export var end_dash_speed: float = 160.0
@export_range(0.0, 1.0) var end_dash_up_mult: float = 0.75
@export var dash_freeze_time: float = 0.05

Add a dash action: X on the keyboard, and a shoulder button on a gamepad.

B — Decision 2#

Is the dash a state, or a modifier the other states consult?

A — a Dash state. Matches the structure; the dash's rules live in one file. Cost: it must decide what to become when it ends, so it needs to know about Idle, Fall, WallSlide.

B — a dash timer other states check. No new state. Cost: every existing state grows an if dashing branch, which is exactly the flag problem the machine was built to escape.

Celeste uses a state (StDash). That's evidence, not proof — its state machine has a StateMachine.State integer with explicit transitions and different constraints from yours.

Choose, record the reason and date in the architecture document, and build it. This document assumes A.

C — The direction (spec — no code)#

Read Input.get_vector("move_left", "move_right", "move_up", "move_down") and snap it to one of eight directions. Requirements:

  • Exactly eight outcomes, all normalised — a diagonal must not be 1.41× faster than a cardinal. This is the classic diagonal-speed bug.
  • No input at all → dash in the direction the character faces.
  • Celeste biases the snap upward: cardinals normally capture 22.5° either side, but that narrows to 17.5° when the aim is upward, so up-diagonals get a wider window. Optional, but it's a real detail and it exists because players aim up more often than the geometry suggests.

Vector2.snapped(), angle(), and Vector2.from_angle() are all worth looking at. Pick an approach and be able to explain why it can't produce a ninth direction.

D — Freeze frames (worked — the timing is fiddly)#

func _freeze(duration: float) -> void:
	Engine.time_scale = 0.0
	# the 4th argument ignores time_scale — without it this timer never fires
	await get_tree().create_timer(duration, true, false, true).timeout
	Engine.time_scale = 1.0

That fourth argument is the whole trick. SceneTreeTimer normally counts in scaled time, so a timer created while time_scale is 0 waits forever and freezes your game permanently. The signature is create_timer(time_sec, process_always, process_in_physics, ignore_time_scale).

Fifty milliseconds is three frames. It reads as impact — the game acknowledging your input before anything moves. Remove it later and feel how much weaker the dash becomes; it's the cheapest feedback in the project.

E — The dash state (skeleton)#

class_name DashState
extends State

var _direction: Vector2
var _timer: float


func enter() -> void:
	# 1. snap the aim to 8 directions
	# 2. remember horizontal speed BEFORE overwriting velocity
	# 3. freeze
	# 4. set velocity — then apply the retention rule below
	# 5. start the timer, spend a dash
	pass


func physics_update(delta: float) -> void:
	# a dash ignores gravity and ignores steering — that is what makes it readable
	pass

The retention rule, which is worth having exactly right:

	# dashing must never slow you down
	if signf(speed_before.x) == signf(player.velocity.x) \
			and absf(speed_before.x) > absf(player.velocity.x):
		player.velocity.x = speed_before.x

Everything goes through player. — a state has no velocity and no tuning of its own. Both live on the body, and the machine injected that reference when it started the state.

Both conditions matter. Same sign — dashing left after running right should not preserve rightward speed. Strictly greater — otherwise a slow approach would override the dash's own speed downward.

F — Ending it#

	# horizontal and upward dashes decay to end_dash_speed
	if _direction.y <= 0.0:
		player.velocity = _direction * player.tuning.end_dash_speed
	if player.velocity.y < 0.0:
		player.velocity.y *= player.tuning.end_dash_up_mult

Downward dashes are deliberately excluded from the first rule — they keep the full 240, which is what makes a down-dash a committed, fast move rather than a gentle drop.

G — Refills#

Refill on landing, gated by dash_refill_cooldown (0.1 s) so that dashing into the ground doesn't instantly hand the dash back mid-dash. Celeste also refuses to refill while standing on spikes — worth an if once hazards can be stood on.

Predict before you run: you run right at full speed, then dash right. Does your speed go up, down, or stay the same? Now dash up-right from the same run. Reason both out before testing — the second one is the interesting case.

H — Commit#

git add . && git commit -m "stage 06: eight-direction dash with freeze frames and speed retention"

Checkpoint — definition of done#

  • Eight directions, all the same speed — diagonals are not faster
  • The freeze is perceptible and makes the dash feel like it lands
  • Dashing while already faster in that direction does not slow you down
  • You get exactly one dash, refilled on landing
  • Dashing into a wall doesn't stick you to it or eat the cooldown oddly
  • Zero warnings; committed; decision 2 recorded
  • You can explain: why does the retention rule check the sign as well as the magnitude?

Stretch (no instructions)#

Implement the hyperdash: dash along the ground, then jump within the dash window, and get x * 1.25 and y * 0.5 — a long, low, very fast jump. Celeste calls the ducking version a hyper and the standing one a super. You'll need a window that outlives the dash itself; Celeste calls it DashAttackTime and sets it to 0.3 s. Work out why it's longer than the dash.

If you get stuck#

  • Game freezes permanently → the fourth argument to create_timer. Every time.
  • Diagonals are faster → you didn't normalise. Vector2(1, 1).length() is 1.41.
  • Dash slows you down → the retention rule is missing, or its comparison is backwards.
  • Speed vanishes right after the dash → run_reduce from stage 01 isn't being used. Check the condition: it applies only when above max speed and still holding that direction.
  • Infinite dashes → refill is firing every frame you're grounded rather than once on landing, or the refill cooldown isn't respected.
  • Dash cancels instantly → gravity is still being applied during the dash. The dash state shouldn't call your gravity code at all.

Next: Stage 07 — walls.