Godot 4 State Machine Tutorial: The Pattern Every Game Needs
State machines are everywhere in games. A player character that idles, runs, jumps, and attacks? State machine. An enemy that patrols, chases, and fights? State machine. A door that's locked, unlocked, opening, and open? State machine.
Once you learn this pattern, you'll see it in every game you play — and you'll use it in every game you build.
What Is a State Machine?
A state machine is a design pattern where an object can be in exactly one state at a time, and transitions between states follow defined rules.
Think of it like this: your player character is either idle, running, jumping, or attacking. Never two at once. Each state has its own behavior, its own animations, and its own rules for when to switch to another state.
Without a state machine, character code turns into a nightmare of nested if statements:
# The "if-else hell" approach — don't do this
func _physics_process(delta: float) -> void:
if is_attacking:
# attack logic...
if attack_finished:
is_attacking = false
elif is_jumping:
# jump logic...
if is_on_floor():
is_jumping = false
elif is_running:
# run logic...
if Input.is_action_just_pressed("attack"):
is_attacking = true
is_running = false
else:
# idle logic...
if Input.is_action_just_pressed("jump"):
is_jumping = true
This breaks fast. Add a dash, a wall slide, and a ledge grab, and you have 200 lines of tangled booleans that fight each other.
The Clean Alternative
A state machine replaces all those booleans with a single current_state variable and a set of state classes, each handling their own logic.
Here's the structure:
CharacterBody3D (Player)
├── StateMachine
│ ├── IdleState
│ ├── RunState
│ ├── JumpState
│ └── AttackState
├── AnimationPlayer
└── CollisionShape3D
Building the State Machine
Step 1: The Base State
Every state shares the same interface. Create a base State class:
# state.gd
class_name State
extends Node
# Reference to the state machine (set by the StateMachine)
var state_machine: StateMachine
# Called when entering this state
func enter() -> void:
pass
# Called when leaving this state
func exit() -> void:
pass
# Called every physics frame while this state is active
func physics_update(delta: float) -> void:
pass
# Called every frame while this state is active
func update(delta: float) -> void:
pass
Step 2: The State Machine
The state machine manages which state is active and handles transitions:
# state_machine.gd
class_name StateMachine
extends Node
@export var initial_state: State
var current_state: State
func _ready() -> void:
# Give each child state a reference back to this machine
for child in get_children():
if child is State:
child.state_machine = self
# Start in the initial state
if initial_state:
current_state = initial_state
current_state.enter()
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
func _process(delta: float) -> void:
if current_state:
current_state.update(delta)
func transition_to(target_state: State) -> void:
if current_state == target_state:
return
current_state.exit()
current_state = target_state
current_state.enter()
Step 3: Concrete States
Now each state is a clean, focused script:
# idle_state.gd
extends State
func enter() -> void:
# Play idle animation when entering this state
owner.get_node("AnimationPlayer").play("idle")
func physics_update(delta: float) -> void:
var player: CharacterBody3D = owner
# Transition to Run if there's movement input
var input := Input.get_vector("left", "right", "forward", "back")
if input.length() > 0.1:
state_machine.transition_to($"../RunState")
return
# Transition to Jump if jump pressed
if Input.is_action_just_pressed("jump") and player.is_on_floor():
state_machine.transition_to($"../JumpState")
return
# Transition to Attack if attack pressed
if Input.is_action_just_pressed("attack"):
state_machine.transition_to($"../AttackState")
return
# run_state.gd
extends State
@export var speed := 5.0
func enter() -> void:
owner.get_node("AnimationPlayer").play("run")
func physics_update(delta: float) -> void:
var player: CharacterBody3D = owner
var input := Input.get_vector("left", "right", "forward", "back")
# Move the player
player.velocity.x = input.x * speed
player.velocity.z = input.y * speed
player.move_and_slide()
# Transition back to Idle if no input
if input.length() < 0.1:
state_machine.transition_to($"../IdleState")
return
# Can still jump or attack from run state
if Input.is_action_just_pressed("jump") and player.is_on_floor():
state_machine.transition_to($"../JumpState")
elif Input.is_action_just_pressed("attack"):
state_machine.transition_to($"../AttackState")
Why This Is Better
-
Each state is isolated. The idle state doesn't know about attack logic. The jump state doesn't care about running. You can change one without breaking others.
-
Adding states is easy. Want a dash? Add
DashState. Want a wall slide? AddWallSlideState. Plug it in, define its transitions, done. -
Debugging is clear. When something goes wrong, you know exactly which state is active. Print
current_state.nameand you instantly know what your character is doing. -
Animations stay synced. Each state plays its own animation on
enter(). No more fighting with animation priority or wondering why the wrong animation is playing.
Using It for Enemy AI
The same pattern works for enemies — and this is where it really shines:
CharacterBody3D (Enemy)
├── StateMachine
│ ├── PatrolState → walk between waypoints
│ ├── ChaseState → pursue the player
│ ├── AttackState → attack when in range
│ ├── StunnedState → stunned after taking damage
│ └── DeadState → death animation, drop loot
├── NavigationAgent3D
└── DetectionArea
Each state handles its own AI behavior. PatrolState walks between points and transitions to ChaseState when the player enters the detection area. ChaseState follows the player and transitions to AttackState when close enough.
The enemy's behavior is readable, testable, and extensible — exactly what you want when you're balancing difficulty or adding new enemy types.
Common Pitfalls
Circular transitions: State A transitions to State B which immediately transitions back to A. Use conditions carefully and test edge cases.
Giant states: If a single state is 200+ lines, it's doing too much. Break it into sub-states or extract helper functions.
Shared data: States often need access to the same data (health, velocity, references). Use the owner property to access the parent node, or pass shared data through the state machine.
Going Further
This tutorial covers the fundamentals — a single-level state machine with simple transitions. Production games often need:
- Hierarchical state machines — states within states (a "Grounded" super-state containing Idle, Run, and Crouch)
- State history — returning to the previous state (useful for stun → return to what you were doing)
- Animated transitions — blending between state animations smoothly
- AI decision trees — combining state machines with utility AI or behavior trees
We cover all of this in our 22-lesson State Machine AI course, where you build a complete 3D enemy AI system from scratch — patrol, chase, attack, stagger, and death — with hierarchical states, navigation, and combat integration.