FSM (State Machines)
Deterministic per-object state machines you tick yourself — named states, guarded transitions, and global interrupts in pure C#.
Give every enemy, menu, and game phase a clean set of named states — and let declarative guarded transitions do the switching.
| Interface | IStateMachineService — a factory that creates IStateMachine<TContext> instances |
| Off switch | NullStateMachineService |
| Assembly | CommonGameSystem.Core |
| Startup | Registered automatically at boot (one of the 23 services) — no setup needed |
What it does
A finite state machine (FSM) organizes behavior into named states — for example Idle, Chase, and Dead for an enemy — with rules for moving between them. This service gives you a flat, headless FSM: you define each state as a small class, then drive the machine with declarative guarded transitions ("go from Idle to Chase when the enemy sees the player") or direct ChangeState calls.
Your game owns the clock: you tick each machine every frame and pass the delta time yourself. There is no internal ticker and no MonoBehaviour baggage. The machine is built for enemy AI, menu flows, and game-phase loops — zero allocation per tick, deterministic ordering, pure C#, and safe under IL2CPP (Unity's ahead-of-time compiler used for player builds).
Quick start
Resolve the factory once, then create a machine per object:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStateMachineUser : MonoBehaviour
{
private IStateMachine<MyStateMachineUser> _fsm;
private void Awake()
{
// Via the factory (recommended — you get diagnostics and the one-line off switch):
_fsm = ServiceLocator.Resolve<IStateMachineService>().Create<MyStateMachineUser>(this);
// Or construct one directly (works standalone, but is not counted in diagnostics):
// _fsm = new StateMachine<MyStateMachineUser>(this);
}
}
Cache the machine in a field, as shown. Do not resolve services inside Update().
API reference
IStateMachine<TContext> — the per-instance machine you tick each frame
TContext is the object your states read and modify — typically the owning MonoBehaviour or entity.
| Member | Signature | Notes |
|---|---|---|
CurrentStateName | string (property) | Name of the current state, or null before Start. |
IsRunning | bool (property) | true after Start, false after Dispose. |
Add | void Add(string name, IState<TContext> state) | Register a state. Use public const string names to catch typos at compile time. |
AddTransition | void AddTransition(string from, string to, Func<TContext, bool> guard) | Register a guarded transition. Use StateMachine<TContext>.AnyState as from for a global interrupt; those are evaluated first. Keep guards cheap and side-effect free — no raycasts, no mutations. |
Start | void Start(string name) | Enter a named state (calls OnEnter). Must be called once before Tick. Does not fire StateChanged. |
ChangeState | void ChangeState(string name) | Transition now: OnExit on the old state, OnEnter on the new one, then StateChanged fires. A safe no-op if the state name is unknown. |
Tick | void Tick(float deltaTime) | Advance one step: call OnUpdate(deltaTime), then evaluate the declarative transitions. You pass the delta, so you pick the clock. |
StateChanged | event Action<string, string> | Fires after OnEnter on a transition, with (fromName, toName). Does not fire on Start. |
Dispose | void Dispose() | Clean shutdown: calls OnExit on the current state once, then clears the tables. Safe to call twice. |
IState<TContext> — what you implement
Implement this interface, or extend StateBase<TContext> and override only what you need:
| Member | Signature | Notes |
|---|---|---|
OnEnter | void OnEnter(TContext ctx) | Called once when the machine enters this state. |
OnUpdate | void OnUpdate(TContext ctx, float deltaTime) | Called every tick while this state is current. May call ChangeState. |
OnExit | void OnExit(TContext ctx) | Called once when the machine leaves this state. |
IStateMachineService — the factory
| Member | Signature | Notes |
|---|---|---|
Create<TContext> | IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default) | Returns a new per-instance machine. Factory-created machines are tracked in ActiveMachineCount. |
ActiveMachineCount | int (property) | Count of factory-created machines not yet disposed. Machines made with new directly are not tracked. |
Examples
using CommonGameSystem.Core;
using UnityEngine;
// The context: the object your states read and modify.
public class Enemy : MonoBehaviour
{
public bool SeesPlayer;
public float Health = 100f;
}
// Define state names as public const strings to avoid typos.
public static class EnemyStates
{
public const string Idle = "Idle";
public const string Chase = "Chase";
public const string Dead = "Dead";
}
// Minimal states — extend StateBase and override only what you need.
public class IdleState : StateBase<Enemy> { }
public class ChaseState : StateBase<Enemy> { }
public class DeadState : StateBase<Enemy> { }
[DefaultExecutionOrder(100)] // Run after the framework has started.
public class EnemyController : MonoBehaviour
{
private IStateMachine<Enemy> _fsm;
private ITimeService _time;
private void Awake()
{
_time = ServiceLocator.Resolve<ITimeService>();
var service = ServiceLocator.Resolve<IStateMachineService>();
_fsm = service.Create<Enemy>(GetComponent<Enemy>());
_fsm.Add(EnemyStates.Idle, new IdleState());
_fsm.Add(EnemyStates.Chase, new ChaseState());
_fsm.Add(EnemyStates.Dead, new DeadState());
// Declarative transitions (preferred).
_fsm.AddTransition(EnemyStates.Idle, EnemyStates.Chase, enemy => enemy.SeesPlayer);
_fsm.AddTransition(EnemyStates.Chase, EnemyStates.Idle, enemy => !enemy.SeesPlayer);
// Global interrupt: any state can go to Dead.
_fsm.AddTransition(StateMachine<Enemy>.AnyState, EnemyStates.Dead, enemy => enemy.Health <= 0);
_fsm.Start(EnemyStates.Idle);
}
private void Update()
{
// The machine reads no clock — you pass the delta, so you pick the clock.
_fsm.Tick(_time.DeltaTime(Clock.Gameplay));
}
private void OnDestroy() => _fsm.Dispose();
}
Turning it off
ServiceLocator.Replace<IStateMachineService>(new NullStateMachineService());
This mutes the state machines: Tick, ChangeState, and Start become no-ops, CurrentStateName is always null, StateChanged never fires, and ActiveMachineCount is 0. Useful for disabling AI or menu flow during testing with no changes to calling code. The no-op service logs one warning when constructed; each later Create<T> call is silent.
Common pitfalls
- Main thread only.
Tick,ChangeState, andStartmust all run on the main thread. Worker-thread calls throwInvalidOperationExceptionin the editor (the checks are stripped in IL2CPP release builds). Keep the machine on the frame loop. - You pick the clock. The machine reads no clock — you pass
deltaTimetoTick. For behavior that pauses with the game, passtimeService.DeltaTime(Clock.Gameplay). To keep ticking through pause or slow motion, passtimeService.UnscaledDeltaTime(Clock.Gameplay). There is no separate "unscaled" clock: the clocks areGameplay,UI, andBackground, and each one has both a scaled and an unscaled reading — see Time. - Keep guards cheap. Transition guards run every tick for every machine. Expensive work in a guard — raycasts, pathfinding, allocations — multiplies by machines × transitions. Compute and cache such results on your context object before calling
Tick. - Struct contexts do not persist changes. If
TContextis astruct, changes made insideOnUpdateonly affect a local copy and are lost. Use a reference type — typically the owning MonoBehaviour or entity — as a mutable shared context. - Struct contexts and IL2CPP. If
TContextis a value type, add alink.xmlentry in your project so the generic instantiationStateMachine<YourStruct>survives IL2CPP code stripping. Class contexts need no entry.
Behavior guarantees
- A
ChangeStaterequested from inside a state hook is applied after the current hook finishes. If several are requested, the most recent one wins. The machine processes them in a loop, never by recursion — a chain of transitions cannot overflow the stack. AnyStateglobal interrupts are always evaluated before the current state's own transitions.default(StateMachineOptions)is the safe configuration: warnings on, state exceptions contained, and a transition to the current state is a no-op.- The machine is a flat FSM — there is no built-in hierarchy or state stacking. For stacked, resumable game states, see Pushdown Stack. For back-stack menu navigation, use the UI panel stack in UI Framework.
Related pages
- Time — pause-aware clocks to drive
Tick - Pushdown Stack — stacked, resumable states
- UI Framework — the UI panel stack for menus
- Getting Started — installing and booting the framework