doc 10 of 10

Stage 08 — Two pixels, and proving it's yours

You will build: corner correction — a jump that clips a ledge by a few pixels slides around it instead of stopping dead — and then you'll prove the whole project is genuinely yours.

You'll learn: why sub-pixel collision differs between engines · where a faithful port stops being possible · rebuilding from memory as a test of understanding.

Warm-up — from memory:

  1. Name every state in your machine and one transition out of each.
  2. From stage 06: what happens to velocity.y at the end of an upward dash, and why not a downward one?
  3. From stage 01: what does run_reduce protect?

Why this exists#

Jump up through a gap and clip the corner by two pixels. What happens?

Without correction: you stop dead. All your upward velocity is gone, you drop, and it looks like the game refused a jump that visibly should have worked. Players don't experience this as "I was two pixels off" — they experience it as the game being wrong.

Celeste nudges you. When a rising jump hits something, it checks up to 4 pixels left and right for a position where the collision wouldn't happen. Find one, move there, cancel the collision entirely, and the jump continues as if you'd been aimed correctly.

This is the smallest mechanic in the project and one of the most felt. It's also the one that exposes a real difference between Godot and the engine Celeste was written in — which is why it's last.

The mechanism#

From Celeste's source, in plain terms:

  • Only when rising (velocity.y < 0). Correcting a fall would teleport you around floors.
  • Try offsets 1 through 4 pixels sideways, each combined with 1 pixel upward.
  • Direction order depends on horizontal velocity: try the side you're already moving toward first. At zero horizontal speed, try both.
  • On the first offset that's clear: move there and cancel the collision — keep all velocity.

The "1 pixel up as well" detail matters. You're not sliding sideways along the ceiling; you're lifting past its corner. Omit it and you'll find offsets that are clear horizontally but still blocked above.

The honest part#

Celeste stores positions as integers and moves one pixel at a time, resolving collisions at each step. "Try 3 pixels left" means exactly three pixels, and "is this clear" is an edge-exclusive integer box test.

Godot's CharacterBody2D uses floats and a small collision safety margin. Your player can sit at x = 40.37. test_move() with a 3-pixel offset asks a subtly different question than Celeste's check does.

So: the mechanic ports fine and will feel good. Pixel-exact parity does not, and chasing it means reimplementing integer movement on top of Godot's physics — a real technique some precision platformers use, and firmly out of scope here.

Knowing where a port stops being faithful is a more useful skill than a faithful port. Games are full of behaviour that came from an engine's quirks rather than a designer's intent, and being able to tell the two apart is what lets you decide which are worth reproducing.

Build it#

A — Where it goes#

This runs after you know a collision happened but before the frame ends — which means it needs to sit next to your move_and_slide() call.

If decision 1 sent you down the "every state calls move_and_slide()" road, you now need this in several places, or a helper every state remembers to call. If you chose "the player calls it once", you have exactly one site.

Neither answer was wrong in stage 05. But note which one made this easier, and write a line about it in the architecture document. Recognising afterwards which choice paid off is how architectural judgement actually gets built — not from reading rules, but from choosing and then noticing.

B — The correction (spec)#

## Attempts to nudge around a ceiling corner. Returns true if it moved.
func _try_corner_correction() -> bool:
	# your code
	pass

Requirements:

  • Only when rising and only after an upward collision.
  • Try 1..corner_correction_pixels (4), each offset combined with 1 pixel up.
  • Prefer the side matching horizontal velocity; try both when it's zero.
  • On success: move there, keep velocity, and don't let this frame register as a head-bonk.
  • On failure: change nothing.

test_move(transform, motion) is the tool. Its first argument is the transform to test from — usually global_transform.

C — Test it honestly#

Build a specific test: a gap exactly the player's width, with a ceiling either side, and jump at it deliberately off-centre by 1, 2, 3, 4 and 5 pixels.

1, 2, 3 and 4 should slide through. 5 should not — the correction has a limit and that limit should be exactly where you set it. If 5 works, your loop bound is off by one. If 4 fails, the same bug in the other direction.

Predict before you run: you're moving right and clip a corner that's clear to the left. Does the correction find it? Read your direction-preference logic and answer before testing. Then decide whether the answer you got is the one you want.

D — Turn it off, then on#

Play a room with the correction disabled for a minute, then enabled. Four pixels. It's a remarkable amount of difference for the size of the change, and it's the clearest demonstration in the whole project that game feel is made of small, specific, deliberate decisions rather than a general quality some games have.

The consolidation#

The rest of this stage has no new code. It's the part that decides whether you actually learned this.

1 — Rebuild the player from memory#

New scene, blank script, no looking at your existing code. Docs are allowed; your own project is not. Get to: runs with acceleration, jumps with variable height, coyote time.

Whatever you have to look up is exactly what you haven't learned yet — not a failure, a list. Write it down.

2 — Draw the state machine#

On paper, from memory. Every state, every transition, every condition. Then open the project and diff it against reality. Missing transitions are the ones you wrote by trial and error rather than by design — worth re-reading now that you know what they do.

3 — Review your own code#

Read every script as if reviewing someone else's, hunting specifically for:

  • Magic numbers that escaped the tuning resource. Any literal in a state file is a smell.
  • Godot 3 idioms that crept in from tutorials: yield, KinematicBody2D, onready without @, instance(), smoothing_enabled, File.new().
  • Underscore-prefixed methods called from other scripts_private means private; calling it from elsewhere means it should be renamed or the design is wrong.
  • Dead code from stages you replaced. Stage 05's refactor probably left some.
  • Untyped declarations. The warning panel should be empty; if you've been ignoring warnings, now is when that debt comes due.

4 — Explain it#

Pick three scripts. For each, explain out loud what it does and why it's shaped that way, without opening it. The ones you can't are the ones to reread.

This is the actual definition of done for the project. Working code you can't explain is the outcome this whole approach exists to avoid — a game that runs and a skill that didn't transfer is a failure even at 60 fps.

git add . && git commit -m "stage 08: corner correction; project consolidation"

Checkpoint — definition of done#

  • Clipping a corner by 1–4 pixels slides through; 5 does not
  • Correction never fires while falling
  • You rebuilt the core controller from memory and listed what you had to look up
  • Your drawn state diagram matches the code
  • Self-review found at least one real issue, and you fixed it
  • Zero warnings; committed
  • You can explain: why does Godot's float-based movement make exact parity with Celeste's corner behaviour impossible, and why is that acceptable?

Stretch (no instructions)#

Celeste also has DashCornerCorrection = 5 — a wider window while dashing — and DashHJumpThruNudge = 6 for one-way platforms. Add the dashing variant, then ask why a dash deserves more forgiveness than a jump. (The answer is about how committed the move is once started.)

Where to go next#

Deepen this project: animation and particles, audio, a pause menu, moving platforms, save and load. Each is a feature module of one to three stages, and every one of them now lands on a structure that can hold it.

Take the systems further: save & load fits naturally — room progress and best times are small and this architecture already keeps them as real values.

Start something new — and this time, write the architecture document before any code. Mapping the systems, the data, and the open decisions while nothing is built yet is a different skill from following a plan, and it's the one that makes the next project yours from the start. Use this project's as the model — including the parts where it states a decision and hands it to you.

Aim for improvement every day.