State Machine

Altruist ships a generic finite state machine core for gameplay systems that need stateful behavior without hand-written switch statements. The built-in AI State Machine uses this core directly, and the same primitives also fit combo systems and quest progression.

Core Types

The generic FSM surface is made of four main pieces:

  • StateMachineBuilder<TContext> builds an immutable machine definition
  • StateMachine<TContext> runs one machine instance for one context
  • IStateContextCore exposes runtime progress and metadata (IStateContext adds the world entity)
  • attributes declare handlers: [StateBehavior], [State], [StateEnter], [StateExit], [StateWindow]

The generic layer does not auto-discover behaviors or tick them by itself. Consumer code decides when to build definitions, create instances, and call Update(...).

The Context Contract

public interface IStateContextCore
{
    float TimeInState { get; set; }
    float StateDuration { get; set; }
    string CurrentStateTag { get; set; }
    IReadOnlyDictionary<string, StateWindow>? ActiveWindows { get; set; }
    object? CurrentStateData { get; set; }
    StateMotionProfile? CurrentStateMotion { get; set; }
    float PreviousProgress { get; set; }

    float Progress => StateDuration > 0f ? TimeInState / StateDuration : 0f;
}

public interface IStateContext : IStateContextCore
{
    ITypelessWorldObject Entity { get; }
}

StateMachine<TContext> and StateMachineBuilder<TContext> only require TContext : class, IStateContextCore, so a machine does not need a world entity: a bot in a match room, a headless simulation, a quest or a UI flow can be driven by implementing IStateContextCore alone. IStateContext adds the Entity back-reference for contexts that belong to a world object, and every existing IStateContext implementation keeps working. See AI agents outside the world for a bot that owns and ticks its own machine.

The runtime writes those fields as the machine advances. Your handlers usually read:

  • TimeInState for raw time
  • Progress for normalized 0..1 progress
  • CurrentStateTag when one method backs multiple states
  • InWindow(...), WindowEntered(...), WindowExited(...) for authored timing windows

Minimal Generic Example

public sealed class DoorStateContext : IStateContext
{
    public ITypelessWorldObject Entity { get; set; } = null!;

    public float TimeInState { get; set; }
    public float StateDuration { get; set; }
    public string CurrentStateTag { get; set; } = "";
    public IReadOnlyDictionary<string, StateWindow>? ActiveWindows { get; set; }
    public object? CurrentStateData { get; set; }
    public StateMotionProfile? CurrentStateMotion { get; set; }
    public float PreviousProgress { get; set; }

    public bool OpenRequested { get; set; }
    public bool CloseRequested { get; set; }
}

[StateBehavior("door")]
public sealed class DoorBehavior
{
    [State("Closed", Initial = true)]
    public string? Closed(DoorStateContext ctx, float dt)
        => ctx.OpenRequested ? "Opening" : null;

    [State("Opening")]
    public string? Opening(DoorStateContext ctx, float dt)
        => ctx.Progress >= 1f ? "Open" : null;

    [State("Open")]
    public string? Open(DoorStateContext ctx, float dt)
        => ctx.CloseRequested ? "Closing" : null;

    [State("Closing")]
    public string? Closing(DoorStateContext ctx, float dt)
        => ctx.Progress >= 1f ? "Closed" : null;
}
var def = new StateMachineBuilder<DoorStateContext>()
    .RegisterHandlers<DoorBehavior>()
    .ConfigureState("Opening").Duration(0.75f)
    .ConfigureState("Closing").Duration(0.75f)
    .Build();

var fsm = new StateMachine<DoorStateContext>(def);
fsm.Initialize(context);
fsm.Update(context, dt);

State Lifecycle

Update Methods

[State("Attack", Initial = true, Delay = 0.2f)]
public string? Attack(MyContext ctx, float dt)
{
    return null;
}
  • return another state name to transition
  • return null to stay
  • Delay suppresses the update method until enough time has passed

Enter and Exit Hooks

