---
title: "Godot State Machine Tutorial - Enum vs Node Approach"
description: "Build a Godot 4 finite state machine two ways: a simple enum and match version, and a scalable node-based version. Compare both and pick the right one."
author: "Tajammal Maqbool"
last_updated: "2026-09-19"
---

# Godot State Machine Tutorial - Enum vs Node Approach

> Build a Godot 4 finite state machine two ways: a simple enum and match version, and a scalable node-based version. Compare both and pick the right one.

**Author:** Tajammal Maqbool  
**Published:** September 19, 2026  
**Tags:** godot, game development

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:

| From | Condition | To |
|------|-----------|----|
| Idle | Horizontal input | Run |
| Idle, Run | Jump pressed on floor | Jump |
| Idle, Run | Not on floor | Fall |
| Run | No horizontal input | Idle |
| Jump | Vertical velocity turns positive | Fall |
| Fall | Landed | Idle 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](https://tajammalmaqbool.com/pages/blogs/godot-match-statement-switch-case-in-gdscript.md) 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](https://tajammalmaqbool.com/pages/blogs/godot-enum-a-comprehensive-guide.md) 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](https://tajammalmaqbool.com/pages/blogs/godot-signals-a-comprehensive-guide.md).

### 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 approach | Node approach |
|---|---------------|---------------|
| Setup time | Minutes | Around half an hour the first time |
| Files | One | One per state, plus two base scripts |
| Best size | 2 to 5 simple states | 5 or more states, or states with their own data |
| State-specific variables | Shared across the whole script | Private to each state |
| Visible in the editor | No | Yes, in the scene tree |
| Per-state `@export` tuning | Awkward | Natural (see `coyote_time`) |
| Reuse across characters | Copy and paste | Share state scenes and scripts |
| Debugging | Print `state` | Print `current_state.name`, or show it in a Label |
| Risk | Grows into a giant `match` | Over-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](https://tajammalmaqbool.com/pages/blogs/godot-dictionary-a-comprehensive-guide.md) 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

| Mistake | Symptom | Fix |
|---------|---------|-----|
| States calling `change_state()` on each other | Tangled dependencies, hard to trace | Emit `finished` and let the machine switch |
| Running `enter()` before the owner is ready | Null references on the first frame | `await owner.ready` in the machine |
| Transition logic duplicated in many states | Changing a rule means editing five files | Use a shared parent state |
| Changing state and continuing the old state's code | Two states act in the same frame | `return` right after emitting a transition |
| Typo in a state name string | Nothing happens | `push_error` on unknown names, as above |
| Gameplay logic inside AnimationTree | Logic hidden in a graph | Keep 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](https://tajammalmaqbool.com/pages/blogs/godot-enum-a-comprehensive-guide.md) 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](https://medium.com/@tajammalmaqbool11) and [Patreon](https://www.patreon.com/TajammalMaqbool). Clap and Comment on Medium Posts if you find this helpful for you. Thanks for reading it!!!

---

## Related Articles

- [GDScript vs C# in Godot - Which Should You Use?](https://tajammalmaqbool.com/pages/blogs/gdscript-vs-csharp-in-godot.md)
- [Godot Match Statement - Switch Case in GDScript](https://tajammalmaqbool.com/pages/blogs/godot-match-statement-switch-case-in-gdscript.md)
- [Godot Signals - A Comprehensive Guide (Godot 4)](https://tajammalmaqbool.com/pages/blogs/godot-signals-a-comprehensive-guide.md)
- [How to Save and Load a Game in Godot 4](https://tajammalmaqbool.com/pages/blogs/how-to-save-and-load-a-game-in-godot-4.md)
- [Godot Array - A Comprehensive Guide (GDScript 4.x)](https://tajammalmaqbool.com/pages/blogs/godot-array-a-comprehensive-guide.md)

## Sitemap

See the full [sitemap](https://tajammalmaqbool.com/sitemap.md) for all pages.
