Five states with ten transitions turns into a wall of nested if/else that breaks every time you add a feature. Your player attacks while dead. Your enemy chases while stunned. One boolean flag fixes it until the next state leaks through.
State machines solve this. Three patterns cover different complexity levels: enum for small cases, node-based for production games, class-based for lightweight systems. Pick the simplest one that works and upgrade when the code tells you to.
For the movement code that states typically wrap, see movement. For the signal wiring that drives transitions, see signals.
You need one when any of these are true:
- More than 3 states. Two booleans (is_attacking, is_jumping) can interact in four ways. Three booleans? Eight. It compounds fast.
- Boolean flags checked everywhere. If your
_physics_processstarts withif is_dead: returnandif is_stunned: return, you're building a state machine poorly. - State leaking. The player attacks while dead. The enemy patrols while chasing. If one behavior bleeds into another, you need explicit state boundaries.
- Enter/exit logic. Starting an animation on state entry, disconnecting signals on exit, resetting timers. Without a state machine, this setup/teardown logic scatters across your codebase.
The simplest pattern. One file, one enum, one match statement.
extends CharacterBody2D
enum State { IDLE, RUNNING, JUMPING, FALLING }
var current_state: State = State.IDLE
func _physics_process(delta: float) -> void:
match current_state:
State.IDLE:
_handle_idle()
State.RUNNING:
_handle_running(delta)
State.JUMPING:
_handle_jumping(delta)
State.FALLING:
_handle_falling(delta)
func _handle_idle() -> void:
velocity.x = 0.0
if Input.get_axis("move_left", "move_right") != 0.0:
current_state = State.RUNNING
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = -400.0
current_state = State.JUMPING
func _handle_running(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
velocity.x = direction * 200.0
if direction == 0.0:
current_state = State.IDLE
if not is_on_floor():
current_state = State.FALLING
move_and_slide()
func _handle_jumping(delta: float) -> void:
velocity.y += 980.0 * delta
var direction := Input.get_axis("move_left", "move_right")
velocity.x = direction * 200.0
if velocity.y >= 0.0:
current_state = State.FALLING
move_and_slide()
func _handle_falling(delta: float) -> void:
velocity.y += 980.0 * delta
if is_on_floor():
current_state = State.IDLE
move_and_slide()
This works for 2-5 states. Everything lives in one script. The match block makes the current behavior obvious. When it grows past ~100 lines or you need to share states between entities, upgrade to the node-based pattern.
This is the community standard, used by GDQuest, The Shaggy Dev, and most production Godot games. Each state is a child Node with its own script. A StateMachine node manages transitions.
State base class (state.gd):
class_name State
extends Node
signal transitioned(new_state_name: StringName)
func enter() -> void:
pass
func exit() -> void:
pass
func update(delta: float) -> void:
pass
func physics_update(delta: float) -> void:
pass
func handle_input(event: InputEvent) -> void:
pass
StateMachine (state_machine.gd):
class_name StateMachine
extends Node
@export var initial_state: State
var current_state: State
var states: Dictionary = {}
func _ready() -> void:
for child in get_children():
if child is State:
states[child.name.to_lower()] = child
child.transitioned.connect(_on_child_transitioned)
if initial_state:
current_state = initial_state
current_state.enter()
func _process(delta: float) -> void:
if current_state:
current_state.update(delta)
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
func _unhandled_input(event: InputEvent) -> void:
if current_state:
current_state.handle_input(event)
func _on_child_transitioned(new_state_name: StringName) -> void:
var new_state := states.get(new_state_name.to_lower())
if new_state == null:
push_warning("State '%s' not found" % new_state_name)
return
if new_state == current_state:
return
current_state.exit()
new_state.enter()
current_state = new_state
A concrete state (idle_state.gd):
extends State
@onready var player: CharacterBody2D = owner
func enter() -> void:
player.velocity.x = 0.0
player.get_node("AnimationPlayer").play("idle")
func physics_update(_delta: float) -> void:
if not player.is_on_floor():
transitioned.emit("Falling")
return
if Input.is_action_just_pressed("jump"):
transitioned.emit("Jumping")
return
if Input.get_axis("move_left", "move_right") != 0.0:
transitioned.emit("Running")
The scene tree looks like this:
Player (CharacterBody2D)
+-- Sprite2D
+-- CollisionShape2D
+-- AnimationPlayer
+-- StateMachine (StateMachine)
+-- Idle (State)
+-- Running (State)
+-- Jumping (State)
+-- Falling (State)
Each state is isolated. Adding a Dashing state means creating one new script and one new child node. Nothing else changes.
States extend RefCounted instead of Node. No scene tree pollution, no extra files. Good for menu flows, networking state, or any system where nodes feel heavy.
extends CharacterBody2D
class BaseState extends RefCounted:
var host: CharacterBody2D
func enter() -> void: pass
func exit() -> void: pass
func physics_update(_delta: float) -> void: pass
class IdleState extends BaseState:
func enter() -> void:
host.get_node("AnimationPlayer").play("idle")
func physics_update(_delta: float) -> void:
if Input.get_axis("move_left", "move_right") != 0.0:
host.change_state(RunState)
class RunState extends BaseState:
func enter() -> void:
host.get_node("AnimationPlayer").play("run")
func physics_update(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
host.velocity.x = direction * 200.0
if direction == 0.0:
host.change_state(IdleState)
host.move_and_slide()
var current_state: BaseState
func _ready() -> void:
change_state(IdleState)
func change_state(new_state_class) -> void:
if current_state:
current_state.exit()
current_state = new_state_class.new()
current_state.host = self
current_state.enter()
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
The tradeoff: states are invisible in the editor and you lose @export variables. Use this when the node-based pattern feels like overkill.
Signals drive transitions in the node-based pattern. Each state emits transitioned with the target state name. The StateMachine listens and orchestrates the swap. States never reference each other directly.
Connect gameplay signals in enter(), disconnect in exit(). This prevents inactive states from reacting to events:
func enter() -> void:
owner.health_component.damage_taken.connect(_on_damage_taken)
func exit() -> void:
owner.health_component.damage_taken.disconnect(_on_damage_taken)
func _on_damage_taken(amount: int) -> void:
if owner.health <= 0:
transitioned.emit("Dead")
Use await for timed transitions like attack animations:
func enter() -> void:
player.get_node("AnimationPlayer").play("attack")
await player.get_node("AnimationPlayer").animation_finished
transitioned.emit("Idle")
AnimationTree has its own visual state machine (AnimationNodeStateMachine) for blending animations. It handles crossfades and transition timing. Code state machines handle game logic, input, and behavior.
Use both together. The code FSM controls what the character does. Each state tells AnimationTree which animation to play:
func enter() -> void:
var playback: AnimationNodeStateMachinePlayback = player.anim_tree.get(
"parameters/playback"
)
playback.travel("run")
Do not encode game logic in AnimationTree conditions. Its branching system is too limited for anything beyond simple animation selection.
| States | Pattern | Example |
|---|---|---|
| 2-4, simple | Enum + match | Menu states, door open/closed |
| 5-10, moderate | Node-based | Player movement, enemy AI |
| 10+, hierarchical | Hierarchical FSM | Sub-states (Grounded > Idle/Run/Crouch) |
| Stackable | Pushdown automaton | Pause menu over gameplay |
| Complex AI | Behavior trees (LimboAI) | Boss phases, companion AI |
Start with enum + match. Refactor to node-based when the match block gets unwieldy or you need to share states between entities. This is standard practice. Do not over-engineer your first iteration.
What You'll Practice: GDScript State Machines
Common GDScript State Machines Pitfalls
- Forgetting to call enter() on the initial state in _ready(), so setup logic like animations and signal connections never runs
- Processing in inactive states because _process/_physics_process run on all child nodes by default. Funnel updates through the StateMachine only.
- Accessing owner before _ready() completes. Use @onready or call_deferred for initial state entry to avoid null references.
- Circular transitions where State A transitions to B and B immediately transitions back to A in the same frame, causing an infinite loop
- Putting character data (speed, health, velocity) on individual states instead of the owner node, duplicating data and causing sync bugs
GDScript State Machines FAQ
What is a state machine in Godot?
A pattern where an object's behavior depends entirely on its current state. Only one state is active at a time. Each state defines what happens during that state and what triggers a transition to another state. This replaces tangled if/else chains with clean, isolated logic per state.
Should I use enum or node-based state machines?
Enum for 2-5 simple states where everything fits in one script. Node-based once you need enter/exit hooks, want states visible in the editor, or plan to share states between entities. The node-based pattern scales better but has more boilerplate.
How do you transition between states in the node-based pattern?
The active state emits a transitioned signal with the target state name: transitioned.emit("Running"). The StateMachine node listens for this signal, calls exit() on the current state, enter() on the new state, and swaps the current_state reference.
Can I use state machines for enemy AI in Godot?
Yes. Enemy AI is one of the most common use cases. A typical enemy FSM has Patrol, Chase, Attack, and Flee states. Use Area2D detection zones to trigger transitions. Connect body_entered/body_exited signals in the patrol state to detect the player.
What is the difference between AnimationTree and a code state machine?
AnimationTree handles animation blending, crossfades, and transition timing. Code state machines handle game logic, input, and behavior. Use both together: the code FSM decides what the character should do, then tells AnimationTree which animation to play via playback.travel().
How do I handle overlapping states like a pause menu over gameplay?
Use a pushdown automaton (stack-based state machine). Push PauseState onto the stack when pausing. Pop it to return to whatever state was active underneath. This preserves the previous state without destroying it.
Do I need a state machine plugin for Godot?
Not for most games. A hand-rolled StateMachine + State base class is about 40 lines of code and gives you full control. For complex AI with behavior trees, look at LimboAI. For visual hierarchical FSMs, the HFSM plugin works well. But start simple.
How do signals work with state machines in Godot 4?
States connect to gameplay signals in enter() and disconnect in exit(). This prevents inactive states from reacting to events. The StateMachine itself listens for each state's transitioned signal to orchestrate state swaps. Godot 4's typed signals make this connection pattern type-safe.
GDScript State Machines Syntax Quick Reference
extends CharacterBody2D
enum State { IDLE, RUNNING, JUMPING, FALLING }
var current_state: State = State.IDLE
func _physics_process(delta: float) -> void:
match current_state:
State.IDLE:
_handle_idle()
State.RUNNING:
_handle_running(delta)
State.JUMPING:
_handle_jumping(delta)
State.FALLING:
_handle_falling(delta)class_name State
extends Node
signal transitioned(new_state_name: StringName)
func enter() -> void:
pass
func exit() -> void:
pass
func update(delta: float) -> void:
pass
func physics_update(delta: float) -> void:
pass
func handle_input(event: InputEvent) -> void:
passclass_name StateMachine
extends Node
@export var initial_state: State
var current_state: State
var states: Dictionary = {}
func _ready() -> void:
for child in get_children():
if child is State:
states[child.name.to_lower()] = child
child.transitioned.connect(_on_child_transitioned)
if initial_state:
current_state = initial_state
current_state.enter()extends State
@onready var player: CharacterBody2D = owner
func physics_update(_delta: float) -> void:
if not player.is_on_floor():
transitioned.emit("Falling")
return
if Input.is_action_just_pressed("jump"):
transitioned.emit("Jumping")extends State
@onready var player: CharacterBody2D = owner
func enter() -> void:
player.health_component.damage_taken.connect(_on_damage)
player.get_node("AnimationPlayer").play("patrol")
func exit() -> void:
player.health_component.damage_taken.disconnect(_on_damage)
func _on_damage(amount: int) -> void:
if player.health <= 0:
transitioned.emit("Dead")