-
Notifications
You must be signed in to change notification settings - Fork 3
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.
- Coroutine-backed states using
IEnumerator - Fluent state registration
- Transitions by yielding another registered state name
- Direct child
IEnumeratorroutines - 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
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.
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.
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.
_stateMachine.ChangeState("Chase");A normal state change:
- exits and disposes the current state enumerator
- records the previous state
- pushes the destination onto history
- creates a fresh enumerator from the destination factory
- invokes enter and changed callbacks
Calling ChangeState with the current state name still performs a full transition.
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.
Every normal transition records its destination in the history stack.
_stateMachine.ChangeState("MainMenu");
_stateMachine.ChangeState("Options");
_stateMachine.ChangeState("Audio");
_stateMachine.GoBack(); // OptionsGoBack() 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 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.
_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;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 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.
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.
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.
Home · Getting Started · Rendering · Custom Renderers · GitHub · Report an Issue
Built with VOID Engine · MIT License
- Coroutines
- State Machine
- Save System
- Pathfinding
- Logging
- Beacon Manager
- Beacon Event System
- Discoverable System
- Instance Helper
- BeaconManager Extensions
- BeaconHandle Extensions
- Enum Extensions
- String Extensions
- Int Extensions
- Float Extensions
- IEnumerable Extensions
- Random Extensions
- Sound Extensions
- Font Extensions