Overview
As game development progresses, character logic becomes increasingly complex. "The player has idle, walking, jumping, attacking, and damage states." "The enemy has patrol, chase, attack, and waiting states." Trying to manage these with just if statements and bool variables (is_jumping, is_attacking, etc.) quickly turns code into tangled spaghetti and becomes a breeding ground for bugs.
The classic yet extremely powerful design pattern for solving this problem is the State Machine.
What is a State Machine? A Blueprint for Organizing Behavior
A state machine is a model that organizes complex behavior by clearly defining the "states" an object can be in and the conditions for "transitions" from one state to another.
- State: The type of current behavior the object has. Examples: "Idle," "Move," "Jump." Each state knows what processing to execute while in that state.
- Transition: The "trigger" or "rule" for moving from one state to another. Examples: "When jump button is pressed, transition from 'Idle' to 'Jump'," "When HP reaches 0, transition to 'Death' from any state."
Using this model, rules like "cannot attack while taking damage" can be clearly designed as "cannot transition to Attack state while in Damage state."
Basics: Simple State Machine with enum and match
The easiest way to implement a state machine in Godot is defining states with enum and branching processing with match statements.
1. Defining States
First, define all possible states the character can have with enum.
# Enemy.gd
extends CharacterBody2D
# Define states with enum. Convention is uppercase
enum State { IDLE, WANDER, CHASE, ATTACK }
# Variable holding current state
var current_state: State = State.IDLE
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var timer: Timer = $Timer
var player: Node2D = null
const SPEED = 50.0
2. Managing State Transitions
The standard practice is creating a dedicated change_state function to centralize transition logic.
# The single gateway for changing state
func change_state(new_state: State):
if current_state == new_state:
return
current_state = new_state
match current_state:
State.IDLE:
animated_sprite.play("idle")
timer.start(2.0)
State.WANDER:
animated_sprite.play("walk")
State.CHASE:
animated_sprite.play("walk")
State.ATTACK:
animated_sprite.play("attack")
3. Processing Each State
In _physics_process, use match to dispatch each frame's processing according to current state.
func _physics_process(delta):
match current_state:
State.IDLE:
_idle_state(delta)
State.WANDER:
_wander_state(delta)
State.CHASE:
_chase_state(delta)
State.ATTACK:
_attack_state(delta)
func _idle_state(delta):
velocity = Vector2.ZERO
if can_see_player():
change_state(State.CHASE)
func _wander_state(delta):
# Wandering processing
if can_see_player():
change_state(State.CHASE)
func _chase_state(delta):
if player:
var direction = global_position.direction_to(player.global_position)
velocity = direction * SPEED
move_and_slide()
if can_attack_player():
change_state(State.ATTACK)
elif not can_see_player():
change_state(State.IDLE)
func _attack_state(delta):
velocity = Vector2.ZERO
if not animated_sprite.is_playing():
change_state(State.CHASE)
func _on_timer_timeout():
if current_state == State.IDLE:
change_state(State.WANDER)
With this structure, _physics_process becomes a traffic controller, and each state's specific behavior and transition conditions are neatly organized in their respective functions.
Common Mistakes and Best Practices
| Common Mistake | Best Practice |
|---|---|
Overusing if-elif-else | Actively use match statements. match clarifies code intent and makes completeness checking easier. |
| Giant state functions | Split functions finely. For example, within _chase_state, separate movement processing, detection processing, and transition decisions into separate helper functions. |
| Scattered transition logic | Centralize transition processing in the change_state function. Perform initialization and cleanup when state changes in this function for consistency. |
| Directly modifying state variables | Always change state through the change_state function. This prevents unintended state transitions and makes debugging easier. |
| Not using timers or signals | Use Timer nodes and signals for time-based or event-based transitions (like animation completion). |
Advanced: State Class Implementation
The enum/match approach is simple, but as states increase, single files become lengthy. Enter the approach of implementing each state as a separate class (file).
1. Creating the State Base Class
# State.gd
class_name State
extends RefCounted # Inherit RefCounted for automatic memory management
var character: Node
func enter():
pass
func exit():
pass
func process(delta):
pass
func physics_process(delta):
pass
Note: By inheriting
RefCounted, memory is automatically released when references are gone. Without inheritance, you must manually free memory.
2. Implementing Concrete State Classes
# ChaseState.gd
extends State
class_name ChaseState
func enter():
character.animated_sprite.play("walk")
func physics_process(delta):
if not character.can_see_player():
character.change_state(character.states["IDLE"])
return
if character.can_attack_player():
character.change_state(character.states["ATTACK"])
return
if character.player:
var direction = character.global_position.direction_to(character.player.global_position)
character.velocity = direction * character.SPEED
character.move_and_slide()
3. Modifying the Main Script
# Enemy.gd
extends CharacterBody2D
var states: Dictionary
var current_state: State
func _ready():
states = {
"IDLE": IdleState.new(),
"CHASE": ChaseState.new(),
"ATTACK": AttackState.new()
}
for state_name in states:
states[state_name].character = self
change_state(states["IDLE"])
func change_state(new_state: State):
if current_state:
current_state.exit()
current_state = new_state
current_state.enter()
func _physics_process(delta):
if current_state:
current_state.physics_process(delta)
Performance and Alternative Patterns
-
Performance: Both
enum/matchand state class approaches have virtually no performance impact.matchstatements often run faster thanif-elifchains, and class method call overhead is negligible on modern PCs and consoles. -
Alternative Pattern (Behavior Trees): For more complex decision-making logic, a different design pattern called Behavior Trees is sometimes used. Behavior trees excel at defining "which actions to prioritize to achieve a goal" in a tree structure.
Summary
State machines are an essential technique for organizing complex character logic and keeping code clean. When struggling with nested if statements, that's the perfect opportunity to introduce a state machine.
- Basics: Implement easily with
enumandmatch. - Advanced: Split logic into files with state classes to maximize maintainability.
- Rule: Centralize transitions with a
change_statefunction.