Free Tutorial

Godot 4 State Machine Tutorial
Clean, Scalable, No Plugins

SScriptSnap/March 2026/10 min read

Every game hits a point where if/elif chains for character behavior spiral out of control. Your player script has 400 lines. Your enemy AI is a maze of boolean flags. Adding a new state breaks two old ones.

The fix is a state machine — and you don't need a plugin for it. In this Godot 4 state machine tutorial, you'll build a clean, reusable finite state machine (FSM) in pure GDScript. We'll then wire it up to a real enemy AI with Idle, Chase, and Attack states.

By the end, you'll have an architecture pattern you can drop into any project — player controllers, UI flows, boss phases, menu systems — and never write another tangled match block again.

The Pattern
1

Why You Need a State Machine

If you've ever written enemy logic like this, you know the pain:

messy_enemy.gd
extends CharacterBody2D

var is_chasing: bool = false
var is_attacking: bool = false
var is_stunned: bool = false

func _physics_process(delta):
    if is_stunned:
        return
    if is_attacking and not is_chasing:
        # attack logic...
    elif is_chasing and not is_attacking:
        # chase logic...
    else:
        # idle... maybe? who knows

Three booleans means eight possible combinations. Add a fourth flag and you're at sixteen. A GDScript state machine replaces all of this with a single concept: the entity is always in exactly one state, and each state owns its own logic.

The result? Each state is its own script. Small, testable, self-contained. Adding new behavior means adding a new file — not touching existing code. That's the Open/Closed Principle at work in your game.

2

Create the Base State Class

Every state in our machine will extend this base class. It defines the interface — the lifecycle hooks that every concrete state must implement. Think of it as an abstract contract.

state.gd
class_name State
extends Node

# Reference to the state machine (set by StateMachine)
var state_machine: StateMachine = null

# Called when entering this state
func enter() -> void:
    pass

# Called when leaving this state
func exit() -> void:
    pass

# Called every frame (like _process)
func update(delta: float) -> void:
    pass

# Called every physics frame (like _physics_process)
func physics_update(delta: float) -> void:
    pass

The key design choice: State extends Node. That means each state lives in the scene tree as a child of the state machine. You can inspect states in the editor, toggle them on and off for debugging, and use @export to tweak per-state settings in the Inspector. If you're not familiar with the export system, check out our Godot 4 @export Tips guide.

3

Build the State Machine Controller

The state machine itself is also a Node. Its job is simple: track the current state, delegate lifecycle calls, and handle transitions. No enums. No match statements. Just clean delegation.

state_machine.gd
class_name StateMachine
extends Node

# Set this in the Inspector to pick the starting state
@export var initial_state: State

var current_state: State

func _ready() -> void:
    # Give every child state a reference back to us
    for child in get_children():
        if child is State:
            child.state_machine = self

    # Enter the initial state
    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)

# Call this to switch states
func transition_to(new_state: State) -> void:
    if new_state == current_state:
        return
    if current_state:
        current_state.exit()
    current_state = new_state
    current_state.enter()

That's it. About 30 lines of code and your state machine is done. The transition_to() method handles the full exit → switch → enter lifecycle. The guard clause prevents re-entering the same state, which avoids resetting timers and animations.

Want a video walkthrough of this exact pattern? Our State Machines in GDScript course covers FSMs, hierarchical states, and game flow — all in 15 minutes.

Take the course — $4.99 →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime
4

Wire It Up in the Scene Tree

Here's the scene tree structure. The StateMachine node is a child of your entity, and each state is a child of the state machine:

scene_tree
Enemy (CharacterBody2D)
  ├── Sprite2D
  ├── CollisionShape2D
  ├── DetectionArea (Area2D)
  └── StateMachine (state_machine.gd)
      ├── Idle (idle_state.gd)
      ├── Chase (chase_state.gd)
      └── Attack (attack_state.gd)

In the Inspector, set the initial_state export on the StateMachine node to point at the Idle node. That's all the configuration needed — no code changes, no hardcoded paths.

This structure means you can duplicate an enemy scene, swap out state scripts, and get entirely different AI behavior. A patrolling guard and a charging brute can share the same state machine skeleton.

5

Real Example — Enemy AI States

Let's build three concrete states for an enemy that idles, chases the player when they get close, and attacks when in range. This is the exact pattern you'll use in your own games.

First, the Idle state. The enemy stands still and waits for a target to enter its detection range.

idle_state.gd
class_name IdleState
extends State

@export var chase_state: State
@export var detection_area: Area2D

func enter() -> void:
    # Play idle animation
    owner.get_node("Sprite2D").modulate = Color.WHITE
    # Connect detection signal
    detection_area.body_entered.connect(_on_body_detected)

func exit() -> void:
    # Disconnect so we don't double-connect later
    detection_area.body_entered.disconnect(_on_body_detected)

func _on_body_detected(body: Node2D) -> void:
    if body.is_in_group("player"):
        state_machine.transition_to(chase_state)

