doc 2 of 5

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.

  1. Attack while standing still.
  2. Attack, and while the sprite is locked, press jump.
  3. 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_attacking guard 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 jump actually mapped in the Input Map, and is your floor a StaticBody2D with a collision shape? Print is_on_floor() every frame and watch it.
  • Attack never unlocks → is the Timer one_shot, and did the timeout signal 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-in velocity property. 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.