GodotFoundry logo
godot 4GDScriptstate machinedesign patternstutorial

Node-Based State Machine in Godot 4: A Complete Implementation

Build a reusable, debuggable state machine in Godot 4 using the node tree. Includes full GDScript source for a 2D platformer character with idle, run, jump, and fall states.

G
Godot Foundry
·

The problem with growing scripts

Every Godot project starts the same way. Your CharacterBody2D script has 50 lines, handles movement, and life is good. A week later it's 400 lines of nested if chains tracking booleans like is_jumping, is_attacking, is_dashing, and can_double_jump. You add wall-sliding and suddenly every function needs to know about every other state.

This is the exact problem a finite state machine solves. The character can only be in one state at a time, each state owns its own logic, and transitions between states are explicit. No more boolean soup.

Enum-based vs. node-based: when to use which

Before building the node-based version, it's worth saying: an enum with a match statement is perfectly fine for simple cases. If your character has three states and you don't plan on reusing the machine across scenes, an enum in a single script is less overhead and easier to reason about. There's no shame in starting there.

The node-based approach earns its complexity when:

  • You have 6+ states and the match block is growing unwieldy
  • You want to reuse the same state machine across different characters (a player and an NPC sharing locomotion states)
  • You want to add or remove states without touching existing code
  • Different team members are working on different states and merge conflicts are becoming a problem

If none of these apply to your project, stick with the enum. You can always refactor later.

Architecture

The node-based setup has three pieces:

  1. State — a base class that every state extends. Defines the interface: enter(), exit(), process(), physics_process().
  2. StateMachine — a node that manages the current state, handles transitions, and delegates _process / _physics_process to the active state.
  3. Concrete states — one node per state (IdleState, RunState, etc.), each extending State.

In the scene tree it looks like this:

Player (CharacterBody2D)
├── CollisionShape2D
├── AnimatedSprite2D
└── StateMachine (Node)
    ├── Idle (Node)
    ├── Run (Node)
    ├── Jump (Node)
    └── Fall (Node)

The base State class

Create state.gd. This is the interface that all states implement:

## state.gd
class_name State
extends Node

## Reference to the CharacterBody2D (or whatever root node) this state controls.
## Injected by StateMachine._ready() — do NOT use @onready here.
var parent: CharacterBody2D

## Reference to the state machine that owns this state.
## Injected by StateMachine._ready().
var state_machine: StateMachine

## Called once when the state machine transitions to this state.
## Receives the previous state (or null on first frame).
func enter(previous_state: State) -> void:
	pass

## Called once when the state machine transitions away.
func exit() -> void:
	pass

## Called every frame while this state is active.
func process(delta: float) -> void:
	pass

## Called every physics frame while this state is active.
func physics_process(delta: float) -> void:
	pass

Two things to note about the design:

Why var parent instead of @onready? This is a common Godot 4 footgun. If you write @onready var player = get_parent().get_parent() in a base class, child scripts that extend it won't inherit that @onready assignment reliably — the @onready runs in the context of the declaring script, and in practice you end up with null references in extended states. By using a plain var that gets set explicitly in _ready(), you avoid this entirely.

Why does enter() receive previous_state? This lets states make decisions based on context. A FallState can check whether the player just walked off a ledge (came from Run or Idle) or is descending from a jump (came from Jump), and behave differently — for example, allowing coyote time only in the first case.

The StateMachine node

Create state_machine.gd:

## state_machine.gd
class_name StateMachine
extends Node

## The state to start in. Set this in the Inspector by dragging
## one of the child state nodes into the slot.
@export var initial_state: State

var current_state: State

func _ready() -> void:
	# Grab the parent node (the CharacterBody2D or whatever the
	# state machine is attached to) and inject it into every state.
	var parent_node = get_parent()

	for child in get_children():
		if child is State:
			child.state_machine = self
			child.parent = parent_node

	# Enter the initial state.
	if initial_state:
		current_state = initial_state
		current_state.enter(null)

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

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

## Call this from any state to request a transition.
## target_state must be a direct child of this StateMachine node.
func transition_to(target_state: State) -> void:
	if target_state == current_state:
		return

	var previous := current_state
	current_state.exit()
	current_state = target_state
	current_state.enter(previous)

The key design decision: states call state_machine.transition_to() themselves. The state machine doesn't know when to switch — each state decides when it's done and where to go next. This keeps transition logic local to each state instead of centralized in one giant function. In Godot community terms, this follows the "call down, signal up" pattern — though state machines are a recognized exception where calling the parent (the state machine) directly is considered acceptable, since the coupling is intentional and contained.