Notice how signal connections are managed per-state. We connect on enter() and disconnect on exit(). This is a pattern from our Signal Bus Pattern guide — it prevents signals from firing in the wrong state.

Next, the Chase state. The enemy moves toward the player. If the player gets close enough, it switches to Attack. If the player escapes, it goes back to Idle.

chase_state.gd
class_name ChaseState
extends State

@export var idle_state: State
@export var attack_state: State
@export var speed: float = 120.0
@export var attack_range: float = 40.0
@export var give_up_range: float = 300.0

var target: Node2D

func enter() -> void:
    # Tint red so we can see the state change
    owner.get_node("Sprite2D").modulate = Color(1, 0.5, 0.5)
    target = owner.get_tree().get_first_node_in_group("player")

func physics_update(delta: float) -> void:
    if not target:
        state_machine.transition_to(idle_state)
        return

    var distance = owner.global_position.distance_to(target.global_position)

    # Close enough to attack?
    if distance <= attack_range:
        state_machine.transition_to(attack_state)
        return

    # Too far away? Give up.
    if distance > give_up_range:
        state_machine.transition_to(idle_state)
        return

    # Move toward player
    var direction = owner.global_position.direction_to(target.global_position)
    owner.velocity = direction * speed
    owner.move_and_slide()

See how every @export variable — speed, attack range, give-up distance — is tunable from the Inspector? Designers can balance the AI without touching a single line of code.

Finally, the Attack state. The enemy deals damage on a cooldown, then goes back to chasing if the player moves away.

attack_state.gd
class_name AttackState
extends State

@export var chase_state: State
@export var damage: float = 10.0
@export var cooldown: float = 0.8
@export var disengage_range: float = 60.0

var timer: float = 0.0

func enter() -> void:
    timer = 0.0
    owner.get_node("Sprite2D").modulate = Color.RED

func update(delta: float) -> void:
    timer -= delta
    if timer <= 0.0:
        _deal_damage()
        timer = cooldown

    # Check if player moved out of range
    var player = owner.get_tree().get_first_node_in_group("player")
    if player:
        var dist = owner.global_position.distance_to(player.global_position)
        if dist > disengage_range:
            state_machine.transition_to(chase_state)

func _deal_damage() -> void:
    # Use a signal bus for decoupled damage
    Events.player_damaged.emit(damage)
    print("Enemy attacks for ", damage, " damage!")

Notice how the Attack state emits damage through Events.player_damaged — that's the signal bus pattern. The state machine handles behavior; the signal bus handles communication. Together they keep your entire codebase clean.

6

Extend It — Add New States Without Breaking Anything

The real power of this GDScript state machine pattern is how easy it is to extend. Want a Stunned state? Add a new script, drop it under the StateMachine node, and export-link it from the states that can trigger it:

stunned_state.gd
class_name StunnedState
extends State

@export var idle_state: State
@export var stun_duration: float = 1.5

func enter() -> void:
    owner.get_node("Sprite2D").modulate = Color.YELLOW
    owner.velocity = Vector2.ZERO

    # Recover after the stun timer
    await owner.get_tree().create_timer(stun_duration).timeout
    state_machine.transition_to(idle_state)

Zero existing files modified. Zero tests broken. That await pattern for timed states is incredibly clean — no manual timer tracking, just one line. If you want more await tricks, check our 5 GDScript Tricks article.

This same pattern works everywhere: player controllers (Idle, Run, Jump, Dash, WallSlide), UI screens (MainMenu, Settings, Paused, GameOver), boss phases (Intro, Phase1, Enrage, Death), and even game-level flow (Loading, Playing, Cutscene, Results). One architecture, infinite use cases.

Want to see hierarchical state machines, push-down automata, and state history? Our State Machines course covers advanced patterns for complex AI.

Take the course — $4.99 →
7

Tips & Best Practices

After building dozens of games with this GDScript state machine, here are the patterns that keep it clean long-term:

  • Use @export for state references — don't use get_node() or string paths. If you rename a node, the Inspector reference still works.
  • Keep states small — if a state script exceeds 60 lines, it's probably doing too much. Split it or move shared logic into the owner entity.
  • Debug with print — add print("Entering: ", name) in the base State.enter() to see every transition in the output log.
  • Combine with signals — use a signal bus for cross-system communication and state machines for within-entity logic.
  • Use Tweens for juice — trigger tween animations from enter(). A scale bounce on attack entry, a flash on stun, a smooth color transition on state change.

Love clean architecture? The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off — perfect for leveling up your entire workflow.

Get the Pro Pack — $12.99 →
>> EOF

That's your complete Godot 4 state machine — about 30 lines of framework code and a handful of state scripts. No plugins, no addons, no dependencies. Just clean, scalable GDScript that you own entirely.

The base State and StateMachine classes are the same two files I drop into every project. The enemy AI we built today is a starting point — add patrol waypoints, a flee state, or a death animation and you'll see how naturally this pattern grows.

Start with the base classes, build one entity, and you'll never go back to boolean spaghetti.

Free download
+ weekly tips

Get the Free GDScript Cheat Sheet + Weekly Godot Tips

Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.