Skip to content

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.

  1. Download ss-gameforge-state-machine from the releases page.
  2. Copy addons/ss-gameforge-state-machine/ into your project’s res://addons/.
  3. Enable ss-gameforge-state-machine in Project Settings → Plugins.
ClassExtendsDescription
StateMachineNodeFSM controller — holds states and handles transitions.
StateBaseNodeBase class for every state. Override lifecycle methods here.
PropertyTypeDefaultDescription
default_stateStateBaseState active on _ready().
enable_debug_loggingboolfalsePrints state transitions to the output panel.
current_stateStateBaseCurrently active state (read-only).
previous_stateStateBaseLast active state (read-only).
signal state_changed(from: String, to: String)
func change_to(new_state: String) -> void # Transition to a state by name
func change_to_previous() -> void # Revert to the previous state
func get_state_history() -> Array[String] # Last 10 state names (debug)
PropertyTypeDescription
controlled_nodeNodeThe node this state controls. Set automatically by StateMachine.
state_machineStateMachineReference to the parent FSM. Set automatically.

Override these in your state classes:

func start() -> void # Called when this state becomes active
func end() -> void # Called when leaving this state
func on_process(delta: float) -> void # Called every frame
func on_physics_process(delta: float) -> void
func on_input(event: InputEvent) -> void
func on_unhandled_input(event: InputEvent) -> void
func on_unhandled_key_input(event: InputEvent) -> void
Player (CharacterBody2D)
└── StateMachine (StateMachine)
├── IdleState (StateBase script)
├── RunState (StateBase script)
└── JumpState (StateBase script)

Set StateMachine.default_state to the IdleState node in the inspector.

idle_state.gd
extends StateBase
class_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")
run_state.gd
extends StateBase
class_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")
# In the parent node or another script
func _ready() -> void:
$StateMachine.state_changed.connect(_on_state_changed)
func _on_state_changed(from: String, to: String) -> void:
print("Transition: %s%s" % [from, to])

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

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.

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.

enemy_state_base.gd
class_name EnemyStateBase
extends StateBase
## Typed accessor — avoids casting controlled_node in every state
var 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 false
enemy_patrol_state.gd
class_name EnemyPatrolState
extends 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()
enemy_chase_state.gd
class_name EnemyChaseState
extends 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()
enemy_fall_state.gd
class_name EnemyFallState
extends 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)
Enemy (CharacterBody2D)
├── Sprite2D
├── DetectionArea (Area2D)
├── EdgeDetector (RayCast2D)
└── StateMachine
├── PatrolState ← EnemyPatrolState script
├── ChaseState ← EnemyChaseState script
└── FallState ← EnemyFallState script
  • One intermediate base per character type. EnemyStateBase for enemies, PlayerStateBase for the player, etc.
  • Helpers return bool for transitions. Early-returning with if check_x(): return keeps 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.