doc 3 of 5

Part 02 — One enum, one match

You will build: the same character from part 01, rewritten so that the bug you found cannot be expressed. About forty lines, one new file's worth of thinking, no new nodes.

This is the rung most games should climb to and then stop on. Learn it properly and you may never need parts 03 and 04.

The idea in one line#

Replace several booleans that are secretly related with one variable that can hold exactly one value from a fixed list.

enum State { IDLE, RUN, JUMP, ATTACK }

var state: State = State.IDLE

state cannot be IDLE and ATTACK at the same time. Not "shouldn't" — cannot. There is no syntax for it. Every bug in part 01 came from a combination that this declaration makes unwritable.

An enum is just named integers — State.IDLE is 0. The value doesn't matter; the name is the point, because state = State.ATTACK reads like a sentence and state = 3 does not.

Why a setter function, not plain assignment#

Here's the part that people skip, and skipping it is why their enum machine ends up as messy as the flags were.

Assigning state = State.ATTACK directly means every place that starts an attack must also remember to start the attack timer, stop horizontal motion, and swap the sprite. Three call sites, three chances to forget one. So all state changes go through one function:

func _set_state(new_state: State) -> void:
	if new_state == state:
		return   # re-entering the same state is almost never what you meant

	# exit: undo whatever the old state set up
	match state:
		State.ATTACK:
			attack_timer.stop()

	state = new_state

	# enter: set up whatever the new state needs
	match state:
		State.IDLE:
			velocity.x = 0.0
		State.ATTACK:
			velocity.x = 0.0
			attack_timer.start()

Two match blocks, one for leaving and one for arriving. Now "entering ATTACK starts the timer" is written once, and it is true from every direction — from IDLE, from RUN, from a future state you haven't invented yet.

The early return matters more than it looks. Without it, holding a movement key would re-enter RUN sixty times a second, restarting anything RUN's enter block does. Guard it once, here, and no caller ever has to think about it.

Build it#

Rewrite the part 01 script. The enter/exit skeleton is given above; the transitions are yours to write.

extends CharacterBody2D

const SPEED: float = 180.0
const JUMP_VELOCITY: float = -330.0
const GRAVITY: float = 980.0

enum State { IDLE, RUN, JUMP, ATTACK }

var state: State = State.IDLE

@onready var attack_timer: Timer = $AttackTimer


func _physics_process(delta: float) -> void:
	# gravity is not a state — it is always true of this character
	velocity.y += GRAVITY * delta

	var direction: float = Input.get_axis("move_left", "move_right")

	# what the current state does each frame
	match state:
		State.IDLE, State.RUN:
			velocity.x = direction * SPEED
		State.JUMP:
			velocity.x = direction * SPEED   # air control; try removing it later
		State.ATTACK:
			velocity.x = 0.0

	# what the current state may become
	match state:
		State.IDLE:
			pass   # → RUN when? → JUMP when? → ATTACK when?
		State.RUN:
			pass
		State.JUMP:
			pass   # what tells you the jump is over?
		State.ATTACK:
			pass   # nothing here — the timer ends this one. Why is that different?

	move_and_slide()


func _on_attack_timer_timeout() -> void:
	_set_state(State.IDLE)

Fill in the second match. The transitions you need:

  • IDLE → RUN when direction isn't zero. RUN → IDLE when it is.
  • IDLE/RUN → JUMP on jump just-pressed and is_on_floor().
  • JUMP → IDLE when is_on_floor() and you're no longer moving upward.
  • IDLE/RUN → ATTACK on attack just-pressed.
  • ATTACK → anything: nothing. The timer owns this exit.

Notice what you did not have to write: a single guard. ATTACK doesn't need and not is_jumping, because when you're in ATTACK the other branches don't run at all. The match does the excluding for you. That is the whole prize.

Predict before you run: attack, and while locked, hammer jump and both movement keys. Write down what you expect. Then try the part 01 bug list against this version.

Where the trap is#

One thing genuinely got harder, and pretending otherwise would be lying to you.

In the flag version, "attacking" and "in the air" were independent, so an air attack was free — buggy, but free. Here they're mutually exclusive, so if you want an air attack you need a real answer: another state (AIR_ATTACK), or a second small machine that tracks grounded-ness separately.

That's not a flaw in the pattern. It's the pattern forcing you to decide something the flags let you leave ambiguous — and ambiguous is exactly where the original bug lived. But it does mean a state machine is a poor fit when two things really are independent. Two independent machines beat one machine with the product of both state lists.

Make the state visible#

Debugging a machine you can't see is miserable. Tint the sprite per state inside the enter matchIDLE white, RUN green, JUMP blue, ATTACK red:

		State.ATTACK:
			velocity.x = 0.0
			attack_timer.start()
			sprite.modulate = Color.RED

Placeholder feedback now; real animations swap into the same lines later. Any time a machine misbehaves, the first move is to make its current state observable — printing on change (not every frame) turns a mystery into a readable story.

Checkpoint — definition of done#

  • Every bug from part 01 is gone, and you fixed none of them individually
  • Attacking mid-air is now impossible, and you can say what you'd add to allow it
  • Holding a movement key does not restart RUN's enter block every frame
  • Zero untyped declarations; no warnings in the Output panel
  • You can explain: why does the early return in _set_state() exist, and what visibly breaks without it?

Stretch (no instructions)#

Add the HURT state from part 01's stretch — knockback that interrupts an attack. Count the lines you had to touch. Compare with the number you wrote down last time.

Then add a coyote-time window: you may still enter JUMP for a fraction of a second after walking off a ledge. Ask yourself whether that is a new state or a timer that JUMP's entry condition consults — and why the answer is "timer".

If you get stuck#

  • Character stuck in one state forever → print inside _set_state() (on change only). Is it ever called with the state you expect? If not, your transition condition is never true.
  • ATTACK ends instantly → is AttackTimer.one_shot on, and is wait_time non-zero? A Timer with wait_time 0 fires next frame.
  • Jump feels like it eats inputs → Input.is_action_just_pressed() is true for one frame. Reading it in _physics_process (which can run at a different rate than _process) can miss presses. This is a real problem with a real fix — it's called input buffering, and it's what the platformer project's stage 03 builds.
  • Flickering between IDLE and RUN → your transition condition and your movement code disagree about what "moving" means. Pick one: direction != 0.0 (intent) or velocity.x != 0.0 (result). They differ the frame you release the key.

Next: Part 03 — when one file isn't enough. Read it when — and only when — this file gets long enough to annoy you.