wrote a Godot 4 state machine that actually scales past 3 states

35 views 2 replies

ngl this took way longer to get right than i expected. every time i built a state machine for a project it'd start clean and then devolve into a mess of if current_state == STATE_X and previous_state == STATE_Y checks scattered everywhere. so i finally sat down and wrote one i actually want to reuse.

the core idea: each state is its own class that extends a base State node. the machine itself is just a coordinator. states don't talk to each other directly — they emit a transition_requested signal with the target state name, and the machine handles the swap. no spaghetti.

# state.gd — base class
class_name State
extends Node

signal transition_requested(next_state: StringName)

func enter(_previous_state: StringName) -> void:
	pass

func exit(_next_state: StringName) -> void:
	pass

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

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

func handle_input(_event: InputEvent) -> void:
	pass
# state_machine.gd
class_name StateMachine
extends Node

@export var initial_state: State

var current_state: State
var states: Dictionary = {}

func _ready() -> void:
	for child in get_children():
		if child is State:
			states[child.name] = child
			child.transition_requested.connect(_on_transition_requested)
	_transition_to(initial_state.name)

func _process(delta: float) -> void:
	current_state.update(delta)

func _physics_process(delta: float) -> void:
	current_state.physics_update(delta)

func _unhandled_input(event: InputEvent) -> void:
	current_state.handle_input(event)

func _transition_to(state_name: StringName) -> void:
	var previous := current_state.name if current_state else &""
	if current_state:
		current_state.exit(state_name)
	current_state = states[state_name]
	current_state.enter(previous)

func _on_transition_requested(next_state: StringName) -> void:
	_transition_to(next_state)

you attach StateMachine as a child of your character node, then add individual state nodes (like IdleState, RunState, AttackState) as children of the machine. each state gets a reference to the character via @export or by walking the tree on _ready.

the enter/exit hooks getting the adjacent state name is the part i'm happiest with — it means an AttackState can play a different recovery animation depending on whether it's going back to idle or into a roll, without needing cross-state references.

one thing i'm still unsure about: i've seen people handle input entirely inside states, and others who route it through an input manager first and pass processed actions down. curious what approach people here are using, especially once you start dealing with input buffering on top of this.

one thing worth adding to the state machine discussion: transition conditions as callables rather than enum comparisons make the system much easier to extend. instead of a big match block that checks state flags, each transition holds a reference to a function that returns bool. you can compose conditions, invert them, and add new ones without touching the core FSM code at all.

class Transition:
    var to_state: StringName
    var condition: Callable

    func should_fire() -> bool:
        return condition.call()

keeps the state definitions clean and the transition logic close to wherever it actually belongs in your data.

the enter/exit hooks receiving the adjacent state name is something i've been doing for a while and it's such a small thing that pays off a lot. one extension i added on top of that pattern: an optional can_transition_to(next_state: StringName) -> bool method on each state. the machine calls it before doing the swap, and the state itself can reject the transition. useful for things like an attack state that shouldn't be cancellable mid-windup — instead of checking flags in the machine coordinator, the state owns that logic itself.