Finite State Machines in Godot
Background
I’m developing a grid-based puzzle game and recently overcame a design challenge that I’ve been struggling with in Godot 4. In need of a Finite State Machine (FSM) implementation, I searched the internet for a simple solution. I know the basics of FSMs, but I wanted to see what people were coming up with specifically for Godot. What I found was several tutorials, all with the same basic premise. Here’s an example.
> Player (player.gd)
| > FSM (fsm_node.gd)
| | > State 1 (idle.gd extends state_node.gd)
| | > State 2 (grounded.gd extends state_node.gd)
| | > State 3 (in_air.gd extends state_node.gd)
First, you create a State class (state_node.gd) that derives from Node. This class will have virtual functions for on_enter(), on_process(), and on_exit(). For each state, you derive from this base class and override the virtual functions. Then, you create a FiniteStateMachine class (fsm_node.gd) that also derives from Node. This class is responsible for managing all of the State nodes assigned to it: enabling/disabling states, checking for valid transitions, etc. Assignment is done by making the state node a child of the FSM node. Each state has a reference to the player, maybe by referencing its owner, and calls functions that make it do things. It’s basic, intuitive, and (for the most part) it works - but something doesn’t quite feel right about it.
The Godot Philosophy
Godot has 3 main components that comprise almost everything: Scenes, Scripts, and Resources. Scenes are comprised of nodes which run scripts that access resources to do… something. Composing scenes from nodes of several different types is the core philosophy behind Godot’s way of doing things. When we create scripts or resources, they only become useful when a node is able to do something with them. This leads to nodes becoming the powerhouse of Godot’s architecture. So naturally a lot of design patterns will focus on them too, such as the FSM example from earlier.
The Power of Nodes
To showcase the power of Godot’s nodes (and setup a point for later), I want to present a very basic example. I’m going to create a basic 2d object for my grid-based puzzle game. We can start with the most basic node as our root: Node2D. This gives us a position on the screen that we can reference. Next, I want to be able to actually see my character, so we’ll add a Sprite2D node as a child. I imagine this character will make a noise at some point, so let’s also add an AudioStreamPlayer2D. Here’s what our little guy looks like so far:
> Node2D
| > Sprite2D
| > AudioStreamPlayer2D
And with no scripting at all, we have an entity with position and rotation that can also display an image and play a sound. We use resources for the nodes to access whatever images we want to display and whatever sounds we want to stream.
This is nice, but the node isn’t actually doing anything. So, to tie it all together, we can extend the root node by adding a script, grid_object.gd for example. In the script, we’ll give it a coordinate property, update our position when the coordinate changes, and make it play sounds when it moves. We’ve now utilized all 3 main components to make a Grid Object that we can see that also makes sounds when it moves.
This is the power of composition of Godot’s nodes. We can keep adding nodes like Timers, Area2Ds, and more. And for each one we add, we’re providing more functionality to the base node through composition.
Make Your Own Node
Let’s continue with this notion of node composition and make the sprite change from red to green every 5 seconds. There’s a million different ways to do this, but we’ll focus on one that highlights composition. Right now, the Sprite2D is displaying a singular texture, but I want 2 textures - one red and one green. We can easily rearrange the structure to provide this capability without breaking any of our previous functionality. First, replace the Sprite2D node with an AnimatedSprite2D node. Then add a Timer node as a child of the AnimatedSprite2D. Let’s look at the full picture again:
> Node2D (grid_object.gd)
| > AnimatedSprite2D
| | > Timer
| > AudioStreamPlayer2D
Now we have a node which contains many sprites that can countdown from any number. How this next part is handled is up to you, but I want to showcase more composability, so here’s how I’ll do it. We add a script called timed_sprite_changer.gd to the AnimatedSprite2D node. In this script we connect the Timer’s timeout signal to a _on_timer_timeout() function. In this function, we’ll increase the value of the AnimatedSprite2D frame property, wrapping the int across the total number of sprites available. And now, without having touched the grid_object.gd script at all, we’ve accomplished our goal! While there were many different ways to accomplish this goal, the way its done here allows us to save the AnimatedSprite2D as its own scene and use it anywhere. We’ve just made our own node, TimedSpriteChanger, that can be added to any other node and provide a changing image over time.
Back to the FSM
I almost forgot this post was about the Finite State Machine problem… So let’s add the FSM to this guy. We’ll give him two states: Quiet and Noisy. When there are no other GridObjects around him, he’ll be noisy. When there is at least one GridObject around him, he’ll be quiet but he’ll shift colors. To accomplish this, we’ll need to add a few things: a function in grid_object.gd that returns whether or not there’s another GridObject around him, the FSM node, the QuietState node, and the NoisyState node. Here’s what it would look like:
> Node2D (grid_object.gd)
| > AnimatedSprite2D
| | > Timer
| > AudioStreamPlayer2D
| > FSM (fsm_node.gd)
| | > QuietState (quiet_state.gd)
| | > NoisyState (noisy_state.gd)
Its simple enough. We’ll set QuietState as the default state. Every frame, QuietState will call the is_surrounded() -> bool function in grid_object.gd through its owner property. If it returns true, nothing happens. If it returns false, it will trigger a transition into NoisyState and stop the AnimatedSprite2D’s Timer to prevent the sprite from changing. NoisyState will also call the is_surrounded() -> bool function and if it returns true, it will trigger a transition back into QuietState. NoisyState overrides the on_enter() function to call the play_sound() function in the grid_object.gd script. It will also override on_exit() to call a stop_sound() function. And, voila - it works!
However, you might also notice that we’ve just made some of the least composable nodes ever 😦. Not only does each StateNode need to be a child of an FSM, they also need to be owned by a node with the grid_object.gd script attached to it. Also, the FSM itself will probably bug out at some point if we start adding random non-StateNodes to it. In essence, these things only work in the exact scenario we’ve put them in. But I want my StateNodes to be less rigid and more composable! I want to follow the node-based Godot philosophy and not work against the system I’m working in!
For beginners, if you have an FSM like this in your game and everything is working how it should - don’t change it! If its working, its probably fine! At some point, you will implement something that only works in one specific scenario and isn’t composable. That’s usually what makes things interesting and unique.
Enter: ButtonGroup
I was looking through all the nodes Godot had to offer for inspiration, spitefully ignoring the Area2D node which requires CollisionShape2Ds be added as children in order to work. I found nothing there that would help with my concern. But then I started scrolling through the resources that Godot provides and I found my salvation: ButtonGroup!
UI Composition
The way Godot expects you to compose scenes changes between its two different realms of operation. There’s the ‘World’ (using Node2Ds and Node3Ds) and there’s the ‘UI’ (using Control nodes). Even though these two different spaces utilize the exact same scene tree and node-based architecture, you’re actually supposed to treat them a little bit differently.
World-based nodes are built around the concept of mechanical composition like I described in detail above. Children provide capabilities to their parents. Parents expect certain children to be able to do things. However, things get flipped around on the UI side of things. Control nodes don’t necessarily expect anything from their children. They are controlling (probably overbearing) parents. To a control node, child nodes are only there for them to manipulate (which is making the appropriateness of the name ‘Control’ for these nodes more obvious). The reason for this is that the architecture of the ‘World’ nodes is based around the things that are supposed to happen. Whereas the architecture of the Control nodes is based around how things are supposed to look. Control nodes are built around the concept of visual composition. That might be why the Area2D node behaves how it does - these collision shapes and areas are more about controlling where the collision is in space rather than the mechanical properties of the collision.
This is an over-simplification since eventually some parent control node will want to know if a button has been pressed or something like that.
This is why ButtonGroup is necessary in the UI-world. The ButtonGroup resource is a clever way to separate mechanical composition and visual composition. If I need a few rows of weapon choices, they are probably set up like this:
> HBoxContainer (weapon_template.gd)
| > Image
| > Label
| > CheckButton
Each row is comprised of these controls, but I only want one of the choices to be selected at a time. How can I rearrange these nodes such that the check buttons all share the same parent? I could create a new Control node called ‘SingleSelection’ that expects all of its children to implement some sort of selected signal. Then I could make sure all of my WeaponTemplates sit under that SingleSelection node. But now we’re having the same problem we did with the FSM! We’re trying to force mechanical composition ontop of Control nodes built around visual composition. So instead, we utilize a resource to convey this information - the ButtonGroup resource. Each button that shares the same instance of this resource is automatically part of this group. We can create as many ButtonGroup resources as we want and put our buttons all over the place without caring about their position in the scene tree. Now we’ve effectively separated visual control from mechanical control using a shared resource.
Back to the FSM (Again)
So how are the FSM node and the StateNodes actually working together? They don’t require any visual composition, so treating them like Control nodes doesn’t make sense. But that’s sort of what was happening - the FSM was controlling the child StateNodes. They do require mechanical composition, but the states aren’t really providing their parent FSM node with additional functionality. They’re moreso providing functionality to their owner. So shouldn’t the owner be utilizing them for additional functionality? How can we convey that these states all belong to the same group, but keep them separated for better composability?
Enter: StateCoordinator
Who cares where my states are??? States are a mechanical feature in the ‘World’. They aren’t UI! I don’t want a pseudo-Control node telling its children what to do if its not changing something in visual space! Let’s take a hint from ButtonGroup and solve this problem with a StateCoordinator resource. We can keep our StateNode mostly the same. They will have _on_enter() and _on_exit(), but they will act a little differently in the other aspects. In order to more closely align with Godot’s principles, I’m going to change what a StateNode represents. A StateNode will now be a Node that wants to be toggled on and off based on some state that it cares about. Yes, the entire node, and all child nodes, should no longer be processed if the state isn’t valid. A StateNode will do its job until the StateCoordinator says its no longer needed. Then it will go to sleep until its time to wake up again. The StateCoordinator is a resource that can be added to any StateNode, which then adds it to the group of states that are sharing control of the mechanics.
Here’s a good starting point for the StateCoordinator and StateNode classes.
state_coordinator.gd
1class_name StateCoordinator
2extends Resource
3
4signal state_registered(state_node: StateNode)
5signal state_unregistered(state_node: StateNode)
6
7@export var debug_printing := true
8var states: Array[StateNode]
9var active_state: StateNode
10
11# Register a previously unregistered StateNode.
12# This adds it to the states that are checked while processing a trigger.
13func register(state_node: StateNode) -> void:
14 if state_node in states: return
15 states.append(state_node)
16 state_node.state_trigger.connect(process_trigger)
17 state_node.tree_exiting.connect(_on_state_tree_exiting.bind(state_node))
18 if state_node.startup_state and active_state == null:
19 activate_state(state_node, null)
20 else:
21 set_state_processes(state_node, false)
22 state_registered.emit(state_node)
23
24# Unregister a previously registered StateNode.
25# This removes it from the states that are checked while processing a trigger.
26func unregister(state_node: StateNode) -> void:
27 if !is_state_registered(state_node): return
28 deactivate_state(state_node)
29 states.erase(state_node)
30 state_node.state_trigger.disconnect(process_trigger)
31 state_node.tree_exiting.disconnect(_on_state_tree_exiting)
32 state_unregistered.emit(state_node)
33
34# Iterate through the registered states to see which ones want to be active.
35# The StateNode that returns the highest priority will be activated.
36func process_trigger(trigger):
37 var to_state: StateNode
38 var top_priority := -1
39 for state in states:
40 if !state.enabled or state == active_state: continue
41 var transition = state.get_transition(trigger)
42 if transition > top_priority:
43 to_state = state
44 top_priority = transition
45 if to_state != null:
46 deactivate_state(active_state)
47 activate_state(to_state, trigger)
48
49# Set state_node as active state and resume its processing.
50func activate_state(state_node: StateNode, trigger: Variant) -> void:
51 if active_state == state_node:
52 if debug_printing: print_debug("State %s already active." % state_node.name)
53 return
54 if !is_state_registered(state_node): return
55 if debug_printing: print_debug("Entering state %s" % state_node.name)
56 active_state = state_node
57 set_state_processes(state_node, true)
58 state_node.enter(trigger)
59
60# Set active_state to null and stop state_node from processing.
61func deactivate_state(state_node: StateNode) -> void:
62 if !is_state_registered(state_node): return
63 if debug_printing: print_debug("Exiting state %s" % state_node.name)
64 state_node.exit()
65 active_state = null
66 set_state_processes(state_node, false)
67
68func is_state_registered(state_node: StateNode) -> bool:
69 var result = state_node in states
70 if debug_printing and !result:
71 print_debug("State %s is not registered." % state_node.name)
72 return result
73
74func _on_state_tree_exiting(state_node: StateNode) -> void:
75 unregister(state_node)
76
77func set_state_processes(state_node: StateNode, process: bool) -> void:
78 if process:
79 state_node.process_mode = Node.PROCESS_MODE_INHERIT
80 else:
81 state_node.process_mode = Node.PROCESS_MODE_DISABLED
state_node.gd
1class_name StateNode
2extends Node
3
4# Call state_trigger.emit(x) when something happens that might trigger a state change.
5@warning_ignore("unused_signal")
6signal state_trigger(trigger)
7signal state_entered()
8signal state_exited()
9
10@export var coordinator: StateCoordinator:
11 get: return coordinator
12 set(value):
13 if coordinator == value: return
14 if coordinator != null:
15 coordinator.unregister(self)
16 coordinator = value
17 if coordinator != null:
18 if !is_node_ready():
19 await ready
20 coordinator.register(self)
21@export var enabled: bool = true
22@export var startup_state := false
23
24func enter(trigger = null) -> void:
25 _on_enter(trigger)
26 state_entered.emit(trigger)
27
28func exit() -> void:
29 _on_exit()
30 state_exited.emit()
31
32func get_transition(trigger) -> int:
33 if !enabled: return -1
34 else: return _get_transition(trigger)
35
36# VIRTUAL FUNCS ---------------------------------------------------
37# Override this to do something when this node becomes the active state
38func _on_enter(_trigger) -> void: pass
39# Override this to do something when this node stops being the active state
40func _on_exit() -> void: pass
41# Override this to send a positive number when a trigger is processed by the coordinator.
42# A higher number means its more important.
43func _get_transition(_trigger) -> int: return -1
44# -----------------------------------------------------------------
45
46func _get_configuration_warnings() -> PackedStringArray:
47 var result: PackedStringArray = []
48 if coordinator == null:
49 result.append("Coordinator must be assigned to work properly.")
50 return result
Welcome Back, Node Composition!
Let’s put our old GridObject node back together with this new, more composable design pattern.
> Node2D (grid_object.gd)
| > QuietState (quiet_state.gd)
| | > AnimatedSprite2D
| | > Timer
| > NoisyState (noisy_state.gd)
| | > AudioStreamPlayer2D
| | > Sprite2D
It doesn’t look too much different, but it feels much better. We can still default to QuietState, which will increment the AnimatedSprite2D’s frame when the Timer goes off. The GridObject can have a reference to its StateCoordinator and anytime is_surrounded() -> bool changes from true to false (or false to true), it can call something like coordinator.process_trigger(SurroundedInfo.new(true)) and the coordinator will activate whichever state is appropriate given the trigger information. When QuietState exits, it will hide() its AnimatedSprite2D. When NoisyState is entered, it will show() its Sprite2D. While the NoisyState is active, the AudioStreamPlayer2D can play whatever sounds are attached to it.
I hope its clear how much more opportunity for composition this pattern provides. NoisyState can actually be attached to any node. As long as it receives the appropriate trigger and returns that it wants to be active, it will just do its thing (be noisy)! And that can be true for any state created using this pattern. The state nodes now have the ability to provide another node with some kind of conditional functionality. Whichever nodes require this functionality can call on this functionality through attached scripts just like we initially did with the GridObject.
Conclusion
Using a more composition-based approached to the Finite State Machine design pattern utilizing Godot’s Resource type can help make more reusable Nodes and scripts. The StateCoordinator can easily be extended to have multiple states active at once or @export properties for different types of entities (enemies vs. friendlies). And the StateNode can utilize the @export feature as well to ensure it always has access to whichever Nodes it needs to operate. As always, consider the pros and cons of each approach before deciding which one is right for your game! Let me know if you try this out and what you think of it.