wrote a tiny FSM base class in GDScript so I'd stop copy-pasting state logic everywhere

480 views 8 replies

okay I got tired of writing the same state machine boilerplate for every enemy AI, player controller, and UI screen in my Godot 4 project. each one had its own slightly different version of the same enter/exit/update loop and it was becoming impossible to maintain without breaking something. so I spent an afternoon making a proper reusable base class and I'm not going back.

two classes: a StateMachine node that owns child State nodes, and a State base class everything extends. the machine auto-discovers its states from children on _ready, handles enter/exit, and passes optional message dicts on transitions, so you can do state_machine.transition_to("Hurt", {"damage": 15, "knockback_dir": Vector2.LEFT}) without needing a signal or a shared variable for every context handoff.

# StateMachine.gd
class_name StateMachine
extends Node

var current_state: State = null
var states: Dictionary = {}

func _ready() -> void:
    for child in get_children():
        if child is State:
            states[child.name] = child
            child.state_machine = self
    if states.size() > 0:
        transition_to(states.values()[0].name)

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 transition_to(target_name: String, msg: Dictionary = {}) -> void:
    if not states.has(target_name):
        push_error("StateMachine: unknown state '%s'" % target_name)
        return
    if current_state:
        current_state.exit()
    current_state = states[target_name]
    current_state.enter(msg)
# State.gd
class_name State
extends Node

var state_machine: StateMachine = null

func enter(_msg: Dictionary = {}) -> void:
    pass

func exit() -> void:
    pass

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

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

usage ends up being pretty clean: make a node tree like Enemy > StateMachine > Idle, Chase, Attack, Hurt, each state script extends State and overrides whatever it needs. the message dict has honestly been the best part. instead of setting flags on the parent or using an autoload to pass context between states, everything stays local to the transition call.

the one thing I haven't solved cleanly: sub-states. if I need an Attack state that has its own windup/active/recovery phases, I'm currently nesting a second StateMachine node as a child of the Attack state node. works fine but feels a bit gross. curious if anyone has a cleaner pattern for hierarchical states in Godot without it ballooning into something unreadable.

Replying to ChronoSpark: One other option: instead of passing data through the transition call itself, ha...

the context object also makes debugging way easier — you can dump the whole thing at any tick and immediately see what the FSM is working with. when data's passed through transition calls, the relevant info is scattered across the call stack at the exact moment a bug happens, which is annoying to reconstruct after the fact. plus serializing FSM state for save/load becomes one obvious thing to handle instead of hunting through every state's enter() for whatever it was expecting to receive.

Replying to ChronoSpark: One other option: instead of passing data through the transition call itself, ha...

The shared context object is the approach I landed on too, and it solved something I hadn't even framed as a problem yet: states needing to peek at sibling state data. Before, I had states holding references to each other, which got messy fast. With a shared context they just read and write to the same typed struct without knowing anything about each other.

One thing I'd add: keep the context object completely inert. Plain typed fields, no methods, no logic. The moment you start putting behavior in the context it starts feeling like a second FSM running alongside the first, and you've made a different kind of mess.

Replying to VertexSage: the context object also makes debugging way easier — you can dump the whole thin...

The context dump is great for runtime debugging. I took it a step further and made the context serializable to a Dictionary so I could save and restore exact FSM state for replay testing: record a sequence of inputs and state transitions, serialize the context at the point of the bug, feed it back in deterministically to reproduce the exact scenario. Debugging AI combat states stopped feeling like archaeology.

It does enforce keeping the context clean, value types only, no object references, but that constraint also prevents it from becoming a grab-bag of random stuff, which is a useful side effect.

Replying to GlitchLattice: this is exactly what i've been putting off writing for my current project, going...

One other option: instead of passing data through the transition call itself, have the FSM own a small typed context object that any state can read or write. The outgoing state writes whatever the incoming state needs, then the transition fires. No per-transition dictionary allocations, no string keys to remember or typo. The context object does accumulate fields over time, but for a single character's state machine it stays manageable, and you get compile-time safety on all the shared data rather than casting from a Variant or object.

Replying to GlitchLattice: this is exactly what i've been putting off writing for my current project, going...

for passing data on transitions i added an optional Dictionary param to transition_to() that gets forwarded into the new state's enter():

func transition_to(new_state: StringName, data: Dictionary = {}) -> void:
    if _current_state:
        _current_state.exit()
    _current_state = _states[new_state]
    _current_state.enter(data)

then each state's enter(from_data) just pulls out whatever it needs. it's untyped which is a little loose, but in practice the states know what they're expecting and i haven't had a bug from it yet. if it starts getting messy i'd probably define a typed inner class per-transition instead of a raw dict.

Replying to NimbusMesh: This is useful beyond replay testing too. If you need to persist AI state across...

One thing worth planning for before committing to this pattern: if your FSM context holds references to scene nodes (a target enemy, a patrol waypoint, a spawner), serializing the context dict alone isn't enough. Those references need to become node paths or stable entity IDs in the serialized form, then get resolved back to live refs on load.

The approach I settled on is a two-pass restore. First, deserialize the context and resolve all IDs back to actual node references. Then restore the current state name and call enter() fresh, rather than trying to snapshot and restore each state's internal variables directly. It means every state's enter() needs to be idempotent given valid context, but honestly that's a good constraint to have regardless. States that can't re-enter cleanly are usually states that are holding too much of their own data instead of reading from context.

this is exactly what i've been putting off writing for my current project, going to steal this shamelessly. one question though: how are you handling transitions where the outgoing state needs to pass data to the incoming one? like if an attack state wants to hand off a hit-confirm flag or a charge level, are you passing it as an argument to enter(), using a shared context dict on the FSM itself, or something else? i've tried both and they both feel a little awkward in different ways.

Replying to VelvetFern: The context dump is great for runtime debugging. I took it a step further and ma...

This is useful beyond replay testing too. If you need to persist AI state across save/load cycles, a serializable context object means you can drop the whole FSM snapshot into your save data without writing custom serialization for every individual state class.

One thing to watch: if any context values are object references (a target node, a resource, etc.), make sure serialization converts those to something stable — a node path string, a persistent ID, whatever your system uses. Had a save/load cycle where a target node reference silently became null on load, and it took a while to catch because the FSM didn't error immediately. It just started behaving strangely a few frames later once it tried to act on the missing target.

Moonjump
Forum Search Shader Sandbox
Sign In Register