Part 01 — Feel the problem first
You will build: a small character controlled by boolean flags, and then you will deliberately reproduce the bug that flags always eventually produce.
This part contains no state machine. That's on purpose. A pattern you adopt before you've felt the problem is cargo cult — you'll apply it in the wrong places and abandon it in the right ones. Twenty minutes here buys you real judgement about the next three parts.
Set up#
A CharacterBody2D named Player with a CollisionShape2D and a Sprite2D (a placeholder
rectangle is fine), sitting on a StaticBody2D floor. Four input actions in Project Settings
→ Input Map: move_left (A), move_right (D), jump (Space) and attack (J).
The flag version#
Type this — don't paste it. The bug we're hunting hides in code that looks completely reasonable, and you need it to look reasonable to you.
extends CharacterBody2D
const SPEED: float = 180.0
const JUMP_VELOCITY: float = -330.0
const GRAVITY: float = 980.0
# the flags — this is the part that will rot
var is_jumping: bool = false
var is_attacking: bool = false
var can_move: bool = true
@onready var attack_timer: Timer = $AttackTimer
func _physics_process(delta: float) -> void:
# gravity always applies
velocity.y += GRAVITY * delta
# horizontal movement, unless something has locked it
if can_move:
var direction: float = Input.get_axis("move_left", "move_right")
velocity.x = direction * SPEED
else:
velocity.x = 0.0
# jumping
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
is_jumping = true
# landing
if is_on_floor() and velocity.y >= 0.0:
is_jumping = false
# attacking locks you in place for the timer's duration
if Input.is_action_just_pressed("attack") and not is_attacking:
is_attacking = true
can_move = false
attack_timer.start()
move_and_slide()
func _on_attack_timer_timeout() -> void:
is_attacking = false
can_move = true
Add a Timer child named AttackTimer, one_shot on, wait_time 0.4, and connect its
timeout signal to _on_attack_timer_timeout.
Now break it#
Predict before you run. Write down, in one sentence each, what you expect from these three inputs. Then try them.
- Attack while standing still.
- Attack, and while the sprite is locked, press jump.
- Attack, then immediately attack again the frame the timer ends.
Number 2 is the one. You can jump while attacking — can_move blocks horizontal movement but
nothing blocks the jump branch. So you float upward mid-swing, locked in place horizontally,
and land still attacking.
"Fine," you think, "add a guard." So the jump branch becomes:
if Input.is_action_just_pressed("jump") and is_on_floor() and not is_attacking:
Now try this: attack while already in the air. You lock can_move, so you fall straight
down with no air control, and is_jumping stays true because you never touched the floor
check in the right order. Add another guard. Then add a hurt state — is_hurt — and now every
single branch needs to know about it, so each guard grows another and not is_hurt.
The actual diagnosis#
Count the guards. Each new behavior forces you to edit every existing behavior, because each branch has to defend itself against all the others. That's the tell. Three flags means each new branch needs up to three guards; the number of things you must remember grows with the square of the features.
Underneath it is one specific mistake: you stored the character's situation across several
variables that are supposed to be related, but nothing enforces the relationship. Nothing in
the language stops is_attacking = true and is_jumping = true and can_move = true all
holding at once. You know that's nonsense. The code does not.
Write down the combinations your character is supposed to allow. For this tiny controller it's about four. Then count what three booleans can represent: eight. Those four extra combinations aren't hypothetical — they're the bug list you'd be closing one by one for the rest of the project.
Checkpoint — definition of done#
- You reproduced the jump-while-attacking bug and can describe why the guard was missing
- You added the
not is_attackingguard and then found a second bug it didn't fix - You can state the number of combinations three booleans allow, and how many you wanted
- You can explain: why does adding a fourth flag require editing code you already wrote and tested?
Stretch (no instructions)#
Add a is_hurt flag with a knockback that interrupts an attack. Don't fix the resulting mess —
just count how many existing lines you had to touch to add it. Keep that number. Part 02 asks
you to add the same feature to a state machine, and comparing the two numbers is the entire
argument.
If you get stuck#
- Jump doesn't fire at all → is
jumpactually mapped in the Input Map, and is your floor aStaticBody2Dwith a collision shape? Printis_on_floor()every frame and watch it. - Attack never unlocks → is the Timer
one_shot, and did thetimeoutsignal actually connect? The signal dock shows a small icon next to connected signals. - Character sinks through the floor →
move_and_slide()in Godot 4 takes no arguments and uses the built-invelocityproperty. If you found a tutorial passing arguments to it, that's Godot 3 code.
Next: Part 02 — one enum, one match, where four states replace three flags and the bug class disappears rather than getting patched.