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.