Skip to content

About

State Machine Framework "Cerberus" from Atlas Reality, Inc.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Latest commit

 

History

17 Commits

Folders and files

Repository files navigation

CerberusStateMachine

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.

Table of Contents

Features

  • 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 StateController per 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

Quick Start

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 -> GameOver

Defining States

States represent the individual behaviors of your state machine. Each state class implements lifecycle methods that are called when the state is entered or exited.

Using the IState Interface

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
    }
}

Using the State Base Class

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
    }
}

Typed States with Return Values

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;
    }
}

Building a State Machine

State machines are constructed using the fluent builder. You define states, wire up events, and then call Build() to produce the state machine.

Basic Structure

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.

Builder Types

There are two builder variants depending on whether you need machine-level events:

  • StateMachineBuilder<StateIdT> - For state machines that only use state-level events
  • StateMachineBuilder<StateIdT, EventIdT> - Adds support for machine-level events (see Machine-Level Events)

Events and Transitions

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.

State-Level Events

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.

Event Context

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);
    }
})

Machine-Level Events

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().

Predicate Events

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 -> Closed

The 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 true runs. 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 null predicate throws ArgumentNullException.
  • Predicate checks allocate nothing. Registrations are copied into an array at Build() and scanned with a plain loop, so TriggerEvent stays 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 Id Types

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. TriggerEvent infers EventIdT from the static type of the value you pass. An int literal does not reach a state declared with long, 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 without Equals and GetHashCode overrides compares by reference, so register and trigger with the same instance. A struct should implement IEquatable<T> and override GetHashCode, otherwise every lookup boxes it and TriggerEvent is no longer allocation-free.
  • null is not a valid event id. AddEvent and TriggerEvent throw ArgumentNullException.

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.

Hierarchical State Machines (Sub-States)

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.

Defining Sub-States

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.

Multi-Level Nesting

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()

Triggering Events on Sub-States

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 Menu

See State Controllers for the full delivery rules.

State Handlers

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.

Defining a Handler

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();
    }
}

Registering Handlers

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();

Handler Ordering

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.

State Controllers

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.

Triggering Events

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.

How Events Are Delivered

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. TriggerEvent returns true if 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.

Deprecated: Per-State Controllers

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.

Dependency Injection

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.

Implementing IStateMachineContainer

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);
    }
}

Using a Custom Container

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.

About

State Machine Framework "Cerberus" from Atlas Reality, Inc.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages