State Machines in Godot 4: From Beginner to Boss AI

Master finite state machines in Godot 4. Learn enum-based and node-based patterns, implement enemy AI with patrol/chase/attack states, and integrate pathfinding.

State machine diagram showing enemy AI states in Godot

State machines are one of the most important patterns in game development. They keep your code organized, prevent impossible states, and make complex behaviors manageable. In this tutorial, we'll build from simple enum-based FSMs to full enemy AI.

What is a State Machine?

A finite state machine (FSM) is a pattern where an entity can only be in one state at a time. A player might be IDLE, WALKING, JUMPING, or ATTACKING—but never two at once. Transitions happen based on conditions (pressed jump, touched ground, etc.).

Simple Enum-Based State Machine

For simple cases with 3-5 states, an enum-based approach is clean and straightforward.

gdscript
extends CharacterBody2D

enum State { IDLE, WALK, JUMP, FALL }
var current_state: State = State.IDLE

var speed = 200.0
var jump_force = -400.0
var gravity = 980.0

func _physics_process(delta):
    # Apply gravity
    velocity.y += gravity * delta

    # Get input
    var direction = Input.get_axis("move_left", "move_right")

    # State machine
    match current_state:
        State.IDLE:
            velocity.x = 0
            if direction != 0:
                change_state(State.WALK)
            if Input.is_action_just_pressed("jump") and is_on_floor():
                change_state(State.JUMP)

        State.WALK:
            velocity.x = direction * speed
            if direction == 0:
                change_state(State.IDLE)
            if Input.is_action_just_pressed("jump") and is_on_floor():
                change_state(State.JUMP)
            if not is_on_floor():
                change_state(State.FALL)

        State.JUMP:
            velocity.x = direction * speed
            if velocity.y > 0:
                change_state(State.FALL)

        State.FALL:
            velocity.x = direction * speed
            if is_on_floor():
                change_state(State.IDLE if direction == 0 else State.WALK)

    move_and_slide()

func change_state(new_state: State):
    # Exit current state
    match current_state:
        State.JUMP:
            pass  # Could stop jump animation

    current_state = new_state

    # Enter new state
    match new_state:
        State.JUMP:
            velocity.y = jump_force
        State.IDLE:
            $AnimationPlayer.play("idle")
        State.WALK:
            $AnimationPlayer.play("walk")

Node-Based State Machine Pattern

For complex AI or when states have their own variables and logic, a node-based approach is more maintainable. Each state is a separate node with its own script.

gdscript
# state_machine.gd - Attach to a Node called StateMachine
extends Node
class_name StateMachine

@export var initial_state: State
var current_state: State

func _ready():
    # Initialize all state children
    for child in get_children():
        if child is State:
            child.state_machine = self
            child.process_mode = Node.PROCESS_MODE_DISABLED

    # Start initial state
    if initial_state:
        change_state(initial_state)

func _process(delta):
    if current_state:
        current_state.update(delta)

func _physics_process(delta):
    if current_state:
        current_state.physics_update(delta)

func change_state(new_state: State):
    if current_state:
        current_state.exit()
        current_state.process_mode = Node.PROCESS_MODE_DISABLED

    current_state = new_state
    current_state.process_mode = Node.PROCESS_MODE_INHERIT
    current_state.enter()
gdscript
# state.gd - Base class for all states
extends Node
class_name State

var state_machine: StateMachine

func enter() -> void:
    pass

func exit() -> void:
    pass

func update(_delta: float) -> void:
    pass

func physics_update(_delta: float) -> void:
    pass
Unity Animator States
// States defined in Animator Controller
// Transitions via parameters
animator.SetBool("isWalking", true);
animator.SetTrigger("attack");
Godot Code FSM
# States in code or as nodes
# Direct control over transitions
state_machine.change_state(walk_state)
state_machine.change_state(attack_state)

Enemy AI States

Let's implement a classic enemy AI with Idle, Patrol, Chase, and Attack states.

gdscript
# enemy_idle_state.gd
extends State

@export var idle_time: float = 2.0
var timer: float = 0.0

func enter():
    timer = idle_time
    get_parent().get_parent().velocity = Vector2.ZERO  # Stop moving

func update(delta):
    timer -= delta
    if timer <= 0:
        state_machine.change_state($"../Patrol")

    # Check for player
    var enemy = get_parent().get_parent()
    if enemy.can_see_player():
        state_machine.change_state($"../Chase")
gdscript
# enemy_patrol_state.gd
extends State

@export var patrol_speed: float = 100.0
var direction: int = 1

func enter():
    direction = 1 if randf() > 0.5 else -1

func physics_update(delta):
    var enemy = get_parent().get_parent()
    enemy.velocity.x = direction * patrol_speed

    # Turn at walls or edges
    if enemy.is_on_wall() or not enemy.is_on_floor_ahead():
        direction *= -1

    enemy.move_and_slide()

    # Check for player
    if enemy.can_see_player():
        state_machine.change_state($"../Chase")
gdscript
# enemy_chase_state.gd
extends State

@export var chase_speed: float = 150.0
@export var attack_range: float = 50.0

func physics_update(delta):
    var enemy = get_parent().get_parent()
    var player = enemy.player_ref

    if not player:
        state_machine.change_state($"../Idle")
        return

    # Move toward player
    var direction = sign(player.position.x - enemy.position.x)
    enemy.velocity.x = direction * chase_speed
    enemy.move_and_slide()

    # Attack if close enough
    var distance = enemy.position.distance_to(player.position)
    if distance < attack_range:
        state_machine.change_state($"../Attack")

    # Lost sight of player
    if not enemy.can_see_player():
        state_machine.change_state($"../Idle")

Adding Pathfinding with NavigationAgent2D

For smarter AI that navigates around obstacles, use Godot's built-in navigation system.

gdscript
# enemy_chase_state.gd (with navigation)
extends State

@export var chase_speed: float = 150.0
@onready var nav_agent: NavigationAgent2D = get_parent().get_parent().get_node("NavigationAgent2D")

func enter():
    nav_agent.path_desired_distance = 4.0
    nav_agent.target_desired_distance = 4.0

func physics_update(delta):
    var enemy = get_parent().get_parent()
    var player = enemy.player_ref

    if not player:
        state_machine.change_state($"../Idle")
        return

    # Update navigation target
    nav_agent.target_position = player.global_position

    if nav_agent.is_navigation_finished():
        state_machine.change_state($"../Attack")
        return

    # Get next point on path
    var next_pos = nav_agent.get_next_path_position()
    var direction = enemy.global_position.direction_to(next_pos)
    enemy.velocity = direction * chase_speed
    enemy.move_and_slide()

When to Use AnimationTree Instead

AnimationTree has its own StateMachine node type. Use it when your states are primarily about animation blending (like character locomotion with walk/run/sprint). Use code-based FSMs when states involve complex game logic beyond animation.

Key Takeaways

  • Start with enum-based FSMs for simple behaviors
  • Graduate to node-based when states become complex
  • Use signals to decouple state machines from other systems
  • NavigationAgent2D handles pathfinding automatically
  • Each state should handle its own enter/exit/update logic
👤
Godot Learning Team Helping developers transition to Godot with practical tutorials and comparisons.