Stage 01 — The Runner
You will build: the project, and a player whose movement feels like an action game —
weight, momentum, stop-on-a-dime tuning you chose yourself.
You'll learn: project setup · acceleration and friction · the vocabulary of "game feel"
Warm-up: none — first stage. (Built the farming game? Predict, before coding: what will
velocity = direction * speed feel like at action-game speeds, and why won't it survive
this stage?)
Why this exists#
In a calm game, instant velocity is fine. In a fast game it reads as weightless — your
brain expects mass, and mass means velocity changes over time. The whole trick is
Vector2.move_toward(): walk the current velocity toward a target a fixed amount per tick.
Three numbers fall out — top speed, acceleration, friction — and tuning them is your first
real act of game design, not programming.
Build it#
A — Project#
Godot 4.7 → New Project → action_rpg, in your projects folder, Mobile renderer.
Pixel-art settings (same reasoning as ever — render small, scale by integers, keep pixels
square): viewport 640×360, window override 1280×720, stretch mode viewport, scale
mode integer, texture filter Nearest. Turn on the typed-GDScript warning
(debug/gdscript/warnings/untyped_declaration → Warn) — the house rules
apply here too. Then:
git init -b main
git add . && git commit -m "stage 00: empty project, pixel settings"
B — Input map#
move_left/right/up/down on WASD + arrows. (Dash and attack come with their stages —
don't stub what you can't test.)
C — The player, with weight (type it, don't paste it)#
player/player.tscn: CharacterBody2D + Sprite2D (placeholder) + CollisionShape2D at the
feet. The script — complete, because the pattern is the lesson; the numbers are yours:
class_name Player
extends CharacterBody2D
@export var max_speed: float = 140.0
@export var acceleration: float = 1200.0
@export var friction: float = 1000.0
var facing: Vector2 = Vector2.DOWN
func _physics_process(delta: float) -> void:
var direction: Vector2 = Input.get_vector("move_left", "move_right", "move_up", "move_down")
if direction != Vector2.ZERO:
facing = direction
# accelerate toward intent
velocity = velocity.move_toward(direction * max_speed, acceleration * delta)
else:
# no intent: friction bleeds speed off
velocity = velocity.move_toward(Vector2.ZERO, friction * delta)
move_and_slide()
Before you run — predict: why does this code multiply by delta when the farming
game's movement didn't? (Hint: one sets velocity, one changes it. Which operation is
per-tick?) Run only after you've committed to an answer.
D — Tune it like you mean it#
This is the stage's real work. Make a scratch arena (Node2D + a few StaticBody2D walls) and spend twenty minutes on the three exported numbers in the Inspector while the game runs:
| Feel | Try |
|---|---|
| Ice-skating | friction 200 |
| Stiff/instant (farming-style) | acceleration 99999 |
| Heavy tank | acceleration 400, max 100 |
| Snappy action (a starting point) | max 140 / accel 1200 / friction 1000 |
Pick your triple. Write the three numbers and one sentence of why in a comment above the exports. You will re-tune after the dash exists — feel is a system, not a setting.
git add . && git commit -m "stage 01: movement with weight, tuned"
Checkpoint — definition of done#
- Starting, stopping, and direction changes all take visible (but small) time
- Diagonals aren't faster (test a corner — and know which line guarantees it)
- Your three numbers are chosen, committed, and justified in a comment
- Zero warnings; pixels crisp at any window size
- You can explain: what would break if
frictionwere applied even while a direction is held? Try it for science, then revert.
Stretch (no instructions)#
A subtle sprite lean: the player tilts a few degrees into their velocity. Two lines and suddenly there's a body under that placeholder.
If you get stuck#
- Movement feels identical to instant → your acceleration is so high it is instant; drop it by 10× and watch what the parameter actually does before re-raising.
- Player never quite stops → print
velocity.length()when idle; if it hovers near zero without reaching it, what doesmove_towarddo on the final step — and is friction actually being reached in yourif? - Sliding along walls feels sticky → that's
move_and_slidedoing its job against acceleration; revisit after the dash, it changes the equation.