[StateEnter("Attack")]
public void AttackEnter(MyContext ctx)
{
    StartAnimation(ctx);
}

[StateExit("Attack")]
public void AttackExit(MyContext ctx)
{
    ClearAttackFlags(ctx);
}

Enter hooks fire immediately on transition, even if the state has a delay.

Per-State Metadata

StateMachineBuilder<TContext> lets you attach metadata to states without baking everything into attributes.

var def = new StateMachineBuilder<MyContext>()
    .RegisterHandlers<MyBehavior>()
    .ConfigureState("Attack")
        .Duration(0.55f)
        .Tag("light")
        .Window("damage", 0.35f, 0.55f)
        .Window("input", 0.60f, 1.00f)
        .Data(new AttackStepData { StaminaCost = 10 })
        .Motion(new StateMotionProfile
        {
            MovementThrottle = 0.25f,
            MotionScale = 1f,
        })
    .Build();

That metadata becomes available at runtime through:

  • CurrentStateTag
  • ActiveWindows
  • CurrentStateData
  • CurrentStateMotion

There are also helpers such as:

  • context.GetCurrentStateData<TData>()
  • context.GetCurrentMovementThrottle()
  • context.EvaluateCurrentMotion()

Time Accounting and Transitions

Update(context, dt) is two halves, callable separately:

  • Advance(context, dt) records the previous progress, then TimeInState += dt (float32, written to the context too).
  • RunState(context, dt) runs the current state's handler (unless its delay has not elapsed) without advancing time, and transitions to the state it returns.

A simulation that advances its phase clock at the start of a tick and evaluates the phase at the end, with work in between that reads the clock, calls Advance first and RunState last; Update is exactly Advance then RunState. A state without a handler does nothing (time does not advance).

Transitions: a handler returning null, the current state's own name or an unknown name stays. A transition runs the old state's exit hook, sets the new state's metadata, the builder's OnStateEnter hook, the new state's enter hook, and resets the time in state to 0. TransitionTo(context, name) forces a transition, and transitioning to the current state re-enters it (exit, enter, time reset): the "restart this phase" of a kickoff.

var b = new StateMachineBuilder<MatchContext>();
b.ConfigureState("Countdown").AsInitial().Handler((m, _) => m.TimeInState >= 3f ? "Playing" : null);
b.ConfigureState("Playing").Handler((m, dt) => m.GoalScored ? "Goal" : null);
b.ConfigureState("Goal").Handler((m, _) => m.TimeInState >= 1.5f ? "Countdown" : null);
var phases = new StateMachine<MatchContext>(b.Build());
phases.Initialize(match);

// Each tick:
phases.Advance(match, dt);
// ... the tick's work ...
phases.RunState(match, dt);

See Simulation Building Blocks for the rest of a deterministic tick.

TypeScript Twin

@altruist/sim2d/flow mirrors the machine for game clients that run the same simulation (prediction, browser bots): StateMachine, StateMachineBuilder, AIStateMachine, AIContext, and the context helpers in StateContext (progress, inWindow, windowEntered, windowExited). The semantics are the same as above, operation for operation. There are no attributes: handlers are registered with configureState(name).handler(...). Motion profiles are not mirrored.

import { AIContext, AIStateMachine, StateMachineBuilder, StateContext } from '@altruist/sim2d/flow';

class BotContext extends AIContext {
  role = 'kickoff';
}

const b = new StateMachineBuilder<BotContext>();
b.configureState('kickoff').asInitial().handler((ctx, dt) => kickoff(ctx));
b.configureState('attack').handler((ctx, dt) => strike(ctx));
b.configureState('defend').duration(0.5).window('commit', 0.2, 0.4).handler((ctx) => {
  if (StateContext.windowEntered(ctx, 'commit')) commit(ctx);
  return null;
});
const brain = new AIStateMachine(b.build());
brain.initialize(ctx);
brain.transitionTo(ctx, 'attack');   // the role chosen by a UtilitySelector
brain.update(ctx, dt);