godot

Godot State Machine Tutorial - Enum vs Node Approach

Cover Image for Godot State Machine Tutorial - Enum vs Node Approach
13 min read
#godot

Every character controller starts clean. Then you add jumping, and a few if is_on_floor() checks appear. Then a dash, which should not work mid-attack. Then wall sliding, which cancels the dash, unless the player is hurt. A week later _physics_process() is two hundred lines of booleans (is_jumping, is_dashing, can_dash, was_hurt) and every new feature breaks an old one.

A Godot state machine fixes this by making one simple rule explicit: the character is in exactly one state at a time, and each state owns its own logic. This tutorial builds a Godot 4 state machine for a 2D platformer character in two ways:

  1. The enum approach: one script, an enum and a match statement. Fast to write, perfect for small cases.
  2. The node approach: one node per state. More setup, but it scales to dozens of states and stays readable.

Then we compare them honestly so you can pick the right one for your project, rather than the one that looks most impressive in a tutorial.

1. What Is a Finite State Machine?

A finite state machine (FSM) has three parts:

  • States: a fixed, finite list of situations the object can be in. For a player: IDLE, RUN, JUMP, FALL.
  • Transitions: rules for moving from one state to another. "From RUN, if the player presses jump, go to JUMP."
  • Enter and exit actions: code that runs once when a state starts or ends. Playing an animation on enter, resetting a timer on exit.

The key idea is that only the current state's logic runs each frame. The JUMP state never has to ask "am I dashing?", because if you were dashing you would be in the DASH state instead.

Here is the machine we are going to build:

FromConditionTo
IdleHorizontal inputRun
Idle, RunJump pressed on floorJump
Idle, RunNot on floorFall
RunNo horizontal inputIdle
JumpVertical velocity turns positiveFall
FallLandedIdle or Run

2. The Enum Approach

The enum approach keeps everything in the player script. An enum names the states, a variable holds the current one, and match runs the right code. If match is new to you, read my Godot Match Statement guide first. It takes five minutes.

Setting Up the Scene

Create a CharacterBody2D named Player with three children: a CollisionShape2D, an AnimatedSprite2D (with animations named idle, run, jump and fall), and nothing else for now. Add input actions move_left, move_right and jump in Project Settings > Input Map.

The Full Script

gdscript
extends CharacterBody2D enum State { IDLE, RUN, JUMP, FALL } @export var speed := 220.0 @export var jump_velocity := -420.0 @export var gravity := 1200.0 var state: State = State.IDLE @onready var sprite: AnimatedSprite2D = $AnimatedSprite2D func _ready() -> void: _enter_state(state) func _physics_process(delta: float) -> void: var direction := Input.get_axis("move_left", "move_right") match state: State.IDLE: velocity.x = move_toward(velocity.x, 0.0, speed) if not is_on_floor(): change_state(State.FALL) elif Input.is_action_just_pressed("jump"): change_state(State.JUMP) elif direction != 0.0: change_state(State.RUN) State.RUN: velocity.x = direction * speed if not is_on_floor(): change_state(State.FALL) elif Input.is_action_just_pressed("jump"): change_state(State.JUMP) elif direction == 0.0: change_state(State.IDLE) State.JUMP: velocity.y += gravity * delta velocity.x = direction * speed if velocity.y >= 0.0: change_state(State.FALL) State.FALL: velocity.y += gravity * delta velocity.x = direction * speed if is_on_floor(): change_state(State.RUN if direction != 0.0 else State.IDLE) if direction != 0.0: sprite.flip_h = direction < 0.0 move_and_slide() func change_state(next: State) -> void: if next == state: return _exit_state(state) state = next _enter_state(state) func _enter_state(s: State) -> void: match s: State.IDLE: sprite.play("idle") State.RUN: sprite.play("run") State.JUMP: velocity.y = jump_velocity sprite.play("jump") State.FALL: sprite.play("fall") func _exit_state(_s: State) -> void: pass

Why This Already Beats a Pile of Booleans

  • velocity.y = jump_velocity lives in _enter_state(State.JUMP), so it runs exactly once per jump. No was_jump_pressed_last_frame flag.
  • Every transition goes through change_state(), so there is one place to add logging, sound effects or debugging.
  • The typed var state: State means the editor autocompletes states and warns if you assign a random integer. My Godot Enum guide covers typed enums in more depth, including a transition table you can bolt onto this machine to reject illegal transitions.

Where the Enum Approach Starts to Hurt

Add DASH, WALL_SLIDE, ATTACK, HURT, CLIMB and DEAD, and this script passes 400 lines. Each state's per-frame code, enter code and exit code live in three different match blocks far apart from each other. State-specific variables (the dash timer, the attack combo counter) pile up at the top of the file and are visible to every state. That is the moment to switch to nodes.

3. The Node Approach

In the node approach, each state is its own node with its own script. A StateMachine node holds them all and forwards the engine callbacks to whichever state is active.

