Skip to content

State Machine

shmellyorc edited this page Sep 23, 2026 · 1 revision

State Machine

VOID includes a coroutine-based finite state machine for gameplay logic that is easier to express as named states instead of a large update method full of flags and branches.

StateMachine is useful for enemy AI, interactions, menus, game modes, scripted sequences, controllers, and any system that has one active behavior at a time.

Unlike a global state manager, each StateMachine is a small independent object that you own and update where it belongs.


What It Provides

  • Coroutine-backed states using IEnumerator
  • Fluent state registration
  • Transitions by yielding another registered state name
  • Direct child IEnumerator routines
  • Current and previous state tracking
  • State history with GoBack()
  • Normal restart and forced re-entry behavior
  • Pause and resume
  • Enter, exit, and changed callbacks
  • Access to the latest FrameTime
  • Automatic disposal of the active state enumerator when explicitly exited
  • Case-insensitive registered state lookups

Quick Start

private readonly StateMachine _stateMachine;

public Enemy()
{
    _stateMachine = new StateMachine()
        .AddState("Idle", Idle)
        .AddState("Chase", Chase)
        .AddState("Attack", Attack)
        .OnChanged((from, to) =>
        {
            Console.WriteLine($"{from} -> {to}");
        });

    _stateMachine.ChangeState("Idle");
}

public void Update(FrameTime frameTime)
{
    _stateMachine.Update(frameTime);
}

A state is just an IEnumerator factory:

private IEnumerator Idle()
{
    while (!CanSeePlayer())
        yield return null;

    yield return "Chase";
}

Yielding the name of another registered state requests a transition on the next state-machine update.


Coroutine-Backed States

States can keep local flow inside the iterator instead of spreading state-specific timers and flags across the owning object.

private IEnumerator Chase()
{
    while (!InAttackRange())
    {
        MoveTowardPlayer(_stateMachine.FrameTime.DeltaTime);
        yield return null;
    }

    yield return "Attack";
}

The machine stores the most recent FrameTime passed to Update, so active states can use the same timing data as the rest of VOID.

For general coroutine scheduling, handles, sequencing, concurrent routines, and reusable waits, see Coroutines.


Yielding Child Routines

A state can directly yield another IEnumerator.

private IEnumerator Attack()
{
    yield return new WaitForSeconds(0.25f);

    DealDamage();
    yield return "Chase";
}

The state machine advances that child directly until it completes, disposes it when applicable, then resumes the parent state. The child is not registered with CoroutineManager, so a simple state-local wait does not need another globally managed coroutine entry.

This direct-child behavior is intentionally shallow. Values yielded by that child are not recursively interpreted by the state machine. For deeper coroutine composition, use the normal Coroutine system.

A StateMachine cannot be yielded inside another StateMachine; nested state machines are deliberately not supported.


Transitions

ChangeState

_stateMachine.ChangeState("Chase");

A normal state change:

  1. exits and disposes the current state enumerator
  2. records the previous state
  3. pushes the destination onto history
  4. creates a fresh enumerator from the destination factory
  5. invokes enter and changed callbacks

Calling ChangeState with the current state name still performs a full transition.

Yield a State Name

private IEnumerator Attack()
{
    PerformAttack();
    yield return "Chase";
}

A yielded string only causes a transition when it matches a registered state. Other yielded values are ignored unless they are an IEnumerator child routine.


History and GoBack

Every normal transition records its destination in the history stack.

_stateMachine.ChangeState("MainMenu");
_stateMachine.ChangeState("Options");
_stateMachine.ChangeState("Audio");

_stateMachine.GoBack(); // Options

GoBack() does nothing when there is not enough history to return anywhere.

History can be cleared without changing the active state:

_stateMachine.ClearHistory();

This is useful for menus, interaction flows, mode navigation, and reversible gameplay states.


Restart and ForceChangeState

Restart the current state through a normal transition:

_stateMachine.Restart();

Restart() records the state in history again and runs the normal exit, enter, and changed callbacks.

Force re-entry is useful when you want a fresh enumerator for the already-current state without changing history or firing the changed callback:

_stateMachine.ForceChangeState("Attack");

If the requested state is different from the current state, ForceChangeState behaves like a normal ChangeState.


Pause and Resume

_stateMachine.Pause();
_stateMachine.Resume();

While paused, the active state is not advanced. Update(frameTime) still stores the supplied FrameTime, so the machine's timing reference remains current.

Useful status properties include:

bool running = _stateMachine.IsRunning;
bool paused = _stateMachine.IsPaused;
bool disposed = _stateMachine.IsDisposed;

string current = _stateMachine.CurrentState;
string previous = _stateMachine.PreviousState;
IReadOnlyCollection<string> states = _stateMachine.States;

Lifecycle Callbacks

Callbacks can be assigned directly:

_stateMachine.OnStateEnter = state => EnterState(state);
_stateMachine.OnStateExit = state => ExitState(state);
_stateMachine.OnStateChanged = (from, to) => StateChanged(from, to);

Or fluently:

_stateMachine
    .OnEnter(EnterState)
    .OnExit(ExitState)
    .OnChanged(StateChanged);

The callbacks make it easy to keep one-time setup and cleanup outside the main state loop.

A state that finishes naturally sets IsRunning to false. Natural completion does not invoke OnStateExit by itself. Exit callbacks run when the current state is explicitly exited by a state change, Stop(), or Dispose().


Stop and Dispose

Stop the current state while keeping registrations and history:

_stateMachine.Stop();

Dispose the entire machine when its owner is finished:

_stateMachine.Dispose();

Disposal stops and disposes the active state, clears callbacks, clears history, clears registered state factories, and marks the machine as disposed.


Performance Design

The FSM is designed so inactive states stay cheap.

  • Only the current state enumerator is advanced.
  • Inactive registered states are not polled or updated.
  • Registered state factories are stored in a dictionary for direct named lookup.
  • State history uses a stack.
  • A fresh state enumerator is created only when that state is entered.
  • Direct child routines are advanced locally instead of being registered with CoroutineManager.
  • There is no reflection in normal state updates or transitions.
  • There is no global state-machine manager or loop over every registered state.

That makes the runtime cost mostly proportional to the one state that is actually active, plus whatever work that state performs itself.

The FSM is not intended to replace direct code for trivial two-branch behavior. It becomes useful when explicit states make lifecycle, transitions, and control flow clearer.


State Machine vs Coroutines vs Beacons

These systems solve different problems and can be used together.

System Best for
State Machine One active named behavior with transitions and lifecycle
Coroutines Waits, sequences, tweens, concurrent routines, reusable timed flow
Beacon Manager Decoupled communication between otherwise unrelated systems

For example, an enemy FSM can transition from Idle to Chase, use a coroutine-style wait inside Attack, and publish a Beacon when it dies.


See Also


Back to Home

Clone this wiki locally