doc 5 of 10

Stage 03 — Faces and feet: facing, run, and the ground

You will build: facing as data that the skin renders · the one-line yaw math (atan2) with its three sign conventions · RunState · and a per-axis move() you can defend. You'll learn: why the model's front is −Z · why acceleration is two one-dimensional problems · what get_gravity() is really doing.

Why this exists#

A 2D character flips a sprite; a 3D one must turn, and turning is where the XZ plane stops being a fact and becomes arithmetic. This stage earns the facing math once, with a prediction you can check against the face box, and it installs run — the second state the stage-02 diagram drew in grey.

Build it#

A — Facing: data first, rotation second#

facing is a Vector3 on the player (stage 02 left it there, unused). It means the last direction the player moved — the cell they'll plant in (stage 04), the way they face after stopping. Rotation is a rendering of that data, applied to the skin only:

# in Player.move() — the two lines stage 02 deferred
if direction != Vector3.ZERO:
	facing = direction
	skin.rotation.y = atan2(-direction.x, -direction.z)

The signs are the whole stage, so here's the derivation, once:

Godot's convention: an unrotated model looks down −Z (the face box is the marker). A yaw rotation of θ turns the forward vector (0, 0, -1) to (-sin θ, 0, -cos θ). You want that to equal your (normalized) movement direction (dx, 0, dz):

-sin θ = dx    →    sin θ = -dx
-cos θ = dz    →    cos θ = -dz

One function returns the angle from its sine and cosine: θ = atan2(-dx, -dz).

Check it against the prediction, the way the almanac does everywhere: walk in +X (D key, raw = (1, 0), direction = (1, 0, 0)). atan2(-1, 0) = −90°. The model's front at −90° yaw is (−sin(−90°), −cos(−90°)) = (1, 0) — pointing +X. The face box leads the walk. Now walk in −Z (W): atan2(0, 1) = 0° — front is (0, 0, −1). Both checks pass on paper, so the engine run is confirmation, not discovery.

Two rules outlive this stage:

  • Facing is maintained by movement, read by everything else. Stage 04's planting asks "which cell am I facing?" and gets the answer from facing — the skin's rotation is never consulted. Data renders; it doesn't answer.
  • Rotate the skin, never the body. The body's rotation belongs to the camera decision from stage 01; the skin's belongs to the player's facing. Keep the two owners apart or stage 01's prediction comes due in a form nobody wants.

B — Run: the state that changes a number#

# run_state.gd
class_name RunState
extends PlayerState

func _physics_update(delta: float) -> void:
	var direction := player.input_direction()
	player.move(direction, player.speed * player.run_multiplier, player.acceleration, delta)

	if direction == Vector3.ZERO:
		machine.travel(&"IdleState")
	elif not Input.is_action_pressed(&"run_action"):
		machine.travel(&"WalkState")

And WalkState gains its exit up the ladder:

	elif Input.is_action_pressed(&"run_action"):
		machine.travel(&"RunState")

Run is walk with a different target speed — the state exists because the machine's diagram (stage 02) said it would, and because "am I running" is a condition other systems will ask about (speed-based dust, a breath meter, a stamina drain — all future states or all player.is_running() queries). The exit logic is the interesting part: no input goes to Idle, input-without-shift goes to Walk, and only shift-release stays put. A run state that traveled straight to Idle on shift-release (skipping Walk) would be correct and feel wrong — the deceleration from run-speed to walk-speed is where the weight lives.

C — move(), per axis, and why#

Stage 02's move() already had the shape; here's the defense, because the 2D habit will keep trying to rewrite it:

func move(direction: Vector3, target_speed: float, velo: float, delta: float) -> void:
	velocity.x = move_toward(velocity.x, direction.x * target_speed, velo * delta)
	velocity.z = move_toward(velocity.z, direction.z * target_speed, velo * delta)

	if not is_on_floor():
		velocity += get_gravity() * delta

	if direction != Vector3.ZERO:
		facing = direction
		skin.rotation.y = atan2(-direction.x, -direction.z)

	move_and_slide()

The 2D version was one line: velocity = velocity.move_toward(direction * speed, accel * delta) — steer the whole vector toward the target vector. In 3D the equivalent steers the combined (x, z) vector, and that changes the feel on turns: a vector-steered body approaches the resultant target, so strafe-into-forward cornering lags and overshoots in a way that reads as drift. Per-axis steering decomposes the turn into two one-dimensional approaches — x reaches its target on its own clock, z on its own — and the corner snaps the way keyboard 3D players expect. It's the same move_toward, applied twice, and the difference is audible in the footwork.

velo is acceleration when moving and deceleration (a bigger number — 11.5 vs 9.0) when a state wants the player stopped (stage 04's UseItem calls player.move(Vector3.ZERO, 0.0, player.deceleration, delta)). Stopping faster than starting is the "weighty" convention: the player commits slowly and cancels quickly.

And get_gravity(), now that you've seen it do work: it returns the world's current gravity as a vector (default (0, -9.81, 0)), so velocity += get_gravity() * delta is v = v + g·Δt — the physics 101 line, sourced from the setting instead of a constant. is_on_floor() (from move_and_slide's result, cached by the body) is the gate: gravity applies only when airborne, or the player would accelerate downward into the floor every tick and jitter. The gate and the accumulation are one idea — gravity is a velocity change, and the floor is a velocity stop — and move_and_slide is what makes the stop honest.

D — Commit#

git add . && git commit -m "stage 03: facing via atan2, run state, per-axis move"

Checkpoint — definition of done#

  • The face box leads every walk direction — all four cardinal directions and one diagonal (a diagonal's facing is the normalized direction, and the yaw is atan2 of it: check +X+Z, which should be −45°... or is it? the paper says so before the engine does)
  • Stop walking: the skin holds its last facing (it's data, and data doesn't decay)
  • Shift while walking: the speed change is accelerated, not teleported — watch the Monitor's frame time or just feel the half-second of getting up to speed
  • Shift-release mid-run: you coast through Walk to a stop, never skipping it
  • You can write θ = atan2(-dx, -dz) from memory and say why each sign is there
  • Zero warnings; committed

Stretch (no instructions)#

A facing smoothing: instead of setting skin.rotation.y directly, approach it — skin.rotation.y = lerp_angle(skin.rotation.y, target, 1.0 - exp(-10.0 * delta)). The frame-rate-independent form is the one with exp; find why a plain lerp_angle(current, target, 0.2) feels different at 30 fps than at 144, and which of the two you'd ship.

If you get stuck#

  • The skin faces backwards (the face box trails the walk) → one of the two signs is flipped. Don't guess which: walk +X, compute both atan2(-dx, -dz) and atan2(dx, dz) on paper, and see which one's front vector (stage A's (−sin θ, −cos θ)) equals (1, 0, 0).
  • The skin spins 180° on some diagonals → direction isn't normalized when it reaches the atan2 (a diagonal's raw magnitude is ~1.41; atan2 doesn't care about magnitude — if this happens, the facing you stored is unnormalized and a later stage's target_position will plant a cell too far). input_direction() normalizes; check you're using its result.
  • Run feels like the same speed as walk → the run_multiplier export exists but RunState passes player.speed, not player.speed * player.run_multiplier. The state is the only place the multiplier is used; the export is just a number until then.
  • The player sinks through the ground when running fast → move_and_slide can tunnel at very high speeds against thin geometry; the ground is 1 unit thick and run is 6.4 u/s, which is safe — if you're sinking, velocity.y is being overwritten somewhere (the stage 01 bullet, again, wearing a run costume).