A fluent, type-safe state machine framework for .NET. Cerberus provides a builder API for defining states, events, and transitions with full support for hierarchical sub-states, custom lifecycle handlers, and dependency injection. Targets .NET Standard 2.0.
- Features
- Quick Start
- Defining States
- Building a State Machine
- Events and Transitions
- Hierarchical State Machines (Sub-States)
- State Handlers
- State Controllers
- Dependency Injection
- Fluent Builder API - Define your state machine with a clean, chainable syntax
- Type-Safe - States, events, and transitions are all strongly typed through generics. State ids are enums; event ids can be enums or any other type
- Event-Driven Transitions - State changes are triggered through events with rich context
- Predicate Events - Match an event on a predicate over its value instead of equality, so one data-carrying event type can route to different handlers by its fields
- Unified Event Triggering - A single
StateControllerper state machine triggers an event of any type and delivers it to every active state, innermost first, without allocating - Hierarchical Sub-States - Nest state machines within states to any depth
- State Handlers - Observe state lifecycle events with custom handlers
- Dependency Injection - Plug in your own IoC container for state and handler resolution
using Cerberus;
using Cerberus.Builder;
// 1. Define your state and event enums
public enum GameState { Idle, Playing, GameOver }
public enum GameEvent { Start, Lose }
// 2. Define your state classes
public class IdleState : State
{
public override void OnEnter() => Console.WriteLine("Waiting to start...");
public override void OnExit() => Console.WriteLine("Let's go!");
}
public class PlayingState : State
{
public override void OnEnter() => Console.WriteLine("Game started!");
}
public class GameOverState : State
{
public override void OnEnter() => Console.WriteLine("Game over!");
}
// 3. Build the state machine
IStateMachine<GameState> stateMachine = new StateMachineBuilder<GameState, GameEvent>()
.State<IdleState, GameEvent>(GameState.Idle)
.AddEvent(GameEvent.Start, e => e.ChangeState(GameState.Playing))
.End()
.State<PlayingState, GameEvent>(GameState.Playing)
.AddEvent(GameEvent.Lose, e => e.ChangeState(GameState.GameOver))
.End()
.State<GameOverState, GameEvent>(GameState.GameOver)
.AddEvent(GameEvent.Start, e => e.ChangeState(GameState.Playing))
.End()
.Build();
// 4. Start the state machine (enters the first defined state)
stateMachine.Start();
// 5. Trigger events through the state machine's controller, which delivers them to the active state
stateMachine.StateController.TriggerEvent(GameEvent.Start); // Transitions from Idle -> Playing
stateMachine.StateController.TriggerEvent(GameEvent.Lose); // Transitions from Playing -> GameOverStates represent the individual behaviors of your state machine. Each state class implements lifecycle methods that are called when the state is entered or exited.
Implement IState directly for full control:
public class MyState : IState
{
public void OnEnter()
{
// Called when this state becomes active
}
public void OnExit()
{
// Called when leaving this state
}
}The State abstract class provides a convenient base with virtual methods, so you only need to override what you need:
public class IdleState : State
{
public override void OnEnter()
{
// Only override what you need
}
}For states that need to return data from their lifecycle methods, use the generic base classes:
// Same return type for both OnEnter and OnExit
public class LoadingState : State<float>
{
public override float OnEnter()
{
return 0f; // initial progress
}
public override float OnExit()
{
return 1f; // completed
}
}
// Different return types for OnEnter and OnExit
public class ProcessingState : State<string, int>
{
public override string OnEnter()
{
return "started";
}
public override int OnExit()
{
return 42;
}
}State machines are constructed using the fluent builder. You define states, wire up events, and then call Build() to produce the state machine.
IStateMachine<MyState> stateMachine = new StateMachineBuilder<MyState, MyEvent>()
.State<SomeState, MyEvent>(MyState.First)
// ... configure events ...
.End()
.State<AnotherState, MyEvent>(MyState.Second)
// ... configure events ...
.End()
.Build();
stateMachine.Start();The first state added to the builder becomes the default state -- it is entered automatically when Start() is called.
There are two builder variants depending on whether you need machine-level events:
StateMachineBuilder<StateIdT>- For state machines that only use state-level eventsStateMachineBuilder<StateIdT, EventIdT>- Adds support for machine-level events (see Machine-Level Events)
State transitions in Cerberus are driven by events. You register event handlers that receive a context object, and call ChangeState() within them to trigger transitions.
Register events on individual states using AddEvent(). The handler receives an IStateEvent<StateT, StateIdT> context:
new StateMachineBuilder<CharacterState, CharacterEvent>()
.State<IdleState, CharacterEvent>(CharacterState.Idle)
.AddEvent(CharacterEvent.Move, e =>
{
e.ChangeState(CharacterState.Running);
})
.AddEvent(CharacterEvent.Jump, e =>
{
e.ChangeState(CharacterState.Jumping);
})
.End()
// ...
.Build();State-level events are only handled when that specific state is active.
The IStateEvent<StateT, StateIdT> passed to your event handler provides:
| Property / Method | Description |
|---|---|
StateInstance |
The current state object instance |
PreviousStateId |
The state that was active before this one |
ChangeState(stateId) |
Triggers a transition to another state |
This lets you write conditional transitions, access state data, or inspect where the state machine came from:
.AddEvent(CharacterEvent.Land, e =>
{
if (e.PreviousStateId == CharacterState.Jumping)
{
e.ChangeState(CharacterState.Landing);
}
else
{
e.ChangeState(CharacterState.Idle);
}
})Use StateMachineBuilder<StateIdT, EventIdT> to register events at the machine level. These are handled regardless of the current state:
new StateMachineBuilder<GameState, GameEvent>()
.State<PlayingState, GameEvent>(GameState.Playing)
// ... state events ...
.End()
.State<PausedState, GameEvent>(GameState.Paused)
.End()
.AddEvent(GameEvent.Reset, e =>
{
// This can be triggered from any state
e.ChangeState(GameState.Playing);
})
.Build();Machine-level event handlers receive a StateMachineEvent<StateIdT> which provides ChangeState().
AddEvent normally matches a triggered event id against the registered value by equality. When the event id type carries data, you can instead register the handler against a predicate and match on the value's contents. The predicate overload is available at the state, sub-state and machine level:
public enum DoorState { Closed, Open }
public readonly struct SwitchInput : IEquatable<SwitchInput>
{
public bool IsOn { get; }
public int Strength { get; }
public SwitchInput(bool isOn, int strength)
{
IsOn = isOn;
Strength = strength;
}
public bool Equals(SwitchInput other) => IsOn == other.IsOn && Strength == other.Strength;
public override bool Equals(object obj) => obj is SwitchInput other && Equals(other);
public override int GetHashCode() => (IsOn ? 1 : 0) * 31 + Strength;
}
IStateMachine<DoorState> stateMachine = new StateMachineBuilder<DoorState, SwitchInput>()
.State<ClosedState, SwitchInput>(DoorState.Closed)
// Two separate events on the same event id type, split by a field of the value
.AddEvent(input => input.IsOn, e =>
{
Console.WriteLine($"Opening with strength {e.EventId.Strength}");
e.ChangeState(DoorState.Open);
})
.AddEvent(input => !input.IsOn, e => Console.WriteLine("Already closed"))
.End()
.State<OpenState, SwitchInput>(DoorState.Open)
.AddEvent(input => !input.IsOn, e => e.ChangeState(DoorState.Closed))
.End()
.Build();
stateMachine.Start();
stateMachine.StateController.TriggerEvent(new SwitchInput(isOn: true, strength: 3)); // Closed -> Open
stateMachine.StateController.TriggerEvent(new SwitchInput(isOn: false, strength: 0)); // Open -> ClosedThe handler receives an IStateEvent<StateT, StateIdT, EventIdT> (or a StateMachineEvent<StateIdT, EventIdT> at the machine level). It has everything the key-based context has plus EventId, the value that satisfied the predicate.
Predicates work with any event id type, including enums and strings:
.AddEvent(id => id == GameEvent.Start || id == GameEvent.Restart, e => e.ChangeState(GameState.Playing))
.AddEvent(name => name.StartsWith("debug:"), e => Log(e.EventId))How a state picks the handler to run when an event is triggered:
- A key-based event wins. If the triggered value is equal to a registered key, that handler runs and no predicate is checked, regardless of the order they were registered in.
- Otherwise the first matching predicate wins. Predicates are checked in registration order and the first one that returns
trueruns. Later predicates are not evaluated. - At most one handler runs per state. Overlapping predicates are not an error, the earlier registration simply takes precedence. Each active state in the hierarchy still makes its own choice, so a sub-state and its parent can both handle the same trigger (see How Events Are Delivered).
- Predicates are not checked for duplicates. Unlike key-based events, registering the same or an equivalent predicate twice is allowed. A
nullpredicate throwsArgumentNullException. - Predicate checks allocate nothing. Registrations are copied into an array at
Build()and scanned with a plain loop, soTriggerEventstays allocation-free when nothing matches. As with key-based events, the only allocation is the context object for the handler that runs.
One consequence of the overload: a bare null literal as the first argument to AddEvent no longer compiles for reference event id types, because it could mean either a null key or a null predicate. Both throw ArgumentNullException at runtime, so this only affects code that was already broken. Cast the literal if you need it to compile, for example (string)null.
Event ids are usually enums, but any type works: string, int, a struct, a class, a record, or an interface. The event id type is whatever you pass as EventIdT to State<StateT, EventIdT>() or StateMachineBuilder<StateIdT, EventIdT>. State ids still have to be enums.
public enum GameState { Idle, Playing }
IStateMachine<GameState> stateMachine = new StateMachineBuilder<GameState, string>()
.AddEvent("reset", e => e.ChangeState(GameState.Idle))
.State<IdleState, string>(GameState.Idle)
.AddEvent("start", e => e.ChangeState(GameState.Playing))
.End()
.State<PlayingState, string>(GameState.Playing)
.End()
.Build();
stateMachine.Start();
stateMachine.StateController.TriggerEvent("start");
stateMachine.StateController.TriggerEvent("reset");A few rules follow from how events are matched to states:
- The type argument must be exactly the type the state was declared with.
TriggerEventinfersEventIdTfrom the static type of the value you pass. Anintliteral does not reach a state declared withlong, a concrete instance does not reach a state declared with its interface or base class, and an enum value does not reach a state declared with the nullable enum. When in doubt, pass the type argument explicitly:TriggerEvent<IMyEvent>(myEvent). - Equality decides which handler runs, unless the handler was registered with a predicate (see Predicate Events). Lookups use
EqualityComparer<EventIdT>.Default. Strings and records compare by value. A class withoutEqualsandGetHashCodeoverrides compares by reference, so register and trigger with the same instance. Astructshould implementIEquatable<T>and overrideGetHashCode, otherwise every lookup boxes it andTriggerEventis no longer allocation-free. nullis not a valid event id.AddEventandTriggerEventthrowArgumentNullException.
If you implement IStateController or IStateControllerProvider yourself instead of using the ones the state machine provides, remove any where EventIdT : Enum constraint from your TriggerEvent / GetStateController. Implementations must match the interface, which no longer has that constraint.
States can contain their own nested state machines. This is useful for modeling complex behaviors where a high-level state has its own internal states and transitions.
Use the three-type-parameter overload .State<StateT, EventIdT, SubStateIdT>(stateId) to declare a state with sub-states:
public enum AppState { Menu, InGame }
public enum AppEvent { Play, Quit }
public enum InGameState { Exploring, Fighting, Paused }
public enum InGameEvent { EncounterEnemy, Win, Pause, Resume }
new StateMachineBuilder<AppState, AppEvent>()
.State<MenuState, AppEvent>(AppState.Menu)
.AddEvent(AppEvent.Play, e => e.ChangeState(AppState.InGame))
.End()
.State<InGameScreenState, AppEvent, InGameState>(AppState.InGame)
.AddEvent(AppEvent.Quit, e => e.ChangeState(AppState.Menu))
// Define sub-states within the parent
.State<ExploringState, InGameEvent>(InGameState.Exploring)
.AddEvent(InGameEvent.EncounterEnemy, e => e.ChangeState(InGameState.Fighting))
.AddEvent(InGameEvent.Pause, e => e.ChangeState(InGameState.Paused))
.End()
.State<FightingState, InGameEvent>(InGameState.Fighting)
.AddEvent(InGameEvent.Win, e => e.ChangeState(InGameState.Exploring))
.End()
.State<PausedState, InGameEvent>(InGameState.Paused)
.AddEvent(InGameEvent.Resume, e => e.ChangeState(InGameState.Exploring))
.End()
.End()
.Build();When a parent state is entered, its first defined sub-state is automatically entered. In the example above, entering AppState.InGame will automatically enter InGameState.Exploring.
Sub-states can transition between each other without exiting the parent state. Exiting the parent state automatically cleans up the active sub-state.
Sub-states can have their own sub-states, allowing arbitrarily deep hierarchies:
.State<ParentState, ParentEvent, ChildState>(ParentState.Main)
.State<ChildA, ChildEvent, GrandchildState>(ChildState.A)
.State<GrandchildX, GrandchildEvent>(GrandchildState.X)
.End()
.End()
.End()You do not need to know which sub-state is active to trigger an event on it. The state machine's StateController offers the event to the innermost active sub-state first, then to its parent, then to machine-level events:
// With AppState.InGame active and InGameState.Exploring as its active sub-state:
stateMachine.StateController.TriggerEvent(InGameEvent.Pause); // Handled by Exploring, transitions to Paused
stateMachine.StateController.TriggerEvent(AppEvent.Quit); // Skips the sub-state (different event type), handled by InGame, transitions to MenuSee State Controllers for the full delivery rules.
State handlers let you observe state lifecycle transitions. They are called whenever a state is entered or exited, and are useful for cross-cutting concerns like logging, analytics, or resource management.
Implement the IStateHandler<StateT> interface:
public class LoggingHandler : IStateHandler<IState>
{
public void OnEnterState(IState stateInstance)
{
Console.WriteLine($"Entered: {stateInstance.GetType().Name}");
}
public void OnExitState(IState stateInstance)
{
Console.WriteLine($"Exited: {stateInstance.GetType().Name}");
}
}The generic parameter StateT controls which states this handler applies to. Using IState applies it to all states. You can use a more specific type to target only certain states:
// Only handles states that implement IMovementState
public class MovementHandler : IStateHandler<IMovementState>
{
public void OnEnterState(IMovementState stateInstance)
{
stateInstance.StartMovement();
}
public void OnExitState(IMovementState stateInstance)
{
stateInstance.StopMovement();
}
}Register handlers on the builder with AddStateHandler<StateT, HandlerT>():
new StateMachineBuilder<MyState>()
.AddStateHandler<IState, LoggingHandler>()
.AddStateHandler<IMovementState, MovementHandler>()
.State<IdleState, MyEvent>(MyState.Idle)
.End()
// ...
.Build();Handlers wrap the state's own lifecycle methods. On enter, every applicable handler's OnEnterState() runs and then the state's OnEnter() runs last. On exit, the state's OnExit() runs first and then OnExitState() runs in the reverse of the enter order:
enter: handler 1 → handler 2 → ... → state.OnEnter()
exit: state.OnExit() → ... → handler 2 → handler 1
The wrap holds whether a handler targets IState or a narrower type. Handlers enter in the order you call AddStateHandler, regardless of which StateT each one targets, and exit in the reverse of that order. In the registration example above, LoggingHandler therefore enters before MovementHandler on every movement state.
If a handler's OnEnterState() changes state, the remaining handlers and the state's OnEnter() are skipped, and only the handlers that already entered receive OnExitState(). For a state with sub-states, its default sub-state is not entered either. This holds even when the new state transitions straight back: the interrupted enter is abandoned and the original state is entered afresh from its first handler, so nothing enters or exits twice.
If a handler's OnExitState() (or the state's OnExit()) changes state instead, the handlers that have not exited yet exit inside that call, the new state is entered, and the transition that was already in progress is abandoned: its target is never entered. Requesting the exiting state itself is ignored and the original transition continues. A sub-state's exit can redirect to a sibling sub-state or to another top-level state in the same way, but a sub-state change requested while its parent state is exiting is ignored.
After building and starting a state machine, you interact with it at runtime through its state controller. Every IStateMachine<StateIdT> exposes a single StateController that is not tied to a particular state or event type, so there is nothing to look up: you trigger an event and the state machine delivers it to whichever states are currently active.
Call TriggerEvent() with any event id value. It returns true if the event was handled:
IStateMachine<GameState> stateMachine = /* ... build and start ... */;
bool handled = stateMachine.StateController.TriggerEvent(GameEvent.Start);Only states that are currently active handle the event. Triggering an event that no active state has registered returns false and does nothing.
The event is offered to every state that is currently active, starting at the innermost active sub-state and working outward to the top-level state, and finally to any machine-level events:
IStateMachine<AppState> stateMachine = /* ... build and start ... */;
// Offered to the active sub-state first, then its parent, then machine-level events
bool handled = stateMachine.StateController.TriggerEvent(InGameEvent.Pause);The rules it follows:
- At each level the event is only offered to a state whose event id type matches the type argument of the call, which is inferred from the value passed in. A state that uses a different event id type is skipped. See Event Id Types.
- Every matching level is offered the event, even when an inner level has already handled it.
TriggerEventreturnstrueif any level handled it. - Within one level at most one handler runs: the key-based handler for the triggered value if there is one, otherwise the first registered predicate that matches. See Predicate Events.
- The set of active states is captured before any handler runs. If a handler causes a transition, states entered by that transition are not offered the same event, and states exited by it no longer handle it.
- The controller itself allocates nothing per call, and neither do key lookups or predicate checks. The only allocations are the event context objects passed to handlers that actually run.
Earlier versions triggered events by looking up a controller for one specific state through IStateMachine<StateIdT>.StateControllerProvider and the IStateController<EventIdT> / IStateController<EventIdT, SubStateIdT> interfaces, with BindInfo and StateControllerBindInfo<StateIdT> describing them. That API still works but is marked [Obsolete] and raises compiler warning CS0618. Reading a parent state's current sub-state through CurrentSubState is currently only possible through it. Its full documentation and a migration guide are in docs/DEPRECATED.md.
By default, Cerberus creates state and handler instances using Activator.CreateInstance. To integrate with your own IoC container, implement IStateMachineContainer and pass it to the builder.
using Cerberus.IoC;
public class MyContainer : IStateMachineContainer
{
private readonly IServiceProvider _serviceProvider;
public MyContainer(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public T Resolve<T>()
{
return (T)_serviceProvider.GetService(typeof(T));
}
public object Resolve(Type type)
{
return _serviceProvider.GetService(type);
}
}Pass your container to the builder constructor:
var container = new MyContainer(serviceProvider);
var stateMachine = new StateMachineBuilder<GameState, GameEvent>(container)
.State<IdleState, GameEvent>(GameState.Idle)
.End()
.Build();All state classes and state handlers will be resolved through your container, allowing constructor injection and lifetime management.