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
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
| Attribute | Signature | Returns |
|---|---|---|
[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
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.
}
| Property | Default | Description |
|---|---|---|
Delay | 0 | Seconds to wait before the update method starts ticking |
DelayUnit | Seconds | TimeUnit.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.