【Godot】Managing AI and Player States with State Machines

Created: 2025-12-06

Simple state machine implementation using enum and match. A design pattern for organizing complex character behavior

Overview

As game development progresses, character logic grows increasingly complex. "The player has idle, walk, jump, attack, and damage states." "The enemy has patrol, chase, attack, and wait states." Trying to manage all this with just if statements and bool variables (is_jumping, is_attacking, etc.) quickly turns code into tangled spaghetti—a breeding ground for bugs.

The classic yet powerful design pattern for solving this problem is the State Machine.

What is a State Machine?

A state machine is a model that organizes complex behavior by clearly defining the "states" an object can be in and the conditions for "transitioning" from one state to another.

Core Elements of State Machines:

  1. States: The type of current behavior. Examples: "Idle," "Move," "Attack." Each state knows what to do every frame while in that state (equivalent to _process or _physics_process processing)
  2. Transitions: The trigger for moving from one state to another. Examples: "When player enters range, transition from 'Idle' to 'Chase'" or "When HP hits 0, transition to 'Death' from any state"

Using this model lets you clearly design complex rules—"Can you attack while jumping?" "Can you move while taking damage?"—as combinations of states and transitions.

Simple Implementation in Godot

There are various ways to implement state machines in Godot, but here we'll introduce the simplest and most understandable approach using enum and match statements.

First, define the possible states using enum:

# Enemy.gd
extends CharacterBody2D

# Define states with enum
enum State { IDLE, WANDER, CHASE, ATTACK }

# Variable holding current state
var current_state = State.IDLE

# Reference to player (for chasing)
var player

func _ready():
    # Set initial state
    change_state(State.IDLE)

func _physics_process(delta):
    # Execute processing based on current state every frame
    match current_state:
        State.IDLE:
            idle_state(delta)
        State.WANDER:
            wander_state(delta)
        State.CHASE:
            chase_state(delta)
        State.ATTACK:
            attack_state(delta)

Next, define the central change_state function and functions for processing each state:

# Function to change state
func change_state(new_state):
    current_state = new_state
    # Perform initial setup for new state
    match new_state:
        State.IDLE:
            # Play idle animation, etc.
            $AnimatedSprite2D.play("idle")
            # Transition to WANDER after a delay
            $Timer.start(2.0)
        State.WANDER:
            # Start moving in random direction
            pass
        # ... initial setup for other states ...

# Per-frame processing for each state
func idle_state(delta):
    # Processing while idling (e.g., looking for player)
    if can_see_player():
        change_state(State.CHASE)

func wander_state(delta):
    # Processing while wandering
    # ... movement processing ...
    if can_see_player():
        change_state(State.CHASE)

func chase_state(delta):
    # Processing while chasing
    # ... move toward player ...
    if distance_to_player() < 50:
        change_state(State.ATTACK)
    elif not can_see_player():
        change_state(State.IDLE)

func attack_state(delta):
    # Processing while attacking
    # ... attack animation and hitbox ...
    # Return to chase when attack finishes
    if not $AnimatedSprite2D.is_playing():
        change_state(State.CHASE)

# Processing when timer times out
func _on_timer_timeout():
    if current_state == State.IDLE:
        change_state(State.WANDER)

The key point of this structure is that _physics_process becomes very simple. The main loop just calls the appropriate function based on current state, with each state's logic encapsulated in its own function. Transition logic is also written within each state function, making it clear where state changes occur.

Toward More Advanced Implementation

While this enum and match approach is simple and understandable, it has the drawback of files becoming lengthy as states increase. For larger projects, each state is implemented as a separate class (like IdleState.gd, ChaseState.gd inheriting from State.gd), with the current_state variable holding an instance of that class. This allows complete separation of each state's logic into different files, further improving maintainability.

Summary

State machines are powerful design tools for organizing complex behavior. When character logic starts getting complex with nested if statements, that's the perfect time to introduce a state machine.

  • Define States with enum
  • Write Transition logic within each state's processing function
  • In _physics_process, just call the function for the current state

Mastering these basics will make your game's AI and player character code remarkably clean and extensible.