AI State Machine

Altruist's AI layer is a built-in consumer of the generic State Machine API. You write plain classes with [AIBehavior] and [AIState] methods, while the framework handles discovery, per-entity FSM instances, ticking, delay gates, and cleanup.

What Altruist Handles

  • Discovers [AIBehavior] classes at startup
  • Builds a reusable state-machine template per behavior
  • Creates one AIStateMachine per entity at runtime
  • Ticks AI entities every engine frame
  • Skips hibernated and expired entities
  • Cleans up FSMs for entities that leave the world

The AI layer is opinionated about lifecycle, not about game rules. Targeting, damage, patrol paths, and threat logic stay in your code.

Agents that are not world objects, such as bots in a match room, use the same behaviors with a machine they create and tick themselves. See AI Agents Outside the World.

How AI Relates to the Generic FSM

[AIBehavior], [AIState], [AIStateEnter], and [AIStateExit] are thin AI-specific wrappers over the generic state-machine attributes. The runtime driver is AIStateMachine, which is a specialization of StateMachine<IAIContext>.

If you already understand the generic API, AI adds three main things:

  • discovery of behavior classes
  • automatic per-entity machine creation
  • automatic ticking through AIBehaviorService

Step 1: Define a Context

IAIContext extends IStateContextCore, the generic FSM metadata surface. A context of a world entity usually also exposes the entity (Entity, as in IStateContext), although the AI system does not require it. Add your own gameplay fields next to the bookkeeping members:

public sealed class MonsterAIContext : IAIContext
{
    public ITypelessWorldObject Entity { get; set; } = null!;

    // Managed by the framework
    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; }

    // Your AI data
    public MonsterEntity Monster { get; set; } = null!;
    public PlayerEntity? Target { get; set; }
    public float SpawnX { get; set; }
    public float SpawnY { get; set; }
    public float SpawnZ { get; set; }
    public float AttackRange { get; set; } = 200f;
    public float AttackCooldown { get; set; } = 1.5f;
}

Note:

The framework writes TimeInState, StateDuration, CurrentStateTag, ActiveWindows, CurrentStateData, CurrentStateMotion, and PreviousProgress. Your behavior code reads them.

Step 2: Define a Behavior

[AIBehavior("aggressive_monster")]
public sealed class AggressiveMonsterBehavior
{
    [AIState("Idle", Initial = true)]
    public string? Idle(MonsterAIContext ctx, float dt)
    {
        if (ctx.Target != null && !ctx.Target.IsDead)
            return "Chase";

        return null;
    }

    [AIState("Chase")]
    public string? Chase(MonsterAIContext ctx, float dt)
    {
        if (ctx.Target == null || ctx.Target.IsDead)
            return "Return";

        float distance = DistanceTo(ctx.Monster, ctx.Target);
        if (distance <= ctx.AttackRange)
            return "Attack";

        MoveToward(ctx.Monster, ctx.Target, dt);
        return null;
    }

    [AIState("Attack", Delay = 1.5f)]
    public string? Attack(MonsterAIContext ctx, float dt)
    {
        if (ctx.Target == null || ctx.Target.IsDead)
            return "Return";

        if (DistanceTo(ctx.Monster, ctx.Target) > ctx.AttackRange * 1.5f)
            return "Chase";

        DealDamage(ctx.Monster, ctx.Target);
        return "Chase";
    }

    [AIState("Return")]
    public string? Return(MonsterAIContext ctx, float dt)
    {
        if (MoveBackToSpawn(ctx, dt))
            return "Idle";

        return null;
    }

    [AIStateEnter("Return")]
    public void ReturnEnter(MonsterAIContext ctx)
    {
        ctx.Target = null;
    }
}

State Method Signatures

AttributeSignatureMeaning
[AIState("name")]string? Method(TContext ctx, float dt)Return next state name, or null to stay
[AIStateEnter("name")]void Method(TContext ctx)Runs once when entering the state
[AIStateExit("name")]void Method(TContext ctx)Runs once when leaving the state

Step 3: Make the Entity AI-Enabled

[WorldObject("monster")]
public sealed class MonsterEntity : WorldObject3D, IAIBehaviorEntity
{
    public string AIBehaviorName => "aggressive_monster";
    public IAIContext AIContext { get; set; } = null!;

    public int Hp { get; set; }
    public int MaxHp { get; set; }
    public bool IsDead { get; set; }

    public MonsterEntity(Transform3D transform) : base(transform) { }
}

Create the context when the entity is spawned:

var monster = new MonsterEntity(transform)
{
    Hp = 1000,
    MaxHp = 1000,
};

monster.AIContext = new MonsterAIContext
{
    Entity = monster,
    Monster = monster,
    SpawnX = monster.Transform.Position.X,
    SpawnY = monster.Transform.Position.Y,
    SpawnZ = monster.Transform.Position.Z,
    AttackRange = 200f,
    AttackCooldown = 1.5f,
};

await world.SpawnDynamicObject(monster);

On the next engine frame, AIBehaviorService matches AIBehaviorName to [AIBehavior("aggressive_monster")], creates an AIStateMachine, and starts ticking it automatically.

Delay and TimeInState

Delay suppresses the state's update method until enough time has passed, but enter/exit hooks still run immediately:

[AIState("Stun", Delay = 500, DelayUnit = TimeUnit.Milliseconds)]
public string? Stun(MonsterAIContext ctx, float dt)
{
    return "Idle";
}

