Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when implementing state machines in Godot — enum-based, node-based, and resource-based FSM patterns with trade-offs
.claude/skills/jame581-state-machine/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-20 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 129% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 90% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 102% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 172% | 0% |
Choose the right FSM pattern for your complexity level. All examples target Godot 4.3+ with no deprecated APIs.
> Related skills: player-controller for movement state integration, ai-navigation for AI state patterns, resource-pattern for resource-based state configuration, animation-system for AnimationTree states driven by FSM, dialogue-system for dialogue flow as a state machine, ability-system for caster state gating (casting/stunned), limboai for the LimboAI addon's HSM (BTState) if you need a behavior tree alongside your FSM, beehave for a GDScript-only BT alternative.
> When to reach for an addon: This skill covers the built-in FSM patterns (enum, node-based, resource-based). If your agent needs a full behavior tree, see limboai (C++ + HSM, Godot 4.6+) or beehave (pure GDScript, Godot 4.1+) instead.
| Approach | Complexity | Best For | |----------------|------------|---------------------------------------| | Enum-Based | Low | Simple objects, fewer than 5 states | | Node-Based | Medium | Characters with complex behavior | | Resource-Based | High | Data-driven or editor-configurable AI |
Use when you have a small number of states and no significant enter/exit logic.
gdscriptextends CharacterBody2D enum State { IDLE, PATROL, CHASE, ATTACK } @export var patrol_range: float = 200.0 @export var chase_range: float = 300.0 @export var attack_range: float = 50.0 @export var speed: float = 80.0 var current_state: State = State.IDLE var patrol_target: Vector2 = Vector2.ZERO @onready var player: Node2D = get_tree().get_first_node_in_group("player") func _physics_process(delta: float) -> void: match current_state: State.IDLE: _state_idle() State.PATROL: _state_patrol() State.CHASE: _state_chase() State.ATTACK: _state_attack() move_and_slide() func _state_idle() -> void: velocity = Vector2.ZERO if _player_in_range(chase_range): current_state = State.CHASE elif randf() < 0.005: patrol_target = global_position + Vector2(randf_range(-patrol_range, patrol_range), 0.0) current_state = State.PATROL func _state_patrol() -> void: var direction := (patrol_target - global_position) if direction.length() < 4.0: current_state = State.IDLE return velocity = direction.normalized() * speed if _player_in_range(chase_range): current_state = State.CHASE func _state_chase() -> void: if not is_instance_valid(player): current_state = State.IDLE return if _player_in_range(attack_range): current_state = State.ATTACK return if not _player_in_range(chase_range): current_state = State.PATROL return velocity = (player.global_position - global_position).normalized() * speed func _state_attack() -> void: velocity = Vector2.ZERO if not _player_in_range(attack_range): current_state = State.CHASE func _player_in_range(range: float) -> bool: if not is_instance_valid(player): return false return global_position.distance_to(player.global_position) <= range
csharpusing Godot; public partial class SimpleEnemy : CharacterBody2D { private enum State { Idle, Patrol, Chase, Attack } [Export] public float PatrolRange { get; set; } = 200f; [Export] public float ChaseRange { get; set; } = 300f; [Export] public float AttackRange { get; set; } = 50f; [Export] public float Speed { get; set; } = 80f; private State _currentState = State.Idle; private Vector2 _patrolTarget = Vector2.Zero; private Node2D _player; public override void _Ready() { _player = GetTree().GetFirstNodeInGroup("player") as Node2D; } public override void _PhysicsProcess(double delta) { switch (_currentState) { case State.Idle: StateIdle(); break; case State.Patrol: StatePatrol(); break; case State.Chase: StateChase(); break; case State.Attack: StateAttack(); break; } MoveAndSlide(); } private void StateIdle() { Velocity = Vector2.Zero; if (PlayerInRange(ChaseRange)) { _currentState = State.Chase; } else if (GD.Randf() < 0.005f) { _patrolTarget = GlobalPosition + new Vector2(GD.RandRange(-PatrolRange, PatrolRange), 0f); _currentState = State.Patrol; } } private void StatePatrol() { var direction = _patrolTarget - GlobalPosition; if (direction.Length() < 4f) { _currentState = State.Idle; return; } Velocity = direction.Normalized() * Speed; if (PlayerInRange(ChaseRange)) _currentState = State.Chase; } private void StateChase() { if (!IsInstanceValid(_player)) { _currentState = State.Idle; return; } if (PlayerInRange(AttackRange)) { _currentState = State.Attack; return; } if (!PlayerInRange(ChaseRange)) { _currentState = State.Patrol; return; } Velocity = (_player.GlobalPosition - GlobalPosition).Normalized() * Speed; } private void StateAttack() { Velocity = Vector2.Zero; if (!PlayerInRange(AttackRange)) _currentState = State.Chase; } private bool PlayerInRange(float range) => IsInstanceValid(_player) && GlobalPosition.DistanceTo(_player.GlobalPosition) <= range; }
> When to upgrade away from enum-based: > - Enter/exit logic starts duplicating across state methods > - Animation sync requires explicit enter/exit hooks > - The match/switch block grows beyond ~100 lines
Each state is its own node. The StateMachine node delegates input and process calls to whichever state is active, and states trigger transitions by name.
Player (CharacterBody2D)
└── StateMachine (Node)
├── Idle (State)
├── Run (State)
├── Jump (State)
└── Attack (State)GDScript (state.gd)
gdscriptclass_name State extends Node ## Populated by StateMachine._ready() var entity: CharacterBody2D var state_machine: StateMachine ## Called when this state becomes active. func enter() -> void: pass ## Called when this state is deactivated. func exit() -> void: pass ## Mirrors _process. Return a state name string to transition, or "" to stay. func update(delta: float) -> String: return "" ## Mirrors _physics_process. Return a state name string to transition, or "". func physics_update(delta: float) -> String: return "" ## Mirrors _unhandled_input. func handle_input(event: InputEvent) -> String: return ""
C# (State.cs)
csharpusing Godot; public partial class State : Node { /// Populated by StateMachine._Ready() public CharacterBody2D Entity { get; set; } public StateMachine StateMachine { get; set; } public virtual void Enter() { } public virtual void Exit() { } public virtual string Update(double delta) => string.Empty; public virtual string PhysicsUpdate(double delta) => string.Empty; public virtual string HandleInput(InputEvent @event) => string.Empty; }
GDScript (state_machine.gd)
gdscriptclass_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.entity = owner as CharacterBody2D child.state_machine = self if initial_state: current_state = initial_state current_state.enter() func _unhandled_input(event: InputEvent) -> void: var next := current_state.handle_input(event) if next: transition_to(next) func _process(delta: float) -> void: var next := current_state.update(delta) if next: transition_to(next) func _physics_process(delta: float) -> void: var next := current_state.physics_update(delta) if next: transition_to(next) func transition_to(state_name: String) -> void: if not states.has(state_name): push_error("StateMachine: unknown state '%s'" % state_name) return current_state.exit() current_state = states[state_name] current_state.enter()
C# (StateMachine.cs)
csharpusing System.Collections.Generic; using Godot; public partial class StateMachine : Node { [Export] public State InitialState { get; set; } public State CurrentState { get; private set; } private readonly Dictionary<string, State> _states = new(); public override void _Ready() { foreach (var child in GetChildren()) { if (child is State state) { _states[state.Name] = state; state.Entity = Owner as CharacterBody2D; state.StateMachine = this; } } if (InitialState != null) { CurrentState = InitialState; CurrentState.Enter(); } } public override void _UnhandledInput(InputEvent @event) { var next = CurrentState.HandleInput(@event); if (!string.IsNullOrEmpty(next)) TransitionTo(next); } public override void _Process(double delta) { var next = CurrentState.Update(delta); if (!string.IsNullOrEmpty(next)) TransitionTo(next); } public override void _PhysicsProcess(double delta) { var next = CurrentState.PhysicsUpdate(delta); if (!string.IsNullOrEmpty(next)) TransitionTo(next); } public void TransitionTo(string stateName) { if (!_states.TryGetValue(stateName, out var next)) { GD.PushError($"StateMachine: unknown state '{stateName}'"); return; } CurrentState.Exit(); CurrentState = next; CurrentState.Enter(); } }
GDScript (idle_state.gd)
gdscriptclass_name IdleState extends State func enter() -> void: entity.get_node("AnimationPlayer").play("idle") func physics_update(delta: float) -> String: if not entity.is_on_floor(): return "Jump" if Input.get_axis("move_left", "move_right") != 0.0: return "Run" return "" func handle_input(event: InputEvent) -> String: if event.is_action_pressed("jump") and entity.is_on_floor(): return "Jump" if event.is_action_pressed("attack"): return "Attack" return ""
Use when designers need to configure states in the Godot Inspector without modifying code.
gdscriptclass_name StateData extends Resource @export var state_name: String = "" @export var animation_name: String = "" @export var move_speed: float = 0.0 @export var can_transition_to: Array[String] = []
Export an Array[StateData] on your AI controller. Designers populate each entry in the Inspector — no code changes needed to tune behavior or add states. The runtime reads can_transition_to to validate transitions and picks animation_name / move_speed for each active state.
csharpusing Godot; [GlobalClass] public partial class StateData : Resource { [Export] public string StateName { get; set; } = string.Empty; [Export] public string AnimationName { get; set; } = string.Empty; [Export] public float MoveSpeed { get; set; } = 0f; [Export] public Godot.Collections.Array<string> CanTransitionTo { get; set; } = new(); }
Attach an Array[StateData] export on your AI controller class ([Export] public Godot.Collections.Array<StateData> States). At runtime, look up the active StateData by StateName and read AnimationName / MoveSpeed to drive behavior; use CanTransitionTo to guard TransitionTo calls.
When a flat FSM grows beyond ~8 states or spans multiple concerns (movement + combat + animation), split into hierarchical machines (states own sub-state machines, e.g. OnGround containing Idle/Walk/Run) or parallel machines (independent FSMs for movement, combat, animation running side-by-side). Both keep state counts additive instead of multiplicative.
See references/hierarchical-and-parallel.md for full scene trees, HierarchicalState base class, parallel-machine character example, and a "which to choose" comparison table — GDScript and C# for each.
Start
│
▼
Fewer than 5 states?
├─ Yes ──────────────────────────────────► Enum-Based
└─ No
│
▼
Multiple independent concerns
(movement + combat + animation)?
├─ Yes ──────────────────────────────► Parallel State Machines
└─ No
│
▼
States naturally nest
(sub-states within states)?
├─ Yes ────────────────────────► Hierarchical State Machine
└─ No
│
▼
Designers need to configure
states in the Inspector?
├─ Yes ──────────────────► Resource-Based
└─ No ──────────────────► Node-Basedenter() and exit() methods (or equivalent)enter() and cleaned up in exit() where needed| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-20 | fail→pass | 27,732 | 20,924 | -25% | 1 | 1 | 0% | 5,043 | 7,709 | +53% | 0 | 0 | — |
case-01 | fail→pass | 15,325 | 12,527 | -18% | 1 | 1 | 0% | 2,886 | 6,596 | +129% | 0 | 0 | — |
case-02 | fail→fail | 19,545 | 16,710 | -15% | 1 | 1 | 0% | 3,511 | 7,009 | +100% | 0 | 0 | — |
case-03 | fail→pass | 17,736 | 12,919 | -27% | 1 | 1 | 0% | 3,373 | 6,422 | +90% | 0 | 0 | — |
case-04 | pass→pass | 15,155 | 15,071 | -1% | 1 | 1 | 0% | 2,667 | 6,920 | +159% | 0 | 0 | — |
case-05 | pass→pass | 16,263 | 14,542 | -11% | 1 | 1 | 0% | 2,753 | 6,454 | +134% | 0 | 0 | — |
case-06 | fail→pass | 16,353 | 11,638 | -29% | 1 | 1 | 0% | 3,180 | 6,414 | +102% | 0 | 0 | — |
case-07 | fail→pass | 10,246 | 6,577 | -36% | 1 | 1 | 0% | 1,859 | 5,057 | +172% | 0 | 0 | — |
case-08 | fail→fail | 16,730 | 13,446 | -20% | 1 | 1 | 0% | 2,512 | 6,039 | +140% | 0 | 0 | — |
case-09 | fail→pass | 9,119 | 7,577 | -17% | 1 | 1 | 0% | 1,652 | 5,297 | +221% | 0 | 0 | — |
case-10 | pass→pass | 14,552 | 13,933 | -4% | 1 | 1 | 0% | 2,422 | 6,355 | +162% | 0 | 0 | — |
case-11 | pass→pass | 16,341 | 15,625 | -4% | 1 | 1 | 0% | 2,552 | 6,784 | +166% | 0 | 0 | — |
case-12 | fail→pass | 16,781 | 12,305 | -27% | 1 | 1 | 0% | 2,703 | 6,096 | +126% | 0 | 0 | — |
case-13 | pass→pass | 11,853 | 7,023 | -41% | 1 | 1 | 0% | 1,697 | 4,901 | +189% | 0 | 0 | — |
case-14 | pass→pass | 14,183 | 11,386 | -20% | 1 | 1 | 0% | 2,431 | 5,954 | +145% | 0 | 0 | — |
case-15 | pass→pass | 13,939 | 8,992 | -35% | 1 | 1 | 0% | 2,244 | 5,252 | +134% | 0 | 0 | — |
case-16 | pass→pass | 7,663 | 5,300 | -31% | 1 | 1 | 0% | 1,416 | 4,817 | +240% | 0 | 0 | — |
case-17 | fail→pass | 16,078 | 11,914 | -26% | 1 | 1 | 0% | 2,822 | 5,970 | +112% | 0 | 0 | — |
case-18 | pass→pass | 17,700 | 14,753 | -17% | 1 | 1 | 0% | 2,825 | 6,434 | +128% | 0 | 0 | — |
case-19 | fail→pass | 16,981 | 11,161 | -34% | 1 | 1 | 0% | 2,796 | 5,911 | +111% | 0 | 0 | — |
case-21 | pass→pass | 9,251 | 10,627 | +15% | 1 | 1 | 0% | 1,494 | 5,700 | +282% | 0 | 0 | — |
case-22 | pass→pass | 20,068 | 21,367 | +6% | 1 | 1 | 0% | 3,837 | 7,916 | +106% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +41 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.