AI System

Altruist provides an attribute-based finite state machine (FSM) for entity AI. Define behaviors as plain classes with [AIState] methods, and the framework handles ticking, state transitions, enter/exit hooks, and lifecycle management automatically.

What Altruist Handles

  • Discovers [AIBehavior] classes at startup
  • Creates FSM instances per entity automatically
  • Ticks all AI entities each engine frame (between physics and visibility)
  • Skips hibernated entities
  • Cleans up FSMs when entities are destroyed

You only define the behavior logic and a context class.

How It Works

Loading diagram...

Step 1: Define a Context

The context holds all data your AI needs — targets, timers, spawn position. It's your class, Altruist doesn't dictate the shape:

public class MonsterAIContext : IAIContext
{
    public ITypelessWorldObject Entity => MonsterEntity;
    public float TimeInState { get; set; }  // Managed by framework
    public MonsterEntity MonsterEntity { get; }

    // AI state data
    public uint? TargetVID { get; set; }
    public float TimeSinceLastAttack { get; set; }
    public float DeathTimer { get; set; }
    public float AggressiveSight { get; set; }
    public float AttackRange { get; set; } = 200f;
    public float AttackCooldown { get; set; } = 2f;
    public int MinDamage { get; set; }
    public int MaxDamage { get; set; }

    public MonsterAIContext(MonsterEntity entity)
    {
        MonsterEntity = entity;
    }
}

Note:

IAIContext requires ITypelessWorldObject Entity and float TimeInState. The framework sets TimeInState each tick (resets on transition) — you just read it. Everything else is yours.

Step 2: Define a Behavior

A behavior is a class with [AIBehavior("name")] containing [AIState] methods. Each method returns the next state name (or null to stay):

[AIBehavior("aggressive_monster")]
public class AggressiveMonsterBehavior
{
    // DI works — inject any service
    private readonly ICharacterService _characters;

    public AggressiveMonsterBehavior(ICharacterService characters)
    {
        _characters = characters;
    }

    [AIState("Idle", Initial = true)]
    public string? Idle(MonsterAIContext ctx, float dt)
    {
        // Look for nearby players
        var pos = ctx.MonsterEntity.Transform.Position;
        var player = FindNearbyPlayer(pos, ctx.AggressiveSight);

        if (player != null)
        {
            ctx.TargetVID = player.VirtualId;
            return "Chase";  // Transition to Chase state
        }

        return null;  // Stay in Idle
    }

    [AIState("Chase")]
    public string? Chase(MonsterAIContext ctx, float dt)
    {
        var target = _characters.GetPlayerByVID(ctx.TargetVID!.Value);
        if (target == null) return "Idle";

        float distance = DistanceTo(ctx.MonsterEntity, target);

        if (distance <= ctx.AttackRange)
            return "Attack";

        // Move toward target
        MoveToward(ctx.MonsterEntity, target, dt);
        return null;
    }

    [AIStateEnter("Attack")]
    public void AttackEnter(MonsterAIContext ctx)
    {
        // Reset attack timer on entering attack state
        ctx.TimeSinceLastAttack = ctx.AttackCooldown;
    }

    [AIState("Attack")]
    public string? Attack(MonsterAIContext ctx, float dt)
    {
        var target = _characters.GetPlayerByVID(ctx.TargetVID!.Value);
        if (target == null) return "Idle";

        float distance = DistanceTo(ctx.MonsterEntity, target);
        if (distance > ctx.AttackRange * 1.5f)
            return "Chase";

        ctx.TimeSinceLastAttack += dt;
        if (ctx.TimeSinceLastAttack >= ctx.AttackCooldown)
        {
            ctx.TimeSinceLastAttack = 0;
            DealDamage(ctx, target);
        }

        return null;
    }

    [AIState("Dead")]
    public string? Dead(MonsterAIContext ctx, float dt)
    {
        ctx.DeathTimer += dt;
        if (ctx.DeathTimer >= ctx.MonsterEntity.RegenTimeSeconds)
            return "Idle";  // Respawn
        return null;
    }

    [AIStateEnter("Dead")]
    public void DeadEnter(MonsterAIContext ctx)
    {
        ctx.MonsterEntity.IsDead = true;
        ctx.DeathTimer = 0;
    }

    [AIStateExit("Dead")]
    public void DeadExit(MonsterAIContext ctx)
    {
        // Respawn: reset HP, move to spawn position
        ctx.MonsterEntity.IsDead = false;
        ctx.MonsterEntity.Hp = ctx.MonsterEntity.MaxHp;
    }
}

State Method Signatures