[AIStateEnter("Stun")]
public void StunEnter(MonsterAIContext ctx)
{
    PlayStunAnimation(ctx.Monster);
}

TimeInState is updated every tick and resets on transition:

[AIState("Patrol")]
public string? Patrol(GuardAIContext ctx, float dt)
{
    if (ctx.TimeInState >= 10f)
        return "Rest";

    MoveToWaypoint(ctx, dt);
    return null;
}

Forcing a Transition

When another system needs to drive AI directly, fetch the machine and transition it:

var fsm = _aiBehaviorService.GetStateMachine(monster.InstanceId);
fsm?.TransitionTo(monster.AIContext, "Dead");

That path is useful for combat, scripted events, or admin tools.

Hibernation

Hibernated entities are skipped by the AI tick. When the Visibility & Virtual IDs system wakes the entity again, the AI machine resumes from its current state.

This means AI, visibility, and synchronization work together like this:

  • visibility decides who can currently observe the entity
  • hibernation sleeps entities with zero observers
  • AI stops ticking while the entity sleeps
  • sync resumes when the entity is active and visible again

AI Agents Outside the World

Not every agent is a world object. A bot that drives a seat in a match room, an opponent in a headless simulation or an agent in a test has no entity in the world organizer, so AIBehaviorService never sees it. Such an agent owns its state machine and ticks it itself, with the same [AIBehavior] classes.

IStateContextCore and IStateContext

The state machine works on IStateContextCore: the bookkeeping members (TimeInState, StateDuration, CurrentStateTag, ActiveWindows, CurrentStateData, CurrentStateMotion, PreviousProgress) plus the Progress and window helpers. IStateContext derives from it and adds ITypelessWorldObject Entity for contexts that belong to a world object.

IAIContext derives from IStateContextCore, so an AI context needs no entity. Contexts that already implement Entity, such as MonsterAIContext above, keep working unchanged.

The AIContext Base Class

AIContext is an abstract base class that implements IAIContext with the bookkeeping members, so your context only declares the agent's own state:

public sealed class StrikerContext : AIContext
{
    // Inputs, set by the owner before each tick
    public IPhysxBody2D Car { get; init; } = null!;
    public IPhysxBody2D Ball { get; init; } = null!;

    // Outputs, read by the owner after each tick
    public float Throttle { get; set; }
    public float Steer { get; set; }
}

Creating the Machine

AIBehaviorDiscovery.CreateStateMachine<TBehavior>() returns a new AIStateMachine for a behavior type, without an assembly scan:

  • The behavior is built once per type: the first call creates one instance with its parameterless constructor and builds the template. Every later call returns a new machine over that same template. If the startup scan already built a template from the same type, that template is reused.
  • The template is registered under the [AIBehavior] name, so CreateStateMachine("name") and HasBehavior("name") find it too. If a different type already holds that name, the name keeps its first registration, and CreateStateMachine<TBehavior>() still runs TBehavior's own handlers.
  • The template registry is thread-safe, so bots in rooms that step on different threads can create machines at the same time.
  • A type without [AIBehavior] throws InvalidOperationException.

Because one behavior instance is shared by every machine of that behavior, keep all per-agent state in the context, not in fields of the behavior class. Each machine instance belongs to one agent and must not be updated from two threads at once.

Example: a Bot That Ticks Its Own Machine

[AIBehavior("striker")]
public sealed class StrikerBehavior
{
    [AIState("Chase", Initial = true)]
    public string? Chase(StrikerContext ctx, float dt)
    {
        // The car faces +X in its own frame.
        var ball = ctx.Car.GetLocalPoint(ctx.Ball.Position);
        if (ball.X < 0f)
            return "TurnAround";

        ctx.Throttle = 1f;
        ctx.Steer = Math.Clamp(ball.Y, -1f, 1f);
        return null;
    }

    [AIState("TurnAround")]
    public string? TurnAround(StrikerContext ctx, float dt)
    {
        ctx.Throttle = 0.5f;
        ctx.Steer = 1f;
        return ctx.TimeInState >= 0.75f ? "Chase" : null;
    }

    [AIStateEnter("TurnAround")]
    public void EnterTurnAround(StrikerContext ctx) => ctx.Throttle = 0f;
}

public sealed class StrikerBot
{
    private readonly StrikerContext _ctx;
    private readonly AIStateMachine _fsm = AIBehaviorDiscovery.CreateStateMachine<StrikerBehavior>();

    public StrikerBot(IPhysxBody2D car, IPhysxBody2D ball)
    {
        _ctx = new StrikerContext { Car = car, Ball = ball };
        _fsm.Initialize(_ctx);              // enters the initial state once
    }

    public string State => _fsm.CurrentStateName;

    // Called by the room once per fixed step, before the physics step.
    public (float Throttle, float Steer) Think(float dt)
    {
        _fsm.Update(_ctx, dt);
        return (_ctx.Throttle, _ctx.Steer);
    }
}

Call Initialize once before the first Update; it runs the initial state's enter hook and applies its metadata. Update returns true when the tick caused a transition, and TransitionTo(ctx, "State") and Reset(ctx) work as for world entities.

Note:

Ticked with the simulation's fixed step and reading only simulation state, a bot is as deterministic as the simulation itself: replays and server re-simulations make the same decisions. Avoid wall-clock time and shared random generators inside behaviors. See the 2D Simulation API for a standalone world to run such bots in.