Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. GDScript
  3. GDScript State Machines Practice
GDScript0 exercises

GDScript State Machines Practice

GDScript state machine patterns for Godot 4: enum, node-based, and class-based. Player movement states, enemy AI, game state management, enter/exit hooks, and signal-driven transitions.

Common ErrorsQuick Reference
On this page
  1. 1When do you need a state machine?
  2. 2How do you build an enum state machine?
  3. 3How do you build a node-based state machine?
  4. 4How do you build a class-based state machine?
  5. 5How do signals integrate with state machines?
  6. 6Should I use AnimationTree or a code state machine?
  7. 7Which pattern should I use?
When do you need a state machine?How do you build an enum state machine?How do you build a node-based state machine?How do you build a class-based state machine?How do signals integrate with state machines?Should I use AnimationTree or a code state machine?Which pattern should I use?

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.

Related GDScript Topics
GDScript MovementGDScript SignalsGDScript FoundationsGDScript CollisionsGDScript Arrays & Loops

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_process starts with if is_dead: return and if 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.

Ready to practice?

Start practicing GDScript State Machines with spaced repetition

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.

StatesPatternExample
2-4, simpleEnum + matchMenu states, door open/closed
5-10, moderateNode-basedPlayer movement, enemy AI
10+, hierarchicalHierarchical FSMSub-states (Grounded > Idle/Run/Crouch)
StackablePushdown automatonPause menu over gameplay
Complex AIBehavior 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

Implementing enum state machines with matchBuilding State and StateMachine base classesWiring transitions with signalsEnter/exit hooks for animation and cleanupChoosing the right pattern for the complexity level

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

Enum state machine
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)
State base class
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 node
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()
State transition signal
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")
Enter/exit hooks with signals
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")

Further Reading

  • GDScript Dictionary map() and map_in_place12 min read
  • Facade Pattern in Godot 4 GDScript: Taming "End Turn" Spaghetti12 min read

Start practicing GDScript State Machines

Free daily exercises with spaced repetition. No credit card required.

← Back to GDScript Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.