The scene tree looks like this:

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

This has a few instant benefits. Each state's code is in a short, focused file. State-specific variables are private to that state. And you can see every state in the scene tree at a glance.

Step 1: The State Base Class

Every state extends this class. It defines the interface the machine calls, and a signal the state emits when it wants to hand over control.

gdscript
# state.gd class_name State extends Node signal finished(next_state_name: StringName) var player: CharacterBody2D func enter() -> void: pass func exit() -> void: pass func handle_input(_event: InputEvent) -> void: pass func update(_delta: float) -> void: pass func physics_update(_delta: float) -> void: pass

Notice that states do not call each other directly. They emit finished and let the machine do the switching. That is the "signal up" half of Godot's "call down, signal up" rule, which I explain in detail in my Godot Signals guide.

Step 2: The StateMachine Node

gdscript
# 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.player = owner as CharacterBody2D child.finished.connect(_on_state_finished) await owner.ready current_state = initial_state current_state.enter() func _unhandled_input(event: InputEvent) -> void: if current_state: current_state.handle_input(event) func _process(delta: float) -> void: if current_state: current_state.update(delta) func _physics_process(delta: float) -> void: if current_state: current_state.physics_update(delta) func _on_state_finished(next_state_name: StringName) -> void: var next_state: State = states.get(next_state_name) if next_state == null: push_error("State '%s' does not exist" % next_state_name) return if next_state == current_state: return current_state.exit() current_state = next_state current_state.enter()

Two lines deserve an explanation:

  • child.player = owner as CharacterBody2D gives every state a reference to the player. owner is the root of the saved scene, which is the Player.
  • await owner.ready waits until the player itself has finished _ready(). Children become ready before their parents, so without this line the first state could run before the player's @onready variables exist.

Step 3: The States

Keep shared tuning values on the player (speed, gravity, jump_velocity, exported so designers can tweak them) and let states read them.

gdscript
# player.gd extends CharacterBody2D @export var speed := 220.0 @export var jump_velocity := -420.0 @export var gravity := 1200.0 @onready var sprite: AnimatedSprite2D = $AnimatedSprite2D func get_direction() -> float: return Input.get_axis("move_left", "move_right") func _physics_process(_delta: float) -> void: var direction := get_direction() if direction != 0.0: sprite.flip_h = direction < 0.0 move_and_slide()

The player's own _physics_process only calls move_and_slide() and flips the sprite. All decision making lives in the states. Parents process before their children, so the player moves with the velocity the state set on the previous frame. If you prefer the state to run first, move the move_and_slide() call into each state's physics_update() instead.

gdscript
# idle.gd extends State func enter() -> void: player.sprite.play("idle") func physics_update(_delta: float) -> void: player.velocity.x = move_toward(player.velocity.x, 0.0, player.speed) if not player.is_on_floor(): finished.emit(&"Fall") elif Input.is_action_just_pressed("jump"): finished.emit(&"Jump") elif player.get_direction() != 0.0: finished.emit(&"Run")
gdscript
# run.gd extends State func enter() -> void: player.sprite.play("run") func physics_update(_delta: float) -> void: var direction := player.get_direction() player.velocity.x = direction * player.speed if not player.is_on_floor(): finished.emit(&"Fall") elif Input.is_action_just_pressed("jump"): finished.emit(&"Jump") elif direction == 0.0: finished.emit(&"Idle")
gdscript
# jump.gd extends State func enter() -> void: player.velocity.y = player.jump_velocity player.sprite.play("jump") func physics_update(delta: float) -> void: player.velocity.y += player.gravity * delta player.velocity.x = player.get_direction() * player.speed if player.velocity.y >= 0.0: finished.emit(&"Fall")
gdscript
# fall.gd extends State @export var coyote_time := 0.1 var _coyote_timer := 0.0 func enter() -> void: player.sprite.play("fall") _coyote_timer = coyote_time func physics_update(delta: float) -> void: _coyote_timer -= delta player.velocity.y += player.gravity * delta player.velocity.x = player.get_direction() * player.speed if _coyote_timer > 0.0 and Input.is_action_just_pressed("jump"): finished.emit(&"Jump") elif player.is_on_floor(): finished.emit(&"Run" if player.get_direction() != 0.0 else &"Idle")

Look at fall.gd. I slipped in coyote time, the grace period that lets players jump a moment after walking off a ledge. Its timer variable belongs to the Fall state alone and cannot leak into other states. In the enum version, that variable would sit at the top of the player script next to everything else. This is the node approach's biggest win in practice. (For a production build, only allow the coyote jump when the fall began by walking off a ledge rather than after a jump, for example by passing a flag into enter().)

Step 4: Wire It Up in the Editor

  1. Attach state_machine.gd to the StateMachine node and each state script to its child.
  2. Select StateMachine and drag the Idle node into the Initial State slot in the Inspector.
  3. Run the scene.

