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
matchblock 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:
State— a base class that every state extends. Defines the interface:enter(),exit(),process(),physics_process().StateMachine— a node that manages the current state, handles transitions, and delegates_process/_physics_processto the active state.- Concrete states — one node per state (
IdleState,RunState, etc.), each extendingState.
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
- Create the scene tree structure shown in the architecture section.
- Attach
state_machine.gdto theStateMachinenode. - Attach each state script to its corresponding node.
- In the Inspector for
StateMachine, drag theIdlenode into the Initial State slot. - For each state node, drag the appropriate sibling nodes into the
@exportstate slots (e.g.,IdleStateneedsfall_state,run_state, andjump_stateset). - Add the input actions
move_left,move_right, andjumpin 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
AnimationTreestate 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 toStatethat returnsbool, and havetransition_to()check it before switching. This prevents invalid transitions like going fromDeadtoJump.
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