State Machine
ss-gameforge-state-machine provides a lightweight FSM for managing game logic states.
Each state is a separate node with its own lifecycle methods, keeping your code organized and easy to extend.
Installation
Section titled “Installation”- Download
ss-gameforge-state-machinefrom the releases page. - Copy
addons/ss-gameforge-state-machine/into your project’sres://addons/. - Enable ss-gameforge-state-machine in Project Settings → Plugins.
Core classes
Section titled “Core classes”| Class | Extends | Description |
|---|---|---|
StateMachine | Node | FSM controller — holds states and handles transitions. |
StateBase | Node | Base class for every state. Override lifecycle methods here. |
StateMachine
Section titled “StateMachine”Properties
Section titled “Properties”| Property | Type | Default | Description |
|---|---|---|---|
default_state | StateBase | — | State active on _ready(). |
enable_debug_logging | bool | false | Prints state transitions to the output panel. |
current_state | StateBase | — | Currently active state (read-only). |
previous_state | StateBase | — | Last active state (read-only). |
Signals
Section titled “Signals”signal state_changed(from: String, to: String)Methods
Section titled “Methods”func change_to(new_state: String) -> void # Transition to a state by namefunc change_to_previous() -> void # Revert to the previous statefunc get_state_history() -> Array[String] # Last 10 state names (debug)StateBase
Section titled “StateBase”Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
controlled_node | Node | The node this state controls. Set automatically by StateMachine. |
state_machine | StateMachine | Reference to the parent FSM. Set automatically. |
Lifecycle methods
Section titled “Lifecycle methods”Override these in your state classes:
func start() -> void # Called when this state becomes activefunc end() -> void # Called when leaving this statefunc on_process(delta: float) -> void # Called every framefunc on_physics_process(delta: float) -> voidfunc on_input(event: InputEvent) -> voidfunc on_unhandled_input(event: InputEvent) -> voidfunc on_unhandled_key_input(event: InputEvent) -> voidQuick start
Section titled “Quick start”Scene setup
Section titled “Scene setup”Player (CharacterBody2D)└── StateMachine (StateMachine) ├── IdleState (StateBase script) ├── RunState (StateBase script) └── JumpState (StateBase script)Set StateMachine.default_state to the IdleState node in the inspector.
State scripts
Section titled “State scripts”extends StateBaseclass_name IdleState
func start() -> void: controlled_node.velocity = Vector2.ZERO
func on_physics_process(delta: float) -> void: if Input.is_action_pressed("ui_right") or Input.is_action_pressed("ui_left"): state_machine.change_to("RunState") if Input.is_action_just_pressed("ui_accept"): state_machine.change_to("JumpState")extends StateBaseclass_name RunState
func on_physics_process(delta: float) -> void: var direction := Input.get_axis("ui_left", "ui_right") controlled_node.velocity.x = direction * 200.0 controlled_node.move_and_slide()
if direction == 0: state_machine.change_to("IdleState")Listening to transitions
Section titled “Listening to transitions”# In the parent node or another scriptfunc _ready() -> void: $StateMachine.state_changed.connect(_on_state_changed)
func _on_state_changed(from: String, to: String) -> void: print("Transition: %s → %s" % [from, to])Use cases
Section titled “Use cases”The FSM works for any game object with discrete states:
- Characters — Idle / Run / Jump / Attack / Dead
- Enemies — Patrol / Chase / Attack / Stunned
- UI flows — MainMenu / Loading / Gameplay / Paused / GameOver
- Cutscenes — Intro / Dialogue / Outro
Shared base state pattern
Section titled “Shared base state pattern”When a character has many states that share common logic — gravity, input reading, transition checks — repeating that code in every state becomes a maintenance problem. The solution is an intermediate base class that sits between StateBase and your concrete states.
Why this pattern
Section titled “Why this pattern”Without it, each state duplicates the same checks:
# Repeated in IdleState, RunState, CrouchState...func on_physics_process(delta: float) -> void: if not controlled_node.is_on_floor(): state_machine.change_to("FallState") if Input.is_action_just_pressed("jump") and controlled_node.is_on_floor(): state_machine.change_to("JumpState")With an intermediate base, that logic lives in one place and every state inherits it.
Example: EnemyStateBase
Section titled “Example: EnemyStateBase”class_name EnemyStateBaseextends StateBase
## Typed accessor — avoids casting controlled_node in every statevar enemy: Enemy: set(value): controlled_node = value get: return controlled_node as Enemy
## ── Shared helpers ──────────────────────────────────
func apply_gravity() -> void: if not enemy.is_on_floor(): enemy.velocity.y += enemy.stats.gravity enemy.velocity.y = minf(enemy.velocity.y, enemy.stats.max_fall_speed)
func face_direction(dir: float) -> void: if not is_zero_approx(dir): enemy.sprite.scale.x = sign(dir)
func is_player_in_range() -> bool: return enemy.detection_area.has_overlapping_bodies()
func get_player_direction() -> float: var player := enemy.detection_area.get_overlapping_bodies().front() as Node2D if player: return sign(player.global_position.x - enemy.global_position.x) return 0.0
func check_alert_transition() -> bool: if is_player_in_range(): state_machine.change_to(enemy.states.CHASING) return true return false
func check_fall_transition() -> bool: if not enemy.is_on_floor(): state_machine.change_to(enemy.states.FALLING) return true return falseConcrete states using the shared base
Section titled “Concrete states using the shared base”class_name EnemyPatrolStateextends EnemyStateBase
var _direction := 1.0
func start() -> void: _direction = enemy.sprite.scale.x
func on_physics_process(delta: float) -> void: apply_gravity()
if check_alert_transition(): return if check_fall_transition(): return
# Reverse at edges if enemy.edge_detector.is_colliding(): _direction *= -1.0
enemy.velocity.x = _direction * enemy.stats.patrol_speed face_direction(_direction) enemy.move_and_slide()class_name EnemyChaseStateextends EnemyStateBase
func on_physics_process(delta: float) -> void: apply_gravity()
if not is_player_in_range(): state_machine.change_to(enemy.states.PATROLLING) return
if check_fall_transition(): return
var dir := get_player_direction() enemy.velocity.x = dir * enemy.stats.chase_speed face_direction(dir) enemy.move_and_slide()class_name EnemyFallStateextends EnemyStateBase
func on_physics_process(delta: float) -> void: apply_gravity() enemy.move_and_slide()
if enemy.is_on_floor(): state_machine.change_to(enemy.states.PATROLLING)Scene structure
Section titled “Scene structure”Enemy (CharacterBody2D)├── Sprite2D├── DetectionArea (Area2D)├── EdgeDetector (RayCast2D)└── StateMachine ├── PatrolState ← EnemyPatrolState script ├── ChaseState ← EnemyChaseState script └── FallState ← EnemyFallState scriptKey takeaways
Section titled “Key takeaways”- One intermediate base per character type.
EnemyStateBasefor enemies,PlayerStateBasefor the player, etc. - Helpers return
boolfor transitions. Early-returning withif check_x(): returnkeeps state logic linear and easy to read. - Typed accessor costs nothing. It reuses
controlled_node— just adds a typed getter for convenience. - Concrete states stay focused. Each state only describes what’s unique about it. The shared infrastructure is invisible boilerplate.