AttributeSignatureReturns
[AIState("name")]string? Method(TContext ctx, float dt)Next state name, or null to stay
[AIStateEnter("name")]void Method(TContext ctx)
[AIStateExit("name")]void Method(TContext ctx)

Note:

Mark one state with Initial = true — this is where the FSM starts. Enter hooks fire on the initial state too.

Step 3: Make Your Entity AI-Enabled

Implement IAIBehaviorEntity on your world object:

[WorldObject("monster")]
public 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 int SpawnX { get; set; }
    public int SpawnY { get; set; }
    public int RegenTimeSeconds { get; set; }

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

Set the Context on Spawn

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

monster.AIContext = new MonsterAIContext(monster)
{
    AggressiveSight = 2000f,
    AttackRange = 200f,
    MinDamage = 20,
    MaxDamage = 40,
};

await world.SpawnDynamicObject(monster);
// AI ticking starts automatically on next engine frame

Note:

That's all the setup. The AIBehaviorService finds the entity, matches AIBehaviorName to [AIBehavior("aggressive_monster")], creates an FSM, and ticks it every frame. No registration, no manual tick loop.

State Transitions

Loading diagram...

Return the state name from your [AIState] method to transition:

return "Chase";   // Transition to Chase
return null;      // Stay in current state

The framework calls [AIStateExit] on the old state and [AIStateEnter] on the new state automatically.

Force Transition

From outside the behavior (e.g., when a combat event fires):

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

State Delay

Add a delay before a state's update method starts running. Enter/exit hooks still fire immediately:

[AIState("Talk", Delay = 2f)]  // Update skipped for 2 seconds after entering
public string? Talk(NpcAIContext ctx, float dt)
{
    // This only runs after the 2s delay
    if (ctx.TimeInState >= 5f) return "Idle";
    return null;
}

[AIStateEnter("Talk")]
public void TalkEnter(NpcAIContext ctx)
{
    // Fires immediately on transition (before delay)
    // Start animation, open dialog, etc.
}
PropertyDefaultDescription
Delay0Seconds to wait before the update method starts ticking
DelayUnitSecondsTimeUnit.Seconds or TimeUnit.Milliseconds for sub-second precision
[AIState("Stun", Delay = 500, DelayUnit = TimeUnit.Milliseconds)]  // 500ms stun
public string? Stun(MonsterAIContext ctx, float dt)
{
    return "Idle"; // Recover immediately after delay ends
}

TimeInState

The framework tracks how long the current state has been active. Use ctx.TimeInState for duration-based logic:

[AIState("Patrol")]
public string? Patrol(GuardAIContext ctx, float dt)
{
    if (ctx.TimeInState >= 10f) return "Rest";  // Patrol for 10 seconds then rest
    MoveToWaypoint(ctx, dt);
    return null;
}

Note:

TimeInState resets to zero on every state transition. The framework manages it — you just read it.

Multiple Behaviors

Define different behaviors for different entity types:

[AIBehavior("passive_npc")]
public class PassiveNpcBehavior
{
    [AIState("Idle", Initial = true)]
    public string? Idle(NpcAIContext ctx, float dt) => null; // Just stands there

    // Player interaction triggers: fsm.TransitionTo(ctx, "Talk")

    [AIState("Talk", Delay = 2f)]  // 2s delay before update starts
    public string? Talk(NpcAIContext ctx, float dt)
    {
        if (ctx.TimeInState >= 5f) return "Idle"; // Back to idle after 5s total
        return null;
    }

    [AIStateEnter("Talk")]
    public void TalkEnter(NpcAIContext ctx)
    {
        // Fires immediately — start talk animation, open dialog
    }
}

[AIBehavior("patrol_guard")]
public class PatrolGuardBehavior
{
    [AIState("Patrol", Initial = true)]
    public string? Patrol(GuardAIContext ctx, float dt)
    {
        // Walk between waypoints
        MoveToWaypoint(ctx, dt);
        if (SpotEnemy(ctx)) return "Alert";
        return null;
    }

    [AIState("Alert")]
    public string? Alert(GuardAIContext ctx, float dt)
    {
        // Chase and attack
        return null;
    }
}

Each entity references its behavior by name:

public class NpcEntity : WorldObject3D, IAIBehaviorEntity
{
    public string AIBehaviorName => "passive_npc";
    public IAIContext AIContext { get; set; } = null!;
}

public class GuardEntity : WorldObject3D, IAIBehaviorEntity
{
    public string AIBehaviorName => "patrol_guard";
    public IAIContext AIContext { get; set; } = null!;
}

Hibernation

Hibernated entities are skipped by the AI tick. When a player approaches and the visibility system wakes the entity, AI resumes from its last state. No special handling needed.