Stage 01 — Ground zero: a body with weight
You will build: the project itself, and a rectangle that runs with acceleration and falls under gravity. No jumping yet — running and falling are enough to feel whether the character has weight.
You'll learn: project setup for pixel-precise 2D · CharacterBody2D · why acceleration
beats instant speed · putting tuning numbers in a Resource · git.
Warm-up — from memory: if this is your first stage, skip it. Otherwise:
- From the farming game: what does
move_and_slide()do that settingpositiondirectly doesn't? - Write from memory: a typed
@exportfor a float with a slider from 0 to 1.
Why this exists#
Nearly every beginner platformer sets velocity.x = direction * SPEED. Press right, instantly
at full speed; release, instantly stopped. It works, and it feels like sliding a chess piece —
there's no mass, no commitment, no sense that the character had to start moving.
Celeste doesn't do that, and neither will you. Its horizontal movement is an approach: each frame, move the current speed toward a target by a fixed amount. That one change is the difference between a rectangle that teleports and one that has weight.
The same idea handles gravity. Falling isn't "add gravity forever" — it's "approach terminal velocity". Writing both the same way means one mental model covers all movement in the game.
Build it#
A — Create the project#
In Godot's Project Manager: Create → name it precision_platformer, put it wherever you
keep your projects, choose the Mobile renderer. Mobile is right here because this is a 2D
desktop game — Forward+ costs more for features you won't use, and Compatibility only matters
if you plan a web export.
Then Project → Project Settings:
| Setting | Value | Why |
|---|---|---|
| Display → Window → Viewport Width | 320 | Celeste's internal resolution — the constants below are sized for it |
| Display → Window → Viewport Height | 180 | |
| Display → Window → Window Width Override | 1280 | So you can actually see it |
| Display → Window → Window Height Override | 720 | |
| Display → Window → Stretch → Mode | viewport | Renders at 320×180, then scales the whole image |
| Display → Window → Stretch → Aspect | keep | Never distort the pixels |
| Debug → GDScript → Warnings → Untyped Declaration | Warn | Turns the typing rules into visible warnings |
That viewport size matters more than it looks. Every number in this project is in pixels per second, sized against a 320-pixel-wide screen. Use a 1920-wide viewport and the screen is six times wider, so 90 px/s takes six times as long to cross it — over twenty seconds, end to end. The same numbers, a completely different game.
B — Layer names, once#
Project Settings → Layer Names → 2D Physics. Name layer 1 solid, layer 2 player,
layer 3 hazard, layer 4 collectible. Two minutes now; otherwise stage 04 is a puzzle of
unlabelled checkboxes.
C — The tuning resource#
Create movement_tuning.gd:
class_name MovementTuning
extends Resource
## Every number that decides how the character feels. Values are Celeste's, in px/s and px/s².
@export_group("Run")
## Top horizontal speed on the ground.
@export var max_speed: float = 90.0
## How fast we approach the target speed. Also the stopping rate.
@export var run_accel: float = 1000.0
## Used only when already ABOVE max_speed and still holding that direction.
@export var run_reduce: float = 400.0
## Air acceleration is this fraction of ground acceleration.
@export_range(0.0, 1.0) var air_multiplier: float = 0.65
@export_group("Fall")
@export var gravity: float = 900.0
@export var max_fall: float = 160.0
Then in the FileSystem dock: right-click → New Resource… → MovementTuning → save as
player_tuning.tres. Leave the defaults.
Why a Resource rather than constants at the top of the player script? Because you are about to
spend several hours adjusting these numbers, and every adjustment as a constant costs a restart.
As a .tres, you can select it in the Inspector while the game is running and feel the
change instantly. Finding good feel means trying fifty values; make trying cheap.
D — The player#
Scene: CharacterBody2D named Player, saved as player.tscn.
Player (CharacterBody2D)
├── Sprite2D ← any 8×16 placeholder; a coloured rect is fine
└── CollisionShape2D ← RectangleShape2D, 8 × 16
Use a rectangle, not a capsule. Capsules are common in tutorials and wrong for this genre: their rounded bottom slides off ledges you visually stood on, and their rounded sides make wall detection ambiguous in stage 07. Precision platformers use boxes.
Set the player's collision layer to 2 (player) and its mask to 1 (solid).
player.gd — this one is fully worked, because it's the first physics code in the project:
class_name Player
extends CharacterBody2D
@export var tuning: MovementTuning
func _physics_process(delta: float) -> void:
_run(delta)
_fall(delta)
move_and_slide()
func _run(delta: float) -> void:
# -1, 0 or 1 — pure intent, before any physics
var input_x: float = Input.get_axis("move_left", "move_right")
# where we WANT to be: full speed in that direction, or a standstill
var target: float = input_x * tuning.max_speed
# in the air we accelerate more slowly — less control, more commitment
var mult: float = 1.0 if is_on_floor() else tuning.air_multiplier
# above top speed AND still pushing that way? bleed it off gently instead of snapping
var rate: float = tuning.run_accel
if absf(velocity.x) > tuning.max_speed and signf(velocity.x) == input_x:
rate = tuning.run_reduce
velocity.x = move_toward(velocity.x, target, rate * mult * delta)
func _fall(delta: float) -> void:
# gravity is an approach toward terminal velocity, not an endless addition
velocity.y = move_toward(velocity.y, tuning.max_fall, tuning.gravity * delta)
Assign player_tuning.tres to the tuning slot in the Inspector.
Then a Main scene: a Node2D with your Player instanced and a StaticBody2D floor
(collision layer 1, a wide RectangleShape2D). Set it as the main scene.
Input actions in Project Settings → Input Map: move_left (A and Left), move_right (D
and Right).
E — Read the three lines that matter#
Before running, make sure you can answer these. They are the whole stage.
move_toward(velocity.x, target, rate * delta) — moves the first value toward the second by
at most the third, never overshooting. With target of 0 (no input), it is friction. With a
target of 90, it's acceleration. One function, both jobs.
rate = run_reduce when above max speed — this is the momentum rule, and it's the reason
Celeste's dash-boosted speed doesn't vanish the instant the dash ends. Below top speed you
accelerate hard (1000). Above it, still pushing, you lose the excess slowly (400). It matters
enormously in stage 06 and does nothing visible today — build it now anyway, so that stage 06
adds one mechanic rather than two.
move_toward for gravity — clamps at max_fall automatically. No separate if velocity.y > max_fall clamp, no chance of the clamp and the gravity disagreeing.
Predict before you press F5: you hold right for two seconds, then release. Roughly how long does the box take to stop — instantly, a fraction of a second, or a full second? Compute it: you're at 90 px/s, losing speed at 1000 px/s². Write the number down, then check by eye.
F — Feel it, then break it#
Play with the .tres open in the Inspector while running:
run_accelto10000— instant, weightless. This is the naive version, and now you can feel exactly what it costs.run_accelto200— sluggish, like ice.air_multiplierto0.1, then walk off the floor edge — you commit to a direction and can barely change it.max_fallto1000— the fall goes from readable to a blur. There's a reason it's capped low.
Put the defaults back. But make notes — this is how you'll tune everything from here.
G — git#
Version control is part of the curriculum, not admin. From the project folder:
git init
git add .
git commit -m "stage 01: project setup, run and gravity"
Godot generates .godot/ (a cache) — Godot 4 writes a .gitignore excluding it when you
create the project. If yours is missing, add one containing .godot/. Commit the .uid files
that appear next to scripts; they're how Godot tracks file identity across renames.
Checkpoint — definition of done#
- The box accelerates up to speed rather than snapping to it, and slides briefly on release
- Walking off the floor edge falls, and the fall speed visibly stops increasing
- Air control is noticeably weaker than ground control
- Editing
player_tuning.treswhile the game runs changes the feel immediately - Zero warnings in the Output panel — including no untyped-declaration warnings
-
git logshows your first commit - You can explain: why is
move_towardused for gravity instead ofvelocity.y += gravity * delta, and what does that save you from writing?
Stretch (no instructions)#
Add a fast_fall_speed of 240 and a fast_max_accel of 300: holding down while already at
terminal velocity should raise the cap to 240 — approached at 300 px/s², not switched instantly.
Celeste does exactly this. Both conditions matter; work out why "already falling at max" is part
of the test.
If you get stuck#
- Box falls through the floor → does the
StaticBody2Dhave aCollisionShape2Dwith an actual shape assigned, is it on layer 1, and is the player's mask set to 1? All three, in that order. - Box doesn't move → are
move_left/move_rightin the Input Map? Printinput_xand watch. - Crash on
tuning.max_speed→tuningis null; the.tresisn't assigned in the Inspector. - Movement is frame-rate dependent → every rate must be multiplied by
delta. Find the one that isn't. - A tutorial told you to pass arguments to
move_and_slide()→ that's Godot 3. In Godot 4 it takes none and reads the built-invelocityproperty.
Next: Stage 02 — the jump, which is three separate mechanisms wearing one button.