Why @export state references instead of NodePaths

You'll see many tutorials reference sibling states with $"../Fall" or get_node("../Fall"). This works but it's brittle: if you rename a node in the scene tree, every $"../OldName" reference silently breaks and you get null errors at runtime.

A more robust approach is to use @export variables typed to State:

@export var fall_state: State
@export var run_state: State
@export var idle_state: State

Then drag the actual node into the Inspector slot. Now if you rename the node, the exported reference stays valid. The Inspector also validates the type, so you can't accidentally assign a CollisionShape2D to a state slot.

The tradeoff: it's more setup in the Inspector per state. For a state machine with 4-5 states this is fine. If you have 15+ states, you might prefer a dictionary-based lookup in the state machine instead.

Concrete states for a 2D platformer

Now the actual character logic. Every state uses parent (the CharacterBody2D injected by the state machine) — no @onready, no get_parent() chains.

IdleState

## idle_state.gd
extends State

@export var fall_state: State
@export var run_state: State
@export var jump_state: State

func enter(previous_state: State) -> void:
	parent.velocity.x = 0.0
	if parent.has_node("AnimatedSprite2D"):
		parent.get_node("AnimatedSprite2D").play("idle")

func physics_process(delta: float) -> void:
	var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
	parent.velocity.y += gravity * delta
	parent.move_and_slide()

	# Fell off a ledge.
	if not parent.is_on_floor():
		state_machine.transition_to(fall_state)
		return

	# Started moving.
	var direction := Input.get_axis("move_left", "move_right")
	if direction != 0.0:
		state_machine.transition_to(run_state)
		return

	# Jump.
	if Input.is_action_just_pressed("jump"):
		state_machine.transition_to(jump_state)

RunState

## run_state.gd
extends State

@export var speed: float = 300.0
@export var fall_state: State
@export var idle_state: State
@export var jump_state: State

func enter(previous_state: State) -> void:
	if parent.has_node("AnimatedSprite2D"):
		parent.get_node("AnimatedSprite2D").play("run")

func physics_process(delta: float) -> void:
	var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
	parent.velocity.y += gravity * delta

	var direction := Input.get_axis("move_left", "move_right")
	parent.velocity.x = direction * speed
	parent.move_and_slide()

	# Flip sprite to face movement direction.
	if parent.has_node("AnimatedSprite2D") and direction != 0.0:
		parent.get_node("AnimatedSprite2D").flip_h = direction < 0.0

	if not parent.is_on_floor():
		state_machine.transition_to(fall_state)
		return

	if direction == 0.0:
		state_machine.transition_to(idle_state)
		return

	if Input.is_action_just_pressed("jump"):
		state_machine.transition_to(jump_state)

JumpState

## jump_state.gd
extends State

@export var jump_velocity: float = -400.0
@export var air_speed: float = 300.0
@export var fall_state: State

func enter(previous_state: State) -> void:
	parent.velocity.y = jump_velocity
	if parent.has_node("AnimatedSprite2D"):
		parent.get_node("AnimatedSprite2D").play("jump")

func physics_process(delta: float) -> void:
	var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
	parent.velocity.y += gravity * delta

	var direction := Input.get_axis("move_left", "move_right")
	parent.velocity.x = direction * air_speed
	parent.move_and_slide()

	# Reached the top of the arc.
	if parent.velocity.y >= 0.0:
		state_machine.transition_to(fall_state)
		return

	# Bonked on ceiling.
	if parent.is_on_ceiling():
		parent.velocity.y = 0.0
		state_machine.transition_to(fall_state)

FallState with coyote time

This is where the previous_state parameter pays off. If the player walked off a ledge (previous state was Idle or Run), we grant a short window where they can still jump. If they're falling after a jump, no coyote time — they already used their jump.

## fall_state.gd
extends State

@export var air_speed: float = 300.0
@export var coyote_time: float = 0.08  ## seconds
@export var idle_state: State
@export var run_state: State
@export var jump_state: State

var coyote_timer: float = 0.0
var can_coyote_jump: bool = false

func enter(previous_state: State) -> void:
	if parent.has_node("AnimatedSprite2D"):
		parent.get_node("AnimatedSprite2D").play("fall")

	# Only allow coyote jump if we walked off a ledge, not after a jump.
	can_coyote_jump = previous_state != jump_state and previous_state != null
	coyote_timer = coyote_time if can_coyote_jump else 0.0

