Godot 4 State Machine Tutorial
Clean, Scalable, No Plugins
Every game hits a point where if/elif chains for character behavior spiral out of control. Your player script has 400 lines. Your enemy AI is a maze of boolean flags. Adding a new state breaks two old ones.
The fix is a state machine — and you don't need a plugin for it. In this Godot 4 state machine tutorial, you'll build a clean, reusable finite state machine (FSM) in pure GDScript. We'll then wire it up to a real enemy AI with Idle, Chase, and Attack states.
By the end, you'll have an architecture pattern you can drop into any project — player controllers, UI flows, boss phases, menu systems — and never write another tangled match block again.
Why You Need a State Machine
If you've ever written enemy logic like this, you know the pain:
extends CharacterBody2D
var is_chasing: bool = false
var is_attacking: bool = false
var is_stunned: bool = false
func _physics_process(delta):
if is_stunned:
return
if is_attacking and not is_chasing:
# attack logic...
elif is_chasing and not is_attacking:
# chase logic...
else:
# idle... maybe? who knowsThree booleans means eight possible combinations. Add a fourth flag and you're at sixteen. A GDScript state machine replaces all of this with a single concept: the entity is always in exactly one state, and each state owns its own logic.
The result? Each state is its own script. Small, testable, self-contained. Adding new behavior means adding a new file — not touching existing code. That's the Open/Closed Principle at work in your game.
Create the Base State Class
Every state in our machine will extend this base class. It defines the interface — the lifecycle hooks that every concrete state must implement. Think of it as an abstract contract.
class_name State
extends Node
# Reference to the state machine (set by StateMachine)
var state_machine: StateMachine = null
# Called when entering this state
func enter() -> void:
pass
# Called when leaving this state
func exit() -> void:
pass
# Called every frame (like _process)
func update(delta: float) -> void:
pass
# Called every physics frame (like _physics_process)
func physics_update(delta: float) -> void:
passThe key design choice: State extends Node. That means each state lives in the scene tree as a child of the state machine. You can inspect states in the editor, toggle them on and off for debugging, and use @export to tweak per-state settings in the Inspector. If you're not familiar with the export system, check out our Godot 4 @export Tips guide.
Build the State Machine Controller
The state machine itself is also a Node. Its job is simple: track the current state, delegate lifecycle calls, and handle transitions. No enums. No match statements. Just clean delegation.
class_name StateMachine
extends Node
# Set this in the Inspector to pick the starting state
@export var initial_state: State
var current_state: State
func _ready() -> void:
# Give every child state a reference back to us
for child in get_children():
if child is State:
child.state_machine = self
# Enter the initial state
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)
# Call this to switch states
func transition_to(new_state: State) -> void:
if new_state == current_state:
return
if current_state:
current_state.exit()
current_state = new_state
current_state.enter()That's it. About 30 lines of code and your state machine is done. The transition_to() method handles the full exit → switch → enter lifecycle. The guard clause prevents re-entering the same state, which avoids resetting timers and animations.
Want a video walkthrough of this exact pattern? Our State Machines in GDScript course covers FSMs, hierarchical states, and game flow — all in 15 minutes.
Want more Godot tricks?
Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.
Wire It Up in the Scene Tree
Here's the scene tree structure. The StateMachine node is a child of your entity, and each state is a child of the state machine:
Enemy (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── DetectionArea (Area2D)
└── StateMachine (state_machine.gd)
├── Idle (idle_state.gd)
├── Chase (chase_state.gd)
└── Attack (attack_state.gd)In the Inspector, set the initial_state export on the StateMachine node to point at the Idle node. That's all the configuration needed — no code changes, no hardcoded paths.
This structure means you can duplicate an enemy scene, swap out state scripts, and get entirely different AI behavior. A patrolling guard and a charging brute can share the same state machine skeleton.
Real Example — Enemy AI States
Let's build three concrete states for an enemy that idles, chases the player when they get close, and attacks when in range. This is the exact pattern you'll use in your own games.
First, the Idle state. The enemy stands still and waits for a target to enter its detection range.
class_name IdleState
extends State
@export var chase_state: State
@export var detection_area: Area2D
func enter() -> void:
# Play idle animation
owner.get_node("Sprite2D").modulate = Color.WHITE
# Connect detection signal
detection_area.body_entered.connect(_on_body_detected)
func exit() -> void:
# Disconnect so we don't double-connect later
detection_area.body_entered.disconnect(_on_body_detected)
func _on_body_detected(body: Node2D) -> void:
if body.is_in_group("player"):
state_machine.transition_to(chase_state)Notice how signal connections are managed per-state. We connect on enter() and disconnect on exit(). This is a pattern from our Signal Bus Pattern guide — it prevents signals from firing in the wrong state.
Next, the Chase state. The enemy moves toward the player. If the player gets close enough, it switches to Attack. If the player escapes, it goes back to Idle.
class_name ChaseState
extends State
@export var idle_state: State
@export var attack_state: State
@export var speed: float = 120.0
@export var attack_range: float = 40.0
@export var give_up_range: float = 300.0
var target: Node2D
func enter() -> void:
# Tint red so we can see the state change
owner.get_node("Sprite2D").modulate = Color(1, 0.5, 0.5)
target = owner.get_tree().get_first_node_in_group("player")
func physics_update(delta: float) -> void:
if not target:
state_machine.transition_to(idle_state)
return
var distance = owner.global_position.distance_to(target.global_position)
# Close enough to attack?
if distance <= attack_range:
state_machine.transition_to(attack_state)
return
# Too far away? Give up.
if distance > give_up_range:
state_machine.transition_to(idle_state)
return
# Move toward player
var direction = owner.global_position.direction_to(target.global_position)
owner.velocity = direction * speed
owner.move_and_slide()See how every @export variable — speed, attack range, give-up distance — is tunable from the Inspector? Designers can balance the AI without touching a single line of code.
Finally, the Attack state. The enemy deals damage on a cooldown, then goes back to chasing if the player moves away.
class_name AttackState
extends State
@export var chase_state: State
@export var damage: float = 10.0
@export var cooldown: float = 0.8
@export var disengage_range: float = 60.0
var timer: float = 0.0
func enter() -> void:
timer = 0.0
owner.get_node("Sprite2D").modulate = Color.RED
func update(delta: float) -> void:
timer -= delta
if timer <= 0.0:
_deal_damage()
timer = cooldown
# Check if player moved out of range
var player = owner.get_tree().get_first_node_in_group("player")
if player:
var dist = owner.global_position.distance_to(player.global_position)
if dist > disengage_range:
state_machine.transition_to(chase_state)
func _deal_damage() -> void:
# Use a signal bus for decoupled damage
Events.player_damaged.emit(damage)
print("Enemy attacks for ", damage, " damage!")Notice how the Attack state emits damage through Events.player_damaged — that's the signal bus pattern. The state machine handles behavior; the signal bus handles communication. Together they keep your entire codebase clean.
Extend It — Add New States Without Breaking Anything
The real power of this GDScript state machine pattern is how easy it is to extend. Want a Stunned state? Add a new script, drop it under the StateMachine node, and export-link it from the states that can trigger it:
class_name StunnedState
extends State
@export var idle_state: State
@export var stun_duration: float = 1.5
func enter() -> void:
owner.get_node("Sprite2D").modulate = Color.YELLOW
owner.velocity = Vector2.ZERO
# Recover after the stun timer
await owner.get_tree().create_timer(stun_duration).timeout
state_machine.transition_to(idle_state)Zero existing files modified. Zero tests broken. That await pattern for timed states is incredibly clean — no manual timer tracking, just one line. If you want more await tricks, check our 5 GDScript Tricks article.
This same pattern works everywhere: player controllers (Idle, Run, Jump, Dash, WallSlide), UI screens (MainMenu, Settings, Paused, GameOver), boss phases (Intro, Phase1, Enrage, Death), and even game-level flow (Loading, Playing, Cutscene, Results). One architecture, infinite use cases.
Want to see hierarchical state machines, push-down automata, and state history? Our State Machines course covers advanced patterns for complex AI.
Tips & Best Practices
After building dozens of games with this GDScript state machine, here are the patterns that keep it clean long-term:
- Use @export for state references — don't use
get_node()or string paths. If you rename a node, the Inspector reference still works. - Keep states small — if a state script exceeds 60 lines, it's probably doing too much. Split it or move shared logic into the owner entity.
- Debug with print — add
print("Entering: ", name)in the baseState.enter()to see every transition in the output log. - Combine with signals — use a signal bus for cross-system communication and state machines for within-entity logic.
- Use Tweens for juice — trigger tween animations from
enter(). A scale bounce on attack entry, a flash on stun, a smooth color transition on state change.
Love clean architecture? The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off — perfect for leveling up your entire workflow.
That's your complete Godot 4 state machine — about 30 lines of framework code and a handful of state scripts. No plugins, no addons, no dependencies. Just clean, scalable GDScript that you own entirely.
The base State and StateMachine classes are the same two files I drop into every project. The enemy AI we built today is a starting point — add patrol waypoints, a flee state, or a death animation and you'll see how naturally this pattern grows.
Start with the base classes, build one entity, and you'll never go back to boolean spaghetti.
Get the Free GDScript Cheat Sheet + Weekly Godot Tips
Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.
Ready to go deeper? Our 15-minute micro-courses cover state machines, signals, and more — practical patterns for indie devs who ship.
State Machines in GDScript
Clean game logic with reusable state machines. Perfect for AI, UI, and game flow.
$4.99 · 15 min →Mastering GDScript Signals
Signal buses, typed signals, async patterns — the definitive signals course for Godot 4.
$4.99 · 15 min →Godot Pro Pack Bundle
All courses at a discount. Signals, shaders, state machines, multiplayer, and more.
$12.99 · Save 50%+ →Keep reading
Enjoyed this? Read How to Use the Signal Bus Pattern in Godot 4, Godot 4 Tween Tutorial, and 5 GDScript Tricks Most Devs Don't Know for more patterns. Browse all courses →