[Godot] Managing AI and Player States with a State Machine

Created: 2025-12-06Last updated: 2026-07-07

How to organize player and enemy AI states in Godot with a state machine, from the enum/match approach up to state classes, with practical examples.

You add "idle", "patrol", "chase", and "attack" to an enemy AI and the if statements keep multiplying. You add "move", "jump", "attack", and "take damage" to the player and suddenly is_attacking and is_damaged are both true, producing strange behavior.

A state machine is what sorts out this kind of state-management mess. It looks like an intimidating design pattern, but just deciding "there is exactly one current state" already helps a lot.

Illustration of a state machine as several state cards connected by arrows

What You'll Learn

  • Why managing state with bools falls apart
  • Building a small state machine with enum and match
  • Implementing Idle / Patrol / Chase / Attack for an enemy AI
  • How to decide when to move to state classes
Sponsored


A State Machine Means "Exactly One Current State"

A state machine is a way of organizing the states a character or enemy can be in, and the conditions under which those states switch.

State transition diagram for an enemy AI with Idle, Patrol, Chase, and Attack

For an enemy AI, you might think of it like this.

  • Idle: Wait in place.
  • Patrol: Walk around a set area.
  • Chase: Spot the player and go after them.
  • Attack: Close enough, so attack.

The key point is that the enemy is never in Patrol and Attack at the same time. You pick exactly one current state and separate the logic per state.


Avoiding bool Hell

Starting with nothing but bool state is easy at first.

var is_jumping := false
var is_attacking := false
var is_damaged := false

But as states pile up, the combinations explode: "what if I take damage mid-attack?", "what if a jump input arrives while damaged?", "what if the attack animation ends while dying?"

Comparison of state management tangled up in bool combinations versus state management organized with States

A state machine keeps only one current state.

enum State {
    IDLE,
    RUN,
    ATTACK,
    DAMAGE,
}

var current_state: State = State.IDLE

That alone makes "am I attacking or taking damage right now?" unambiguous.


Hands-On: Building an Enemy AI with enum and match

Let's start small and build an enemy AI with enum and match.

# Enemy.gd
extends CharacterBody2D
class_name Enemy

enum State {
    IDLE,
    PATROL,
    CHASE,
    ATTACK,
}

@export var speed: float = 80.0
@export var attack_range: float = 32.0

var current_state: State = State.IDLE
var player: Node2D
var patrol_direction := Vector2.RIGHT

@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var idle_timer: Timer = $IdleTimer

func _ready() -> void:
    # Route even the initial state through change_state() so entry logic is consistent
    change_state(State.IDLE)

func _physics_process(delta: float) -> void:
    # This function only dispatches to the per-state logic
    match current_state:
        State.IDLE:
            _state_idle(delta)
        State.PATROL:
            _state_patrol(delta)
        State.CHASE:
            _state_chase(delta)
        State.ATTACK:
            _state_attack(delta)

Drawn as a diagram, this code just reads current_state and lets match dispatch to the matching state function.

Diagram showing match reading current_state and dispatching to per-state functions such as _state_idle and _state_chase

Keep _physics_process() as pure traffic control. Pushing per-state logic into separate functions keeps things readable.

func _state_idle(delta: float) -> void:
    velocity = Vector2.ZERO

    # Switch to chasing once the player is spotted
    if can_see_player():
        change_state(State.CHASE)

func _state_patrol(delta: float) -> void:
    velocity = patrol_direction * speed
    move_and_slide()

    if can_see_player():
        change_state(State.CHASE)

func _state_chase(delta: float) -> void:
    if player == null:
        # Go back to patrolling if there's nothing to chase
        change_state(State.PATROL)
        return

    var direction := global_position.direction_to(player.global_position)
    velocity = direction * speed
    move_and_slide()

    if global_position.distance_to(player.global_position) <= attack_range:
        # Switch to attacking once inside attack range
        change_state(State.ATTACK)
    elif not can_see_player():
        change_state(State.PATROL)

func _state_attack(delta: float) -> void:
    velocity = Vector2.ZERO

    if player == null:
        change_state(State.PATROL)
        return

    if global_position.distance_to(player.global_position) > attack_range:
        change_state(State.CHASE)

As states multiply, it stays obvious which function a piece of logic belongs in.


Funnel Transitions Through change_state

When changing state, don't write current_state = State.CHASE directly. Go through a dedicated function.

