State Machines in Godot

Module 3 ยท Lesson 1 ยท ~45 min ยท Godot 4.x

Game code rots faster than any other code you will write. Without discipline, _process becomes a 400-line cascade of if flags: if is_moving and not is_stunned and can_act and not is_menu_open. State machines are the cure, and they're so useful that you should put one in place before you write a second condition.

The pattern that will save your game

Every game entity with complex behavior has a current state. A card is in_deck, in_hand, being_dragged, in_play_zone, discarded. A duel is player_turn, resolving, enemy_turn, victory, defeat. The entity does completely different things in each state.

Make states explicit. Here's the minimal version:

extends Node
class_name DuelController

enum State { PLAYER_DRAW, PLAYER_PLAY, RESOLVING, ENEMY_TURN, VICTORY, DEFEAT }

var state: State = State.PLAYER_DRAW

func _ready() -> void:
    _enter_state(state)

func transition_to(new_state: State) -> void:
    if new_state == state:
        return
    _exit_state(state)
    state = new_state
    _enter_state(state)

func _enter_state(s: State) -> void:
    match s:
        State.PLAYER_DRAW:
            player.draw_to(7)
            transition_to(State.PLAYER_PLAY)
        State.PLAYER_PLAY:
            ui.show_end_turn_button()
        State.RESOLVING:
            ui.hide_end_turn_button()
            _resolve_played_word()
        State.ENEMY_TURN:
            await enemy.take_turn()
            transition_to(State.PLAYER_DRAW)
        State.VICTORY:
            ui.show_victory_screen()
        State.DEFEAT:
            ui.show_defeat_screen()

func _exit_state(_s: State) -> void:
    pass  # cleanup hooks go here if needed

Three functions: transition_to, _enter_state, _exit_state. Every change goes through transition_to. Every state's entry logic lives in one place. It's almost embarrassingly simple. It'll carry you through a 30-hour game.

This is a Redux reducer with a one-key state slice and transition hooks. If you can read switch(action.type), you can read this. The reason it works in game dev is that games spend 90% of their time in a state and 10% transitioning โ€” so optimizing transitions matters.

Why enums beat strings

You'll see tutorials use strings for state names. Don't. Enums are type-checked (typos become compile errors), autocomplete works, renaming is safe.

# BAD
state = "player_turn"
if state == "Player_Turn": ...  # typo, silently broken

# GOOD
state = State.PLAYER_TURN
if state == State.PLAYER_TURN: ...  # compile error on typo

Hierarchical state machines (when you need them)

Above a certain complexity you'll want sub-states. Example: during PLAYER_PLAY, a card can be idle, being_dragged, or in_target_slot. You don't want to write one flat enum with every combination.

The simplest hierarchy is nested state machines. The Duel has a big state machine. Each Card has its own small one. They don't know about each other. The Duel state machine only cares that some card has been played (signal), not which card's internal state it's in.

# Card's internal state machine
extends Control
class_name Card

enum CardState { IN_HAND, DRAGGING, IN_SLOT, PLAYED, DISCARDED }

var card_state: CardState = CardState.IN_HAND

signal entered_slot(card: Card)

func transition_to(s: CardState) -> void:
    if s == card_state: return
    card_state = s
    match s:
        CardState.DRAGGING:
            modulate.a = 0.7
            z_index = 100
        CardState.IN_SLOT:
            modulate.a = 1.0
            z_index = 0
            entered_slot.emit(self)
        CardState.PLAYED:
            _animate_to_play_zone()
        CardState.DISCARDED:
            queue_free()

Each layer of the hierarchy owns its own state. The outer state machine listens for signals from the inner ones.

The "State as Node" pattern

For complex entities (boss enemies, protagonists with dozens of abilities) even a single enum gets unwieldy. The next level up is making each state its own node, with the entity's base script managing which child-state is active.

Player (CharacterBody2D)
โ”œโ”€โ”€ States (Node)
โ”‚   โ”œโ”€โ”€ Idle (Node, script idle_state.gd)
โ”‚   โ”œโ”€โ”€ Running (Node, script running_state.gd)
โ”‚   โ”œโ”€โ”€ Attacking (Node, script attacking_state.gd)
โ”‚   โ””โ”€โ”€ Stunned (Node, script stunned_state.gd)
# base state class
class_name State
extends Node

var owner_entity: Node

func enter() -> void: pass
func exit() -> void: pass
func process(_delta: float) -> void: pass
func handle_input(_event: InputEvent) -> void: pass

# state machine manager
class_name StateMachine
extends Node

@export var initial: State
var current: State

func _ready() -> void:
    for child in get_children():
        if child is State:
            child.owner_entity = get_parent()
    current = initial
    current.enter()

func transition_to(new_state: State) -> void:
    if new_state == current: return
    current.exit()
    current = new_state
    current.enter()

func _process(delta: float) -> void:
    if current: current.process(delta)

For Lexicon Duel's scope, you probably don't need this level of ceremony. Start with the enum-based state machine. Refactor to State-as-Node only if the enum cases grow beyond ~8 or one state needs its own inner state.

Judgement call When in doubt, stay flat. Premature abstraction is the #2 killer of solo indie projects (#1 is shipping. #3 is scope creep). The flat enum state machine works for a remarkable amount of game.

Signals as the transition trigger

Internal logic rarely calls transition_to directly. More often, a signal from somewhere else triggers the transition:

func _ready() -> void:
    player_hand.word_submitted.connect(_on_word_submitted)
    end_turn_button.pressed.connect(_on_end_turn_pressed)

func _on_word_submitted(word: String) -> void:
    if state == State.PLAYER_PLAY:
        transition_to(State.RESOLVING)

func _on_end_turn_pressed() -> void:
    if state == State.PLAYER_PLAY:
        transition_to(State.ENEMY_TURN)

Notice the guard: if state == State.PLAYER_PLAY. The button can only fire when we're in a state that cares. Every signal handler should check the state it's valid in. This is how you keep the state machine authoritative instead of advisory.

Debugging state machines

Print every transition:

func transition_to(new_state: State) -> void:
    if new_state == state: return
    print("[DuelController] %s -> %s" % [State.keys()[state], State.keys()[new_state]])
    _exit_state(state)
    state = new_state
    _enter_state(new_state)

State.keys()[state] converts the enum int back to its name. Cheap, readable logs. Leave these in; you'll thank yourself when a weird bug happens at 11pm.

Do this now

  1. Create scripts/duel_controller.gd with the first code block (DuelController with 6 states).
  2. Make it a child of Main. You don't need player/enemy/ui wired up yet โ€” just stub them out with # TODO inside _enter_state.
  3. From the _ready, trigger transition_to(State.PLAYER_PLAY) manually and watch the log print transitions.
  4. In lesson 3's exercise you'll expand this into the full duel controller.