Allocation-free finite and hierarchical state machines that run one active state at a time over a shared context, with guarded transitions and explicit tick control, for AI, character controllers, and any object whose behavior changes with its state.
Summary
A state machine tracks which of several named states an object is currently in, runs that state’s logic each frame, and switches to another state only through explicit, guarded transitions. It is the natural choice for anything whose behavior is a set of distinct modes: a character that is idle, running, or jumping; an enemy that patrols, chases, or attacks; a door that is open, closed, or locked. The machine owns the “which mode am I in” bookkeeping so the state code can stay focused on one mode at a time.
Scylla Core provides a two-class family plus the pieces they build on:
ScyllaStateMachine<TContext>is the flat FSM (Finite State Machine). One state is current at a time, and a transition runs the old state’s exit callback then the new state’s enter callback.ScyllaHierarchicalStateMachine<TContext>is the HSM (Hierarchical State Machine). It adds queries over the active root-to-leaf state chain when states nest inside one another.ScyllaCompositeState<TContext>is a state that owns a nested child machine, which is how one level of hierarchy is expressed.IScyllaState<TContext>is the state contract, andScyllaStateBase<TContext>is the convenience base whose callbacks are virtual no-ops, so a state overrides only what it needs.
Every state operates on a shared TContext, a blackboard object holding the data all states of one machine read and write (the character, its rigidbody, the input snapshot, whatever the states need). The machine is a plain C# class with no Unity dependency, performs no heap allocations after construction, and does nothing until it is driven: game code calls Tick and FixedTick explicitly, so a paused or disabled machine costs nothing.
Use the state machines for:
- Character controllers with distinct movement modes (idle, walk, run, jump, fall, dash).
- Enemy and NPC (Non-Player Character) AI (Artificial Intelligence): patrol, investigate, chase, attack, flee.
- Interactable objects with a small set of modes: doors, levers, chests, elevators.
- Game-flow and screen state: boot, menu, loading, playing, paused, game-over.
- Ability and weapon logic where charge, fire, and cooldown are separate states.
- Any behavior where “grounded with sub-states” and “airborne with sub-states” is clearer as a hierarchy than as one flat list.
Features
- Two machine types.
ScyllaStateMachine<TContext>for a flat set of states, andScyllaHierarchicalStateMachine<TContext>for nested states. The hierarchical machine derives from the flat one, so everything the flat machine does the hierarchical machine does too. - Shared typed context. Every callback receives the machine’s
TContext, a single blackboard instance the states share. States hold their own per-state fields; cross-state data lives on the context. - Guarded transitions. A target state’s
CanEnter(context)runs before the machine commits to it. Returningfalsekeeps the current state, so a transition to “attack” can be refused when there is no target in range. - Deterministic enter/exit ordering. A transition always runs the outgoing state’s
OnExitbefore the incoming state’sOnEnter, so cleanup and setup never interleave. - Explicit tick control.
Tick(deltaTime)andFixedTick(fixedDeltaTime)forward to the current state’sOnTick/OnFixedTick. There is no implicit update loop; the caller decides when and how often the machine advances. - Allocation-free after construction. Transitions and ticks allocate nothing on the managed heap. States are ordinary reference objects created once and reused for the machine’s lifetime.
StateChangedevent. The flat machine raisesStateChanged(previous, next)after every completed transition, including the initial transition fromStart(previous isnull) and the final one fromStop(next isnull), so a HUD (Heads-Up Display) or animator can react without pollingCurrentState.- Re-entry control.
TransitionTo(state, allowReentry)rejects a transition to the already-current state by default; passallowReentry: trueto force an exit-and-re-enter of the same state (a fresh attack swing, a restarted timer). - Hierarchy through composition.
ScyllaCompositeState<TContext>is a normal state that owns a child machine. Entering it starts the child at a configured initial child state, exiting stops the child, and ticks flow down to the active child. Composites nest arbitrarily deep. - Active-chain queries.
ScyllaHierarchicalStateMachine<TContext>.GetActivePath(buffer)fills a reusable list with the current root-to-leaf chain, andIsInState(state)reports whether a state is anywhere on that chain, so “am I grounded” is one call even when the leaf is “grounded/running”. - No-op base class.
ScyllaStateBase<TContext>implements everyIScyllaStatemember as a virtual no-op withCanEnterreturningtrue, so a new state overrides only the callbacks it actually uses and stays source-compatible if the interface grows.
The machines
The family is small: two machine classes and three supporting types. The context type parameter TContext threads through all of them, so a machine, its states, and any nested child machine all share one blackboard type.
| Type | Role |
|---|---|
IScyllaState<TContext> | The state contract: the CanEnter guard plus the OnEnter, OnExit, OnTick, and OnFixedTick callbacks. Implement directly only when a state cannot use the base class. |
ScyllaStateBase<TContext> | Convenience base with virtual no-op callbacks and CanEnter returning true. The normal starting point for a state: override the callbacks the state needs and leave the rest. |
ScyllaStateMachine<TContext> | The flat FSM. Holds the current and previous state, the running flag, and the StateChanged event. Driven by Start, Stop, TransitionTo, Tick, and FixedTick. |
ScyllaCompositeState<TContext> | A state that owns a nested ChildMachine. One composite is one level of hierarchy; using a composite as a child state of another composite nests the hierarchy deeper. |
ScyllaHierarchicalStateMachine<TContext> | The HSM. Everything the flat machine has, plus GetActivePath and IsInState for reading the active root-to-leaf chain built from composite states. |
States are plain objects with no Unity dependency, so they are trivially unit-testable: construct a context, construct the states, drive the machine, and assert on CurrentState. One state instance belongs to one machine; the machine does not isolate per-state data, so sharing a single state object across two machines is not supported.
Recipes
These recipes build up from a flat three-state character machine to a nested hierarchical one. Start with the first recipe if the API is new to you; the later ones assume you have a machine and states in hand.
Build a flat character state machine
Problem. A character has a few distinct movement modes (idle, running, jumping) and the per-mode logic is tangled into one Update full of boolean flags. Each mode wants its own enter, per-frame, and exit behavior without stepping on the others.
Solution. Give each mode a state class deriving from ScyllaStateBase<TContext>, put the shared data on the context, and let the machine own the current-mode bookkeeping. States request transitions from their OnTick.
/* The shared blackboard. Every state reads and writes this one instance. */
public sealed class PlayerContext
{
public Rigidbody Body;
public float MoveInput;
public bool JumpPressed;
public bool IsGrounded;
}
/* One state per mode. Override only the callbacks the mode needs. */
public sealed class IdleState : ScyllaStateBase<PlayerContext>
{
private readonly PlayerStates _states;
public IdleState(PlayerStates states) { _states = states; }
public override void OnEnter(PlayerContext ctx)
{
/* Stop horizontal drift the moment we enter idle. */
ctx.Body.linearVelocity = new Vector3(0f, ctx.Body.linearVelocity.y, 0f);
}
public override void OnTick(PlayerContext ctx, float dt)
{
/* Request the next mode. Never call TransitionTo from OnEnter/OnExit. */
if (ctx.JumpPressed) { _states.Machine.TransitionTo(_states.Jump); }
else if (Mathf.Abs(ctx.MoveInput) > 0.01f) { _states.Machine.TransitionTo(_states.Run); }
}
}
/* Wire up the machine and its states once, at spawn. */
var ctx = new PlayerContext { Body = rb };
var machine = new ScyllaStateMachine<PlayerContext>(ctx);
var states = new PlayerStates(machine); /* holds Idle, Run, Jump instances */
machine.Start(states.Idle); /* enters Idle; returns false if Idle.CanEnter rejects */
/* Drive it from the MonoBehaviour that owns the character. */
void Update() { machine.Tick(Time.deltaTime); }
void FixedUpdate() { machine.FixedTick(Time.fixedDeltaTime); }
Reject a transition with a guard
Problem. An enemy should only enter its attack state when it actually has a target in range. Scattering that check at every call site that might trigger an attack is error-prone; the rule belongs to the attack state itself.
Solution. Override CanEnter on the target state. The machine calls it before committing, and a false return keeps the current state. TransitionTo then returns false so the caller can branch if it cares.
public sealed class AttackState : ScyllaStateBase<EnemyContext>
{
public override bool CanEnter(EnemyContext ctx)
{
/* No target, no attack. The machine stays in whatever state it was in. */
return ctx.Target != null && ctx.DistanceToTarget <= ctx.AttackRange;
}
public override void OnEnter(EnemyContext ctx) { ctx.Animator.SetTrigger("Attack"); }
}
/* From the chase state's tick: attempt the attack; fall back to chasing. */
if (!machine.TransitionTo(states.Attack))
{
/* Guard refused (target gone or out of range). Keep closing the distance. */
machine.TransitionTo(states.Chase);
}
React to state changes without polling
Problem. The animator, the HUD, and an analytics logger all need to know when the character changes state, but none of them should have to compare CurrentState against last frame’s value every Update.
Solution. Subscribe to the StateChanged event. It fires once per completed transition with the previous and next state, including the initial transition from Start (previous null) and the final one from Stop (next null).
machine.StateChanged += (previous, next) =>
{
/* next is null only when the machine has just Stopped. */
if (next != null)
{
_hud.ShowStateName(next.GetType().Name);
}
};
/* Later, tearing the character down. Stop() fires StateChanged(current, null). */
machine.Stop();
Nest states with a hierarchical machine
Problem. A character is either grounded (idle or running) or airborne (jumping or falling), and both top-level modes share transitions: taking damage or dying should work the same whether grounded or airborne. Flattening every combination produces a state list that grows multiplicatively and duplicates the shared edges.
Solution. Model the top level as ScyllaCompositeState<TContext> instances, each owning a child machine over its sub-states, and drive the whole thing with a ScyllaHierarchicalStateMachine<TContext>. Entering a composite starts its child machine at the configured initial child state; exiting stops it; ticks flow down to the active child automatically.
var ctx = new PlayerContext { Body = rb };
/* Sub-states of the grounded mode. */
var idle = new IdleState();
var run = new RunState();
/* The grounded composite owns a child machine that starts in Idle.
* Its child machine shares the same context as the parent. */
var grounded = new ScyllaCompositeState<PlayerContext>(ctx, initialChildState: idle);
/* Sub-states of the airborne mode, and its composite. */
var jump = new JumpState();
var fall = new FallState();
var airborne = new ScyllaCompositeState<PlayerContext>(ctx, initialChildState: jump);
/* The top-level hierarchical machine transitions between the two composites. */
var machine = new ScyllaHierarchicalStateMachine<PlayerContext>(ctx);
machine.Start(grounded); /* enters grounded -> child machine enters idle */
/* Switch a sub-state on the grounded composite's own child machine. */
grounded.ChildMachine.TransitionTo(run); /* now grounded/running */
/* Jump: swap the whole top level. Grounded's child machine stops; airborne's starts at jump. */
machine.TransitionTo(airborne);
/* One tick drives every level: the top machine ticks the active composite,
* which forwards the tick down to its active child. */
void Update() { machine.Tick(Time.deltaTime); }
Read the active state chain
Problem. Damage handling needs to know “is the character grounded right now”, but with a hierarchy the current top-level state might be a composite whose active leaf is “grounded/running”. Comparing CurrentState to a single state misses the nesting.
Solution. GetActivePath fills a caller-owned list with the full root-to-leaf chain, and IsInState answers membership on that chain directly, including intermediate composites.
/* Reuse one buffer across frames to stay allocation-free. */
private readonly List<IScyllaState<PlayerContext>> _path = new();
void LateUpdate()
{
machine.GetActivePath(_path); /* clears then fills, e.g. [grounded, run] */
/* The leaf is the deepest active state. */
var leaf = _path.Count > 0 ? _path[_path.Count - 1] : null;
/* Membership anywhere on the chain, composites included. */
var isGrounded = machine.IsInState(grounded);
if (isGrounded && _tookLethalHit)
{
machine.TransitionTo(dead);
}
}
API Sketch
The public surface of the state machine family. See the API reference for full signatures, remarks, and exception contracts on every member.
namespace Scylla.Core.Structures
{
/* ------------------------------------------------------------------ */
/* STATE CONTRACT AND BASE */
/* ------------------------------------------------------------------ */
public interface IScyllaState<in TContext>
{
bool CanEnter(TContext context); /* transition guard; false rejects */
void OnEnter(TContext context);
void OnExit(TContext context);
void OnTick(TContext context, float deltaTime); /* variable-rate update */
void OnFixedTick(TContext context, float fixedDeltaTime);
}
public abstract class ScyllaStateBase<TContext> : IScyllaState<TContext>
{
/* All callbacks are virtual no-ops; CanEnter returns true. Override as needed. */
public virtual bool CanEnter(TContext context);
public virtual void OnEnter(TContext context);
public virtual void OnExit(TContext context);
public virtual void OnTick(TContext context, float deltaTime);
public virtual void OnFixedTick(TContext context, float fixedDeltaTime);
}
/* ------------------------------------------------------------------ */
/* FLAT STATE MACHINE */
/* ------------------------------------------------------------------ */
public class ScyllaStateMachine<TContext>
{
public ScyllaStateMachine(TContext context);
public TContext Context { get; }
public IScyllaState<TContext> CurrentState { get; }
public IScyllaState<TContext> PreviousState { get; }
public bool IsRunning { get; }
public event Action<IScyllaState<TContext>, IScyllaState<TContext>> StateChanged;
public bool Start(IScyllaState<TContext> initialState); /* false if the guard rejects */
public void Stop();
public bool TransitionTo(IScyllaState<TContext> nextState, bool allowReentry = false);
public void Tick(float deltaTime);
public void FixedTick(float fixedDeltaTime);
}
/* ------------------------------------------------------------------ */
/* HIERARCHY */
/* ------------------------------------------------------------------ */
public class ScyllaCompositeState<TContext> : ScyllaStateBase<TContext>
{
public ScyllaCompositeState(TContext context, IScyllaState<TContext> initialChildState);
public ScyllaStateMachine<TContext> ChildMachine { get; }
public IScyllaState<TContext> InitialChildState { get; }
/* OnEnter starts ChildMachine, OnExit stops it, OnTick/OnFixedTick forward down. */
}
public class ScyllaHierarchicalStateMachine<TContext> : ScyllaStateMachine<TContext>
{
public ScyllaHierarchicalStateMachine(TContext context);
public void GetActivePath(List<IScyllaState<TContext>> buffer); /* root-to-leaf, cleared first */
public bool IsInState(IScyllaState<TContext> state);
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Lifecycle and callbacks
A state receives five callbacks over its life on a machine. The machine drives them in a fixed order; states never call each other’s callbacks directly.
| Callback | When it runs | Typical use |
|---|---|---|
CanEnter(context) | Before the machine enters the state, from Start and TransitionTo. Returning false cancels the transition. | Preconditions: has a target, has stamina, cooldown elapsed. |
OnEnter(context) | Once, when the state becomes current, after the previous state’s OnExit. | Set up: play an animation, zero a velocity, start a timer. |
OnTick(context, deltaTime) | Every Tick while the state is current. | Per-frame logic and transition requests. |
OnFixedTick(context, fixedDt) | Every FixedTick while the state is current. | Fixed-step physics and simulation. |
OnExit(context) | Once, when the state stops being current, before the next state’s OnEnter. | Tear down: clear a flag, release a lock, stop an effect. |
The machine drives transitions through three entry points. Start(initialState) moves the machine from stopped to running, consulting the initial state’s guard first; it throws InvalidOperationException if the machine is already running and ArgumentNullException on a null state. TransitionTo(nextState, allowReentry) performs a guarded switch while running; it returns false when the machine is stopped, when the guard rejects, or when a self-transition is requested without allowReentry, and it throws InvalidOperationException when called from inside an enter or exit callback. Stop() exits the current state and returns the machine to stopped; it is a safe no-op when already stopped.
The machine is not thread-safe. Drive one machine from a single thread, normally the Unity main thread. There is no synchronized wrapper and no DOTS (Data-Oriented Technology Stack) variant, because a state machine’s callbacks run arbitrary managed code and are not blittable.
Best Practices
- Put shared data on the context, per-state data on the state. The context is the one object every state sees; use it for the character, its components, and the input snapshot. Keep data that only one state cares about (a charge timer, a chosen attack) as fields on that state.
- Derive from
ScyllaStateBase, notIScyllaState. The base class gives every callback a no-op default, so a state overriding onlyOnTickstays short, and new interface callbacks in a future version do not break existing states. - Request transitions from tick callbacks only.
OnEnterandOnExitrun mid-transition; callingTransitionTofrom them throws. Signal intent by setting a field on the context or the state during enter/exit, and act on it in the nextOnTick. - Keep guards cheap and side-effect-free.
CanEntercan run on transitions that never happen. Read state and return a bool; do not start timers, play sounds, or mutate the context from a guard. - Let the guard own the precondition. A rule like “cannot attack without a target” belongs in the attack state’s
CanEnter, not duplicated at every call site that might trigger an attack. Callers just attempt the transition and branch on theboolresult. - Drive
TickandFixedTickfrom the matching Unity callbacks. ForwardUpdatetoTickandFixedUpdatetoFixedTick, so variable-rate logic and fixed-step physics land on the correct clock. Route both through the Time subsystem when the machine should respect pause and slow-motion. - Prefer a hierarchy over a combinatorial flat list. When two top-level modes each have sub-states and share transitions, model the top level as composites. It keeps the shared edges (damage, death) at one level instead of duplicating them across every leaf.
- Reuse one buffer for
GetActivePath. It clears the list before filling, so a singleList<>reused every frame stays allocation-free. Do not allocate a new list per call. - Remember composites restart their children. Re-entering a composite always begins at
InitialChildState. If a mode must resume its last sub-state, record that choice on the context and route to it in the composite’s initial child. - Test machines without Unity. States are plain objects and the machine has no Unity dependency, so a full behavior test is: build a context, build the states,
Start, feedTickcalls, and assert onCurrentStateor the active path. - Pair with the event bus for cross-system reactions. When many unrelated systems care about a state change, publish a typed event from
StateChangedthrough the Event System rather than wiring each listener directly to the machine.
Pitfalls
Related
- Collections Overview
- Graph
- Event System
- API reference:
Scylla.Core.Structures.IScyllaState`1 - API reference:
Scylla.Core.Structures.ScyllaStateBase`1 - API reference:
Scylla.Core.Structures.ScyllaStateMachine`1 - API reference:
Scylla.Core.Structures.ScyllaHierarchicalStateMachine`1 - API reference:
Scylla.Core.Structures.ScyllaCompositeState`1