Transition lifecycle diagram showing change_state() always running exit (_exit_state), then updating the state, then entry (_enter_state) in the same order
func change_state(next_state: State) -> void:
    if current_state == next_state:
        return

    # Always run exit, state update, and entry in this same order
    _exit_state(current_state)
    current_state = next_state
    _enter_state(current_state)

Separate what happens the moment you enter a state from what happens when you leave it.

func _enter_state(state: State) -> void:
    # Everything that should run only on entering a state
    match state:
        State.IDLE:
            sprite.play("idle")
            idle_timer.start(1.5)
        State.PATROL:
            sprite.play("walk")
        State.CHASE:
            sprite.play("run")
        State.ATTACK:
            sprite.play("attack")

func _exit_state(state: State) -> void:
    # Everything that cleans up when leaving a state
    if state == State.IDLE:
        idle_timer.stop()

With this shape, things like "play the animation the instant we enter the attack state" and "stop the Timer when leaving idle" all live in one place.

func _on_idle_timer_timeout() -> void:
    # Even when triggered by a Timer, check the current state before transitioning
    if current_state == State.IDLE:
        change_state(State.PATROL)

When Timers or signals change the state, always route them through change_state() too.


When to Split Into State Classes

enum + match is ideal for starting small. Once per-state logic gets long, though, consider splitting each state into its own file.

Comparison of starting small with enum and match versus splitting into State classes as things get complex

With state classes, each state becomes its own file, like IdleState.gd and ChaseState.gd.

# State.gd
class_name State
extends Node

var owner_enemy: Enemy

func enter() -> void:
    pass

func exit() -> void:
    pass

func physics_update(delta: float) -> void:
    pass

ChaseState is responsible for chasing and nothing else.

# ChaseState.gd
extends State

func enter() -> void:
    owner_enemy.sprite.play("run")

func physics_update(delta: float) -> void:
    if owner_enemy.player == null:
        owner_enemy.change_state("Patrol")
        return

    var direction := owner_enemy.global_position.direction_to(owner_enemy.player.global_position)
    owner_enemy.velocity = direction * owner_enemy.speed
    owner_enemy.move_and_slide()

    if owner_enemy.can_attack_player():
        owner_enemy.change_state("Attack")

The class approach adds files, so it feels a bit heavy up front. With around four states and short logic, enum + match is often the more readable option.


How This Relates to AnimationTree

Godot's AnimationTree also has a StateMachine. That one primarily manages animation state.

The state machine in this article manages game logic state, like enemy AI or player control. In real projects, you switch the AnimationTree's animation at the moment the logic state changes.

func _enter_state(state: State) -> void:
    match state:
        State.IDLE:
            animation_tree["parameters/playback"].travel("Idle")
        State.CHASE:
            animation_tree["parameters/playback"].travel("Run")
        State.ATTACK:
            animation_tree["parameters/playback"].travel("Attack")

Detailed control on the animation side leads into AnimationTree and State Machines.


Common Pitfalls

Writing to the State Variable Directly

Scattering current_state = State.ATTACK around your code means entry and exit logic such as playing animations or stopping Timers gets skipped. Funnel state changes through change_state().

One State Function Balloons

If _state_chase() passes 100 lines, split detection, movement, and attack checks into helper functions. If that still hurts, move to state classes.

Turning Everything Into a State Machine

You don't need a full state machine for simple states like a switch's on/off or a chest that opens once. With only two states and little branching, a bool like is_opened is plenty.

Confusing State With Animation

Being in the Attack state doesn't necessarily mean exactly one attack animation. Combo attacks, weapon types, and facing direction can all split the animation. Keep logic state and animation state as separate concepts when you need to.


Bonus: When to Consider a Behavior Tree

State machines are good at organizing "what state am I in right now?" But once an enemy AI starts choosing actions by priority, like "hide", "heal", "call for help", or "flank the player", pure state transitions get complicated.

At that point, a behavior tree becomes a candidate. It's a design where a tree structure decides "given these conditions, which action takes priority?" In the Godot learning path, we cover it later in Designing AI with Behavior Trees.


Summary

A state machine is the fundamental pattern for organizing player and enemy AI states.

  • Consider adopting it once bools like is_attacking and is_damaged pile up.
  • Start small with enum and match.
  • Funnel state changes through change_state().
  • Split into State classes once per-state logic gets big.
  • Keep it conceptually separate from AnimationTree's StateMachine.

The test is: "Can I settle on exactly one current state?" If you can, a state machine works well. If your AI is instead picking among several conditions by priority, it's worth looking at behavior trees.