Motive

I've spent a chunk of the last few weeks down a State Machine rabbit hole in Godot, and figured I'd write up what I landed on. I read through a bunch of different takes on this (sources are linked at the bottom if you want to go down the same rabbit hole), stole the parts I liked from each, and mashed them together into what's below. So fair warning, this is my opinionated take, not the one true way to do it.

State Machines

A State Machine is just a pattern for managing state without it turning into a mess. You've got a set of states, transitions between them, and some logic that runs when you enter, exit, or move between states.

They're genuinely great for managing complex behavior. A few reasons why:

  • Improved organization and maintainability
  • States are decoupled from each other. To extend behavior, only the relevant state needs to be touched
  • Transitions are explicit and easy to follow, which helps debugging

State Machines are best when an object only has a handful of distinct states it can be in. If you've ever written a massive _physics_process with a ton of nested if-statements checking whether the player is jumping, running, attacking, or stunned all at once, that's exactly what these fix.

Two Approaches

There are two main ways I've seen people do this in Godot: the Enum approach and the Node approach. Enum is simpler and works fine for small cases. Node is more powerful, but it comes with more overhead: more nodes in the scene tree, more setup, more jumping around when you're debugging. Honestly, if you're not reusing state logic or don't need each state fully encapsulated, Node is probably overkill. Start with Enum and only upgrade once you actually feel the pain.

Enum State Machine

To build the Enum version, define your states as an enum, track the current one in a variable, and use a match block in _process to run whatever logic belongs to that frame. A changeState() function handles the actual transition, fires a signal, and is where any enter/exit logic goes. Here's what that looks like:

This is great for simple cases: a door that's open or closed, a switch with a few modes, anything with maybe 2-5 states and nothing complicated happening per-state. It stays lightweight and easy to read while still giving you some structure. Once your states start needing their own helper functions, or you catch yourself copying transition logic across multiple objects, that's your sign to move to the Node approach.

extends Node

# simple enum state machine
# good for small, self-contained state logic

enum EnumState {
	On,
	Off
}

signal stateChanged(old, new)

var currentState
var prevState

func _ready() -> void:
	currentState = EnumState.Off

func _process(delta: float) -> void:
	match currentState:
		EnumState.On:
			pass # logic for being on
                        # handle_on_state(delta) <- optional helper functions to keep it clean
		EnumState.Off:
			pass # logic for being off
		_:
			pass

func changeState(newState: EnumState) -> void:
	if currentState == newState:
		return

	# exit logic for the old state
	match currentState:
		EnumState.On:
			pass # e.g. stop_effects()
		EnumState.Off:
			pass

	prevState = currentState
	currentState = newState

	# enter logic for the new state
	match currentState:
		EnumState.On:
			pass # e.g. start_effects()
		EnumState.Off:
			pass

	stateChanged.emit(prevState, newState)

Node State Machine

The Node approach gives each state its own script and its own node in the scene tree. A central StateMachine node manages whatever the current state is, forwards _process/_physics_process/input calls down to it, and handles transitions. Every state ends up fully self-contained, which makes the whole thing a lot easier to extend later.

