【Godot】Managing AI and Player States with State Machines

Created: 2025-12-06Last updated: 2025-12-16

Learn how to organize complex character behavior, from simple state machines using enum and match statements to advanced design patterns with state classes.

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 MistakeBest Practice
Overusing if-elif-elseActively use match statements. match clarifies code intent and makes completeness checking easier.
Giant state functionsSplit functions finely. For example, within _chase_state, separate movement processing, detection processing, and transition decisions into separate helper functions.
Scattered transition logicCentralize transition processing in the change_state function. Perform initialization and cleanup when state changes in this function for consistency.
Directly modifying state variablesAlways change state through the change_state function. This prevents unintended state transitions and makes debugging easier.
Not using timers or signalsUse 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/match and state class approaches have virtually no performance impact. match statements often run faster than if-elif chains, 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 enum and match.
  • Advanced: Split logic into files with state classes to maximize maintainability.
  • Rule: Centralize transitions with a change_state function.