doc 3 of 10

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:

  1. From the farming game: what does move_and_slide() do that setting position directly doesn't?
  2. Write from memory: a typed @export for 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:

SettingValueWhy
Display → Window → Viewport Width320Celeste's internal resolution — the constants below are sized for it
Display → Window → Viewport Height180
Display → Window → Window Width Override1280So you can actually see it
Display → Window → Window Height Override720
Display → Window → Stretch → ModeviewportRenders at 320×180, then scales the whole image
Display → Window → Stretch → AspectkeepNever distort the pixels
Debug → GDScript → Warnings → Untyped DeclarationWarnTurns 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_accel to 10000 — instant, weightless. This is the naive version, and now you can feel exactly what it costs.
  • run_accel to 200 — sluggish, like ice.
  • air_multiplier to 0.1, then walk off the floor edge — you commit to a direction and can barely change it.
  • max_fall to 1000 — 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.tres while the game runs changes the feel immediately
  • Zero warnings in the Output panel — including no untyped-declaration warnings
  • git log shows your first commit
  • You can explain: why is move_toward used for gravity instead of velocity.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 StaticBody2D have a CollisionShape2D with 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_right in the Input Map? Print input_x and watch.
  • Crash on tuning.max_speedtuning is null; the .tres isn'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-in velocity property.

Next: Stage 02 — the jump, which is three separate mechanisms wearing one button.