There are three moving pieces: the State base class, the StateMachine manager, and the individual state scripts. The player (or whatever you're managing) just owns the StateMachine as a child node and forwards its process calls into it.


State Base Class

Each state extends the base class below, which just defines the interface: enter(), exit(), and the three process methods, all empty and overridable. The parent and machine references get set by the StateMachine during setup, so any state can reach the character it's running on and trigger transitions whenever it needs to.

class_name State extends Node

var stateName: String = "BASE"

var parent: Node      # set by the state machine on setup
var machine: StateMachine

func enter() -> void:
	pass

func exit() -> void:
	pass

# processInput is better for event-driven actions (button presses)
# movement-related input usually works better in processPhysics
func processInput(_event: InputEvent) -> void:
	pass

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

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

StateMachine

On setup, the StateMachine loops through its children, registers each one by name, and sets the parent and machine references on all of them. After that it just tracks the current state and routes process calls into it. changeState() calls exit() on whatever the old state was and enter() on the new one before actually swapping. States get keyed by their stateName lowercased, and changeState() lowercases whatever you pass in too, so casing in state scripts never bites you. Using strings instead of typed node references keeps it flexible. Any state can trigger a transition to any other state without needing a direct reference to it.

class_name StateMachine extends Node

signal stateChanged(old, new)

@export var initialState: State
@onready var states = {} # stateName (str) -> State

var prevState: State
var currentState: State
var parent: Node

func setup(parentNode: Node) -> void:
	var childStates = get_children()
	if len(childStates) == 0:
		return

	for state in childStates:
		state.parent = parentNode
		state.machine = self
		var key = state.stateName.to_lower()
		if states.has(key):
			push_warning("StateMachine: duplicate state name '%s'" % key)
		states[key] = state

	changeState(initialState.stateName)


func processInput(event: InputEvent) -> void:
	if currentState:
		currentState.processInput(event)

func processPhysics(delta: float) -> void:
	if currentState:
		currentState.processPhysics(delta)

func processFrame(delta: float) -> void:
	if currentState:
		currentState.processFrame(delta)


func changeState(newStateName: String):
	var key = newStateName.to_lower()
	if key not in states:
		return

	var newState = states.get(key)
	if !newState or newState == currentState:
		return

	if currentState:
		currentState.exit()

	prevState = currentState
	currentState = newState
	currentState.enter()

	stateChanged.emit(prevState, newState)

Player Example

The player script itself stays pretty simple. It calls stateMachine.setup(self) in _ready() to hand itself over as the parent, then just forwards its process callbacks into the machine. All the real behavior lives in the state scripts. One nice side effect of this setup is you can slot in logic before handing off to the state machine, things like global cooldowns, checking for death, or anything else that should run no matter what state you're in.

class_name StatePlayer extends CharacterBody2D

@onready var stateMachine = $StateMachine

func _ready() -> void:
	stateMachine.setup(self)

func _unhandled_input(event: InputEvent) -> void:
	stateMachine.processInput(event)

func _physics_process(delta: float) -> void:
	stateMachine.processPhysics(delta)

func _process(delta: float) -> void:
	stateMachine.processFrame(delta)

The Idle State

The idle state handles gravity while airborne, transitions into jump on a jump input, transitions into move if there's horizontal input, and decelerates the player if none of that applies. It reaches the player through parent and fires transitions through machine.changeState().

extends State

func _ready():
	stateName = "idle" # state names are internal FSM identifiers, not inspector-facing

func processPhysics(delta: float):
	if not parent.is_on_floor():
		parent.velocity += parent.get_gravity() * delta

	if Input.is_action_just_pressed("ui_accept") and parent.is_on_floor():
		machine.changeState("jump")

	var direction = Input.get_axis("left", "right")
	if direction != 0:
		machine.changeState("move")
	else:
		parent.velocity.x = move_toward(parent.velocity.x, 0, 300.0)

	parent.move_and_slide()

The jump and move states follow the same pattern, each one just handles its own input checks and fires its own transitions. The jump state sets velocity in enter() specifically so the jump only happens once, right when you enter the state, instead of every single frame.

In the scene tree, the StateMachine node just has Idle, Move, and Jump as children, each with their script attached. Set the initialState export on StateMachine to the Idle node in the inspector, and that's genuinely all the wiring you need.


Scene Tree Example

Things to Watch Out For

The system above is clean, but there's a handful of gotchas worth knowing about before you go build on it.

  • If initialState isn't set in the inspector, setup() will crash the moment it tries to read initialState.stateName.
  • If two child nodes end up with the same stateName, the second one silently overwrites the first and that state just vanishes. The setup code above throws a push_warning() so you at least get a heads up, but it's still easy to miss.
  • Calling changeState() from inside enter() or exit() is asking for trouble. It technically works, but you're swapping states mid-transition, and that can spiral into confusing behavior or straight up infinite loops. If you need to chain transitions, call_deferred("changeState", ...) is the safer route.
  • Need to block certain transitions under specific conditions? The cleanest place to do that is in the state's processPhysics, right before calling machine.changeState(). If the condition is more global, a guard check at the top of changeState() itself works too.

Sources

Here's what I actually used while putting this together. If this writeup didn't click for you, or you just want to go deeper, these are worth a watch.

Disagree with me?

If you think I'm wrong about any of this, tell me! I'm always looking to get better at this stuff, and I'd rather hear it than keep doing something wrong. Reach out through this form.