func physics_process(delta: float) -> void:
	var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
	parent.velocity.y += gravity * delta

	var direction := Input.get_axis("move_left", "move_right")
	parent.velocity.x = direction * air_speed
	parent.move_and_slide()

	# Coyote time: allow jumping for a few frames after leaving ground.
	if coyote_timer > 0.0:
		coyote_timer -= delta
		if Input.is_action_just_pressed("jump"):
			state_machine.transition_to(jump_state)
			return

	# Landed.
	if parent.is_on_floor():
		var h_input := Input.get_axis("move_left", "move_right")
		if h_input != 0.0:
			state_machine.transition_to(run_state)
		else:
			state_machine.transition_to(idle_state)

Coyote time is a small detail that makes a big difference in how a platformer feels. The 0.08 second default (about 5 frames at 60fps) is a common starting point — adjust to taste.

Integrating with AnimationTree

The state scripts above use AnimatedSprite2D.play() for simplicity, but most production projects use AnimationTree with an AnimationNodeStateMachine for blended transitions. The integration is straightforward — in each state's enter(), tell the tree's playback to travel to the matching animation:

## In any state's enter():
func enter(previous_state: State) -> void:
	var playback: AnimationNodeStateMachinePlayback = parent.get_node("AnimationTree")["parameters/playback"]
	playback.travel("run")

One thing to watch out for: Godot 4's AnimationTree has its own state machine node that handles transitions with blend times. If your animation transitions are complex enough that you're building logic around them, consider whether the AnimationTree state machine alone covers your needs — you might not need a separate code-level state machine at all. The code state machine is most useful when your game logic (physics, input handling, ability cooldowns) differs between states, not just the animations.

Wiring it up

  1. Create the scene tree structure shown in the architecture section.
  2. Attach state_machine.gd to the StateMachine node.
  3. Attach each state script to its corresponding node.
  4. In the Inspector for StateMachine, drag the Idle node into the Initial State slot.
  5. For each state node, drag the appropriate sibling nodes into the @export state slots (e.g., IdleState needs fall_state, run_state, and jump_state set).
  6. Add the input actions move_left, move_right, and jump in Project → Project Settings → Input Map.

The player's root CharacterBody2D needs no script at all. All movement logic lives in the states. This is the payoff — your player "script" is actually four small scripts, each under 40 lines, each responsible for exactly one behavior.

Reusing states across characters

Because states reference parent (a generic CharacterBody2D) and sibling states through @export, the same state scripts work for any character with the same scene structure. You can have a Player and an EnemyPatroller both using the same IdleState and RunState — just set different speed values in the Inspector per instance.

For AI-controlled characters, replace Input.get_axis() calls with a method on the parent. For example, add a get_input_direction() -> float method to your enemy script that returns direction based on pathfinding or patrol logic, and have the state call parent.get_input_direction() instead of reading Input directly.

Debugging

Add a print() in StateMachine.transition_to():

func transition_to(target_state: State) -> void:
	if target_state == current_state:
		return
	print("[StateMachine] ", current_state.name, " -> ", target_state.name)
	var previous := current_state
	current_state.exit()
	current_state = target_state
	current_state.enter(previous)

You'll see output like [StateMachine] Idle -> Run -> Jump -> Fall -> Idle in the console. If a transition fires that you don't expect, you know exactly which state triggered it and can look at that state's physics_process().

For visual debugging, add a Label node to the player and update it in transition_to():

if get_parent().has_node("DebugLabel"):
	get_parent().get_node("DebugLabel").text = current_state.name

Limitations and when to reach for something else

This pattern covers most character controllers, simple enemy AI, and menu/UI flows. It starts to strain in a few specific areas:

  • Concurrent states — a character that is simultaneously "running" and "shooting" can't be represented by a single state machine. The standard solution is multiple state machines (one for locomotion, one for combat), each operating independently on the same character.
  • Complex AI with many conditions — if your enemy has 15 states with conditional transitions that depend on range, health, cooldowns, and ally positions, a behavior tree is a better fit. Godot doesn't ship a built-in behavior tree node, but there are well-maintained community addons like LimboAI that implement them.
  • Animation-driven characters — as mentioned above, Godot 4's AnimationTree state machine can handle some cases on its own. If your states differ only in which animation plays and not in game logic, you might be adding unnecessary abstraction.
  • Guard conditions — this implementation doesn't validate transitions. Any state can transition to any other state. For complex machines, you might want to add a can_enter() method to State that returns bool, and have transition_to() check it before switching. This prevents invalid transitions like going from Dead to Jump.

For most 2D and 3D games, the node-based state machine covers the majority of use cases with code that's readable, extensible, and straightforward to debug.

Looking for Godot assets?

Browse our marketplace for shaders, scripts, 3D models, and more — all for Godot 4.

Browse Assets