Overview
Production-grade patterns for hierarchical finite state machines (HSM), pushdown automata, context passing, transition validation, and concurrent state orchestration in Godot 4.7+. Covers state stacks, sub-states, transition guards, animation syncing, and data-driven state loading.
When to Use
- Basic FSMs are insufficient for your character/AI complexity
- Implementing layered AI with interruptive states (Pause, Menu, Stun)
- You need transition validation to prevent illegal state changes
- You need parallel state machines (e.g., movement + combat simultaneously)
- You need data-driven state definitions via
.tres Resources
- You need re-entry-aware states that distinguish fresh entry from stack-pop resume
- Keywords: state machine, HSM, hierarchical, pushdown automata, state stack, FSM, AI behavior, transition guard, concurrent states
Prerequisites
- Godot 4.7+ (stable, 2026-06-18). Consult the Godot 4.7 migration guide when upgrading from 4.6.
- NEVER assume 4.6 defaults (stretch mode, audio area_mask, RichTextLabel percent flags) without checking 4.7 migration notes.
- Windows host is primary (PowerShell). All paths use Windows conventions.
- This folder is the skill. Prefer
scripts/ here over any private cache path.
- MANDATORY: Read
scripts/hsm_hierarchical_base.gd before implementing hierarchical AI behaviors. This is the foundational delegator script.
Available Scripts
Load each reference file from scripts/ when the corresponding pattern is needed:
| Script |
Load When |
scripts/hsm_hierarchical_base.gd |
Always load first. HSM base delegator for propagating physics/input to sub-states. |
scripts/hsm_pushdown_stack.gd |
Implementing interruptive state stacking (Pause/Menu overlays). |
scripts/hsm_state_context.gd |
Passing persistent data between states without global singletons. |
scripts/hsm_transition_guard.gd |
Preventing illegal state transitions via validation rules. |
scripts/hsm_animation_syncer.gd |
Syncing logic state changes to AnimationTree travel logic. |
scripts/hsm_concurrent_logic.gd |
Running parallel state machines (e.g., Move + Attack simultaneously). |
scripts/hsm_resource_state_loader.gd |
Data-driven state definitions using custom Godot Resources (.tres). |
scripts/hsm_reentry_aware_state.gd |
Distinguishing resume-from-stack-pop vs fresh entry events. |
scripts/hsm_state_history_logger.gd |
Debug ring-buffer for tracking transition history and stack depth. |
scripts/hsm_state_timer_component.gd |
Auto-transition for finite-duration states (Stun, Dash, Cooldown). |
Procedure
1. Core HSM Setup
- Create
hierarchical_state.gd as the state machine root:
# hierarchical_state.gd
class_name HierarchicalState
extends Node
signal transitioned(from_state: String, to_state: String)
var current_state: Node
var state_stack: Array[Node] = []
func _ready() -> void:
for child in get_children():
child.state_machine = self
if get_child_count() > 0:
current_state = get_child(0)
current_state.enter()
func transition_to(state_name: String) -> void:
if not has_node(state_name):
return
var new_state := get_node(state_name)
if current_state:
current_state.exit()
transitioned.emit(current_state.name if current_state else "", state_name)
current_state = new_state
current_state.enter()
func push_state(state_name: String) -> void:
if current_state:
state_stack.append(current_state)
current_state.exit()
transition_to(state_name)
func pop_state() -> void:
if state_stack.is_empty():
return
var previous_state := state_stack.pop_back()
transition_to(previous_state.name)
- Create the base
State class — one state per file:
# state.gd
class_name State
extends Node
var state_machine: HierarchicalState
func enter() -> void:
pass
func exit() -> void:
pass
func update(delta: float) -> void:
pass
func physics_update(delta: float) -> void:
pass
func handle_input(event: InputEvent) -> void:
pass
- Add state nodes as children of the
HierarchicalState node in the scene tree. The first child becomes the initial state automatically.
2. Pushdown Automaton (Interruptive States)
- Load
scripts/hsm_pushdown_stack.gd for the full implementation.
- Use
push_state("Pause") when an interruptive state begins — the current state is saved to the stack and exit() is called.
- Use
pop_state() when the interruptive state ends — the previous state is restored via transition_to().
- Every
push_state MUST have a retirement plan (pop_state) — unbounded pushes cause stack overflow.
3. Context Passing (Decoupled Data)
- Load
scripts/hsm_state_context.gd.
- Create a context object holding shared data (health, target, input vector, etc.).
- Pass the context into
enter() / update() / physics_update() instead of reading global singletons.
- States remain reusable across different characters because they depend on the context interface, not global state.
4. Transition Guards
- Load
scripts/hsm_transition_guard.gd.
- Define allowed transitions as a dictionary or adjacency map:
{"Idle": ["Move", "Attack"], "Attack": ["Idle", "Hit"]}.
- In
transition_to(), check the guard before proceeding. Reject illegal transitions silently or with a debug warning.
5. Re-entry-Aware States
- Load
scripts/hsm_reentry_aware_state.gd.
- Override
enter() to accept a is_reentry: bool parameter (or check a flag).
- On fresh entry: play entry SFX/VFX, initialize timers.
- On re-entry from stack pop: skip entry SFX/VFX, resume from where the state was interrupted.
6. Concurrent State Machines
- Load
scripts/hsm_concurrent_logic.gd.
- Run two or more state machines as siblings (e.g.,
MovementStateMachine + CombatStateMachine).
- Each machine processes its own states independently. Coordinate via signals or a shared context object.
7. Animation Syncing
- Load
scripts/hsm_animation_syncer.gd.
- Connect the state machine's
transitioned signal to the syncer.
- Map state names to AnimationTree travel conditions or animation names.
- The syncer drives
AnimationTree travel without hardcoding play() calls inside state enter() methods.
8. Data-Driven State Loading
- Load
scripts/hsm_resource_state_loader.gd.
- Define custom
Resource classes for state definitions (name, transitions, properties).
- Save as
.tres files. The loader instantiates state nodes from resource definitions at runtime.
9. State Timer Component
- Load
scripts/hsm_state_timer_component.gd.
- Attach to finite-duration states (Stun, Dash, Cooldown).
- Configure duration. On timeout, the component triggers
transition_to() to the next state automatically.
10. Debug History Logger
- Load
scripts/hsm_state_history_logger.gd.
- Attach to the state machine node. It maintains a ring-buffer of recent transitions.
- Query the buffer at runtime or print to console for debugging unexpected state sequences.
Expert Patterns
HSM Visualizer (Debug Tool)
Use a Control node with _draw() to visualize the current state stack/hierarchy in the viewport:
class_name HSMVisualizer extends Control
@export var state_machine: Node
func _draw() -> void:
var font := ThemeDB.fallback_font
var pos := Vector2(20, 20)
draw_string(font, pos, "Active: " + state_machine.current_state.name)
State-Based Audio (Decoupled)
Use a syncer that listens to transitioned and maps state names to AudioStream resources — never hardcode audio.play() inside enter():
class_name StateAudioSyncer extends Node
@export var state_machine: Node
@export var audio_map: Dictionary # { "Jump": preload("jump.wav") }
func _ready() -> void:
state_machine.transitioned.connect(_on_state_changed)
func _on_state_changed(_old, new_state: String):
if audio_map.has(new_state):
$AudioPlayer.stream = audio_map[new_state]
$AudioPlayer.play()
Transition Cost (Utility AI)
Enable states to evaluate their own weight based on context. The state machine polls sibling costs and transitions to the lowest-cost behavior:
# CostState.gd (Base)
func get_cost(context: Dictionary) -> float:
return 10.0 # Default weight
# UtilityStateMachine.gd
func _physics_process(_d: float) -> void:
var best_state: Node = current_state
var low_cost: float = INF
for child in get_children():
var cost = child.get_cost(context)
if cost < low_cost:
low_cost = cost
best_state = child
if best_state != current_state:
transition_to(best_state.name)
Pitfalls
Hierarchy & Delegation
- NEVER forget to propagate physics/input to children — In an HSM, failing to call
child.physics_update() from the parent's _physics_process orphans child logic. The child's update never runs.
- NEVER use deep nesting (>3 levels) — Extreme hierarchy creates "State Spaghetti." If logic is that complex, consider a Behavior Tree or Utility AI instead.
Transitions & Lifecycle
- NEVER call
enter() without a preceding exit() — Skipping exit logic leaves timers, tweens, or audio loops running in the background, causing resource leaks.
- NEVER modify state during a transition frame — Re-entrant
transition_to() calls inside enter() cause recursion crashes. Use call_deferred("transition_to", state_name) if immediate sub-transitioning is required.
- NEVER hardcode state names as strings — Typos like
transition_to("Idel") are silent killers. Use class_name-based checks OR string constants.
Architecture & Context
- NEVER use global singletons for state data — Coupling states to
GameManager.player_health makes them non-reusable. Pass a Context object instead.
- NEVER push states indefinitely — In a Pushdown Automaton, every
push_state MUST have a retirement plan (pop_state) to avoid stack overflow.
- NEVER assume state re-entry is always a fresh start — Resuming from a stack pop should often bypass "Entry SFX/VFX"; use re-entry flags.
Engine Version
- NEVER assume 4.6 defaults without checking 4.7 migration notes (stretch mode, audio area_mask, RichTextLabel percent flags).
Verification
Confirm state propagation works — add a print in each child state's physics_update():
func physics_update(delta: float) -> void:
print(name, " physics_update running")
Run the scene and verify child state prints appear each physics frame.
Verify push/pop balance — instrument the stack:
print("Stack depth: ", state_machine.state_stack.size())
After a full push/pop cycle, stack depth must return to its original value. If it grows unboundedly, a pop_state is missing.
Verify transition guards reject illegal transitions — attempt a disallowed transition and confirm it is blocked (no state change, no crash, debug warning logged).
Verify no resource leaks on exit — after 50+ transitions, check for orphaned tweens/timers:
print("Tween count: ", get_tree().get_processed_tweens().size())
The count should not grow over time if exit() properly cleans up.
Verify re-entry flag — push a state, pop it, and confirm the resumed state's enter() receives is_reentry = true and skips entry SFX/VFX.
Verify concurrent machines — with two state machines active, confirm both physics_update() methods run independently each frame without interfering with each other's current_state.
Related Skills
godot-characterbody-2d — character body controller that pairs with this state machine
godot-animation-player — animation playback integration for state-driven animation
- Master Skill:
godot-master
1---2name: godot-state-machine-advanced3description: Implements Godot 4.7+ hierarchical FSMs and pushdown automata: state stacks, transition_to/push_state, transition guards, concurrent Move+Combat machines, and re-entry flags. Use when Pause/Stun interrupts or layered AI outgrow a flat FSM. Do not use for Has-A orchestrator splits (godot-composition) or AnimationTree BlendSpace locomotion. Never skip exit() before enter() or nest deeper than three levels.4---5
6## Overview
7
8Production-grade patterns for hierarchical finite state machines (HSM), pushdown automata, context passing, transition validation, and concurrent state orchestration in Godot 4.7+. Covers state stacks, sub-states, transition guards, animation syncing, and data-driven state loading.
9
10## When to Use
11
12- Basic FSMs are insufficient for your character/AI complexity
13- Implementing layered AI with interruptive states (Pause, Menu, Stun)
14- You need transition validation to prevent illegal state changes
15- You need parallel state machines (e.g., movement + combat simultaneously)
16- You need data-driven state definitions via `.tres` Resources
17- You need re-entry-aware states that distinguish fresh entry from stack-pop resume
18- Keywords: state machine, HSM, hierarchical, pushdown automata, state stack, FSM, AI behavior, transition guard, concurrent states
19
20## Prerequisites
21
22- **Godot 4.7+** (stable, 2026-06-18). Consult the [Godot 4.7 migration guide](https://docs.godotengine.org/en/4.7/tutorials/migrating/upgrading_to_godot_4.7.html) when upgrading from 4.6.
23- **NEVER** assume 4.6 defaults (stretch mode, audio area_mask, RichTextLabel percent flags) without checking 4.7 migration notes.
24- Windows host is primary (PowerShell). All paths use Windows conventions.
25- This folder is the skill. Prefer `scripts/` here over any private cache path.
26- **MANDATORY**: Read `scripts/hsm_hierarchical_base.gd` before implementing hierarchical AI behaviors. This is the foundational delegator script.
27
28## Available Scripts
29
30Load each reference file from `scripts/` when the corresponding pattern is needed:
31
32| Script | Load When |
33|---|---|
34| `scripts/hsm_hierarchical_base.gd` | **Always load first.** HSM base delegator for propagating physics/input to sub-states. |
35| `scripts/hsm_pushdown_stack.gd` | Implementing interruptive state stacking (Pause/Menu overlays). |
36| `scripts/hsm_state_context.gd` | Passing persistent data between states without global singletons. |
37| `scripts/hsm_transition_guard.gd` | Preventing illegal state transitions via validation rules. |
38| `scripts/hsm_animation_syncer.gd` | Syncing logic state changes to AnimationTree travel logic. |
39| `scripts/hsm_concurrent_logic.gd` | Running parallel state machines (e.g., Move + Attack simultaneously). |
40| `scripts/hsm_resource_state_loader.gd` | Data-driven state definitions using custom Godot Resources (`.tres`). |
41| `scripts/hsm_reentry_aware_state.gd` | Distinguishing resume-from-stack-pop vs fresh entry events. |
42| `scripts/hsm_state_history_logger.gd` | Debug ring-buffer for tracking transition history and stack depth. |
43| `scripts/hsm_state_timer_component.gd` | Auto-transition for finite-duration states (Stun, Dash, Cooldown). |
44
45## Procedure
46
47### 1. Core HSM Setup
48
491. Create `hierarchical_state.gd` as the state machine root:
50
51```gdscript
52# hierarchical_state.gd
53class_name HierarchicalState
54extends Node
55
56signal transitioned(from_state: String, to_state: String)
57
58var current_state: Node
59var state_stack: Array[Node] = []
60
61func _ready() -> void:
62 for child in get_children():
63 child.state_machine = self
64
65 if get_child_count() > 0:
66 current_state = get_child(0)
67 current_state.enter()
68
69func transition_to(state_name: String) -> void:
70 if not has_node(state_name):
71 return
72
73 var new_state := get_node(state_name)
74
75 if current_state:
76 current_state.exit()
77
78 transitioned.emit(current_state.name if current_state else "", state_name)
79 current_state = new_state
80 current_state.enter()
81
82func push_state(state_name: String) -> void:
83 if current_state:
84 state_stack.append(current_state)
85 current_state.exit()
86
87 transition_to(state_name)
88
89func pop_state() -> void:
90 if state_stack.is_empty():
91 return
92
93 var previous_state := state_stack.pop_back()
94 transition_to(previous_state.name)
95```
96
972. Create the base `State` class — one state per file:
98
99```gdscript
100# state.gd
101class_name State
102extends Node
103
104var state_machine: HierarchicalState
105
106func enter() -> void:
107 pass
108
109func exit() -> void:
110 pass
111
112func update(delta: float) -> void:
113 pass
114
115func physics_update(delta: float) -> void:
116 pass
117
118func handle_input(event: InputEvent) -> void:
119 pass
120```
121
1223. Add state nodes as children of the `HierarchicalState` node in the scene tree. The first child becomes the initial state automatically.
123
124### 2. Pushdown Automaton (Interruptive States)
125
1261. Load `scripts/hsm_pushdown_stack.gd` for the full implementation.
1272. Use `push_state("Pause")` when an interruptive state begins — the current state is saved to the stack and `exit()` is called.
1283. Use `pop_state()` when the interruptive state ends — the previous state is restored via `transition_to()`.
1294. **Every `push_state` MUST have a retirement plan (`pop_state`)** — unbounded pushes cause stack overflow.
130
131### 3. Context Passing (Decoupled Data)
132
1331. Load `scripts/hsm_state_context.gd`.
1342. Create a context object holding shared data (health, target, input vector, etc.).
1353. Pass the context into `enter()` / `update()` / `physics_update()` instead of reading global singletons.
1364. States remain reusable across different characters because they depend on the context interface, not global state.
137
138### 4. Transition Guards
139
1401. Load `scripts/hsm_transition_guard.gd`.
1412. Define allowed transitions as a dictionary or adjacency map: `{"Idle": ["Move", "Attack"], "Attack": ["Idle", "Hit"]}`.
1423. In `transition_to()`, check the guard before proceeding. Reject illegal transitions silently or with a debug warning.
143
144### 5. Re-entry-Aware States
145
1461. Load `scripts/hsm_reentry_aware_state.gd`.
1472. Override `enter()` to accept a `is_reentry: bool` parameter (or check a flag).
1483. On fresh entry: play entry SFX/VFX, initialize timers.
1494. On re-entry from stack pop: skip entry SFX/VFX, resume from where the state was interrupted.
150
151### 6. Concurrent State Machines
152
1531. Load `scripts/hsm_concurrent_logic.gd`.
1542. Run two or more state machines as siblings (e.g., `MovementStateMachine` + `CombatStateMachine`).
1553. Each machine processes its own states independently. Coordinate via signals or a shared context object.
156
157### 7. Animation Syncing
158
1591. Load `scripts/hsm_animation_syncer.gd`.
1602. Connect the state machine's `transitioned` signal to the syncer.
1613. Map state names to AnimationTree travel conditions or animation names.
1624. The syncer drives `AnimationTree` travel without hardcoding `play()` calls inside state `enter()` methods.
163
164### 8. Data-Driven State Loading
165
1661. Load `scripts/hsm_resource_state_loader.gd`.
1672. Define custom `Resource` classes for state definitions (name, transitions, properties).
1683. Save as `.tres` files. The loader instantiates state nodes from resource definitions at runtime.
169
170### 9. State Timer Component
171
1721. Load `scripts/hsm_state_timer_component.gd`.
1732. Attach to finite-duration states (Stun, Dash, Cooldown).
1743. Configure duration. On timeout, the component triggers `transition_to()` to the next state automatically.
175
176### 10. Debug History Logger
177
1781. Load `scripts/hsm_state_history_logger.gd`.
1792. Attach to the state machine node. It maintains a ring-buffer of recent transitions.
1803. Query the buffer at runtime or print to console for debugging unexpected state sequences.
181
182## Expert Patterns
183
184### HSM Visualizer (Debug Tool)
185
186Use a `Control` node with `_draw()` to visualize the current state stack/hierarchy in the viewport:
187
188```gdscript
189class_name HSMVisualizer extends Control
190@export var state_machine: Node
191
192func _draw() -> void:
193 var font := ThemeDB.fallback_font
194 var pos := Vector2(20, 20)
195 draw_string(font, pos, "Active: " + state_machine.current_state.name)
196```
197
198### State-Based Audio (Decoupled)
199
200Use a syncer that listens to `transitioned` and maps state names to `AudioStream` resources — never hardcode `audio.play()` inside `enter()`:
201
202```gdscript
203class_name StateAudioSyncer extends Node
204@export var state_machine: Node
205@export var audio_map: Dictionary # { "Jump": preload("jump.wav") }
206
207func _ready() -> void:
208 state_machine.transitioned.connect(_on_state_changed)
209
210func _on_state_changed(_old, new_state: String):
211 if audio_map.has(new_state):
212 $AudioPlayer.stream = audio_map[new_state]
213 $AudioPlayer.play()
214```
215
216### Transition Cost (Utility AI)
217
218Enable states to evaluate their own weight based on context. The state machine polls sibling costs and transitions to the lowest-cost behavior:
219
220```gdscript
221# CostState.gd (Base)
222func get_cost(context: Dictionary) -> float:
223 return 10.0 # Default weight
224
225# UtilityStateMachine.gd
226func _physics_process(_d: float) -> void:
227 var best_state: Node = current_state
228 var low_cost: float = INF
229 for child in get_children():
230 var cost = child.get_cost(context)
231 if cost < low_cost:
232 low_cost = cost
233 best_state = child
234 if best_state != current_state:
235 transition_to(best_state.name)
236```
237
238## Pitfalls
239
240### Hierarchy & Delegation
241
242- **NEVER forget to propagate physics/input to children** — In an HSM, failing to call `child.physics_update()` from the parent's `_physics_process` orphans child logic. The child's update never runs.
243- **NEVER use deep nesting (>3 levels)** — Extreme hierarchy creates "State Spaghetti." If logic is that complex, consider a Behavior Tree or Utility AI instead.
244
245### Transitions & Lifecycle
246
247- **NEVER call `enter()` without a preceding `exit()`** — Skipping exit logic leaves timers, tweens, or audio loops running in the background, causing resource leaks.
248- **NEVER modify state during a transition frame** — Re-entrant `transition_to()` calls inside `enter()` cause recursion crashes. Use `call_deferred("transition_to", state_name)` if immediate sub-transitioning is required.
249- **NEVER hardcode state names as strings** — Typos like `transition_to("Idel")` are silent killers. Use `class_name`-based checks OR string constants.
250
251### Architecture & Context
252
253- **NEVER use global singletons for state data** — Coupling states to `GameManager.player_health` makes them non-reusable. Pass a `Context` object instead.
254- **NEVER push states indefinitely** — In a Pushdown Automaton, every `push_state` MUST have a retirement plan (`pop_state`) to avoid stack overflow.
255- **NEVER assume state re-entry is always a fresh start** — Resuming from a stack pop should often bypass "Entry SFX/VFX"; use re-entry flags.
256
257### Engine Version
258
259- **NEVER assume 4.6 defaults** without checking 4.7 migration notes (stretch mode, audio area_mask, RichTextLabel percent flags).
260
261## Verification
262
2631. **Confirm state propagation works** — add a print in each child state's `physics_update()`:
264 ```gdscript
265 func physics_update(delta: float) -> void:
266 print(name, " physics_update running")
267 ```
268 Run the scene and verify child state prints appear each physics frame.
269
2702. **Verify push/pop balance** — instrument the stack:
271 ```gdscript
272 print("Stack depth: ", state_machine.state_stack.size())
273 ```
274 After a full push/pop cycle, stack depth must return to its original value. If it grows unboundedly, a `pop_state` is missing.
275
2763. **Verify transition guards reject illegal transitions** — attempt a disallowed transition and confirm it is blocked (no state change, no crash, debug warning logged).
277
2784. **Verify no resource leaks on exit** — after 50+ transitions, check for orphaned tweens/timers:
279 ```gdscript
280 print("Tween count: ", get_tree().get_processed_tweens().size())
281 ```
282 The count should not grow over time if `exit()` properly cleans up.
283
2845. **Verify re-entry flag** — push a state, pop it, and confirm the resumed state's `enter()` receives `is_reentry = true` and skips entry SFX/VFX.
285
2866. **Verify concurrent machines** — with two state machines active, confirm both `physics_update()` methods run independently each frame without interfering with each other's `current_state`.
287
288## Related Skills
289
290- `godot-characterbody-2d` — character body controller that pairs with this state machine
291- `godot-animation-player` — animation playback integration for state-driven animation
292- Master Skill: `godot-master`