Adding a new state is now a repeatable recipe: create a node, extend State, fill in enter() and physics_update(), and emit finished from the states that should lead to it.

4. Enum vs Node: An Honest Comparison

Enum approachNode approach
Setup timeMinutesAround half an hour the first time
FilesOneOne per state, plus two base scripts
Best size2 to 5 simple states5 or more states, or states with their own data
State-specific variablesShared across the whole scriptPrivate to each state
Visible in the editorNoYes, in the scene tree
Per-state @export tuningAwkwardNatural (see coyote_time)
Reuse across charactersCopy and pasteShare state scenes and scripts
DebuggingPrint statePrint current_state.name, or show it in a Label
RiskGrows into a giant matchOver-engineering tiny objects

My rule of thumb:

  • Doors, pickups, traffic lights, simple enemies: enum. A door with OPEN, CLOSED, LOCKED does not need four nodes.
  • Player controllers, bosses, complex AI: nodes. Anything that will keep growing through development.
  • Unsure? Start with the enum. Refactoring to nodes later is mechanical: each match branch becomes a state's physics_update(), each _enter_state branch becomes its enter().

5. Going Further

Debug Label

Seeing the current state on screen saves hours. Add a Label above the player and update it on every transition:

gdscript
# in StateMachine, at the end of _on_state_finished() if has_node("../StateLabel"): get_node("../StateLabel").text = String(current_state.name)

Passing Data Between States

Sometimes a state needs context: which way the player was knocked back, whether a fall started from a jump. Extend the signal with an optional dictionary and pass it into enter():

gdscript
signal finished(next_state_name: StringName, data: Dictionary) func enter(_data: Dictionary = {}) -> void: pass

Update _on_state_finished() in the machine to accept the second parameter and call current_state.enter(data). Then emit it with finished.emit(&"Hurt", {"knockback": Vector2(-200, -150)}) and read it in the Hurt state's enter(). The Godot Dictionary guide covers safe access with get() and defaults.

Hierarchical States

When several states share logic (every "grounded" state checks for jump input and falling), you can build a small hierarchy with inheritance: a GroundedState extends State and handles the shared checks, and Idle and Run extend GroundedState and call super.physics_update(delta). This keeps a growing machine from duplicating the same five lines everywhere.

Animation State Machines Are a Different Tool

Godot's AnimationTree has a built-in AnimationNodeStateMachine with a visual graph and a travel() method for smooth blending. It is excellent for animation transitions, but it is not meant to hold gameplay logic. A common and clean setup is to use a gameplay FSM like the one above and have each state's enter() tell the animation state machine where to go:

gdscript
@onready var playback: AnimationNodeStateMachinePlayback = $AnimationTree.get("parameters/playback") func enter() -> void: playback.travel("run")

6. Common Mistakes

MistakeSymptomFix
States calling change_state() on each otherTangled dependencies, hard to traceEmit finished and let the machine switch
Running enter() before the owner is readyNull references on the first frameawait owner.ready in the machine
Transition logic duplicated in many statesChanging a rule means editing five filesUse a shared parent state
Changing state and continuing the old state's codeTwo states act in the same framereturn right after emitting a transition
Typo in a state name stringNothing happenspush_error on unknown names, as above
Gameplay logic inside AnimationTreeLogic hidden in a graphKeep gameplay in your FSM, animations in the tree

7. Frequently Asked Questions

Is there a built-in state machine in Godot 4?

Only for animations (AnimationNodeStateMachine inside AnimationTree). For gameplay logic you write your own, which is why the two approaches above are so common. Community plugins exist too, but the node approach here is short enough that most developers own the code instead.

Should states be Nodes or RefCounted objects?

Nodes are easier: they show up in the scene tree, support @export, and get engine callbacks forwarded naturally. RefCounted states are lighter and work well when you spawn hundreds of AI agents that each need a machine. Start with nodes and switch only if profiling says so.

How do I make the enemy AI use the same machine?

The StateMachine and State scripts above never mention the player specifically beyond the player variable. Rename it to actor (typed as CharacterBody2D) and the same machine drives enemies, NPCs or anything else that moves.

How do I prevent illegal transitions?

Keep an allowed-transitions dictionary in the machine and check it in _on_state_finished() before switching. My Godot Enum guide has a ready-made transition table you can adapt.

Conclusion

A Godot state machine replaces a pile of boolean flags with one clear rule: one state at a time, each state owns its logic. The enum approach gets you there in a single script and is the right call for small objects. The node approach adds a little structure up front and pays it back as soon as your character grows beyond a handful of states, with private per-state data, editor visibility and reusable states.

Build the enum version first. The moment you add a variable that only one state uses, you will know it is time to move to nodes.

Follow and Support me on Medium and Patreon. Clap and Comment on Medium Posts if you find this helpful for you. Thanks for reading it!!!

Related Blogs

View All

Other Blogs

View All