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.

InterfaceIStateMachineService — a factory that creates IStateMachine<TContext> instances
Off switchNullStateMachineService
AssemblyCommonGameSystem.Core
StartupRegistered 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.

MemberSignatureNotes
CurrentStateNamestring (property)Name of the current state, or null before Start.
IsRunningbool (property)true after Start, false after Dispose.
Addvoid Add(string name, IState<TContext> state)Register a state. Use public const string names to catch typos at compile time.
AddTransitionvoid 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.
Startvoid Start(string name)Enter a named state (calls OnEnter). Must be called once before Tick. Does not fire StateChanged.
ChangeStatevoid 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.
Tickvoid Tick(float deltaTime)Advance one step: call OnUpdate(deltaTime), then evaluate the declarative transitions. You pass the delta, so you pick the clock.
StateChangedevent Action<string, string>Fires after OnEnter on a transition, with (fromName, toName). Does not fire on Start.
Disposevoid 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:

MemberSignatureNotes
OnEntervoid OnEnter(TContext ctx)Called once when the machine enters this state.
OnUpdatevoid OnUpdate(TContext ctx, float deltaTime)Called every tick while this state is current. May call ChangeState.
OnExitvoid OnExit(TContext ctx)Called once when the machine leaves this state.

IStateMachineService — the factory

MemberSignatureNotes
Create<TContext>IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default)Returns a new per-instance machine. Factory-created machines are tracked in ActiveMachineCount.
ActiveMachineCountint (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, and Start must all run on the main thread. Worker-thread calls throw InvalidOperationException in 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 deltaTime to Tick. For behavior that pauses with the game, pass timeService.DeltaTime(Clock.Gameplay). To keep ticking through pause or slow motion, pass timeService.UnscaledDeltaTime(Clock.Gameplay). There is no separate "unscaled" clock: the clocks are Gameplay, UI, and Background, 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 TContext is a struct, changes made inside OnUpdate only 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 TContext is a value type, add a link.xml entry in your project so the generic instantiation StateMachine<YourStruct> survives IL2CPP code stripping. Class contexts need no entry.

Behavior guarantees

  • A ChangeState requested 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.
  • AnyState global 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.