State Machine Entities
Problem
You want entities to have distinct behavioral states (idle, patrol, attack, flee) with clean transitions between them.
Solution
Components
public enum AIStateType
{
Idle,
Patrol,
Chase,
Attack,
Flee,
Dead
}
[Component]
public partial struct AIState : IComponent
{
public AIStateType Current;
public AIStateType Previous;
public float TimeInState;
}
// State-specific data components
[Component]
public partial struct PatrolData : IComponent
{
public int CurrentWaypointIndex;
public float WaitTimer;
}
[Component]
public partial struct ChaseData : IComponent
{
public Entity Target;
public float LostTargetTimer;
}
[Component]
public partial struct AttackData : IComponent
{
public Entity Target;
public float Cooldown;
}
State Machine System
public class AIStateMachineSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.Update;
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
foreach (var entity in World.Query<AIState, Position>())
{
ref var state = ref World.Get<AIState>(entity);
ref readonly var pos = ref World.Get<Position>(entity);
state.TimeInState += deltaTime;
// Evaluate transitions
var newState = EvaluateTransitions(entity, ref state, ref pos);
if (newState != state.Current)
{
TransitionTo(buffer, entity, ref state, newState);
}
}
buffer.Execute();
}
private AIStateType EvaluateTransitions(Entity entity, ref AIState state, ref Position pos)
{
switch (state.Current)
{
case AIStateType.Idle:
// Check for enemies in range
if (TryFindTarget(entity, pos, out _))
return AIStateType.Chase;
// Random chance to start patrolling
if (state.TimeInState > 3f)
return AIStateType.Patrol;
break;
case AIStateType.Patrol:
if (TryFindTarget(entity, pos, out _))
return AIStateType.Chase;
break;
case AIStateType.Chase:
if (World.TryGet<ChaseData>(entity, out var chase))
{
if (!World.IsAlive(chase.Target))
return AIStateType.Idle;
var targetPos = World.Get<Position>(chase.Target);
var dist = Distance(pos, targetPos);
if (dist < 50) // In attack range
return AIStateType.Attack;
if (dist > 500) // Lost target
return AIStateType.Idle;
}
break;
case AIStateType.Attack:
if (World.TryGet<Health>(entity, out var health) && health.Current < 20)
return AIStateType.Flee; // Low health, run!
if (World.TryGet<AttackData>(entity, out var attack))
{
if (!World.IsAlive(attack.Target))
return AIStateType.Idle;
var targetPos = World.Get<Position>(attack.Target);
if (Distance(pos, targetPos) > 100) // Out of attack range
return AIStateType.Chase;
}
break;
case AIStateType.Flee:
if (World.TryGet<Health>(entity, out var h) && h.Current > 50)
return AIStateType.Idle; // Recovered
break;
}
return state.Current; // No transition
}
private void TransitionTo(ICommandBuffer buffer, Entity entity, ref AIState state, AIStateType newState)
{
// Exit current state - clean up state-specific components
switch (state.Current)
{
case AIStateType.Patrol:
buffer.Remove<PatrolData>(entity);
break;
case AIStateType.Chase:
buffer.Remove<ChaseData>(entity);
break;
case AIStateType.Attack:
buffer.Remove<AttackData>(entity);
break;
}
// Enter new state - add state-specific components
switch (newState)
{
case AIStateType.Patrol:
buffer.Add(entity, new PatrolData { CurrentWaypointIndex = 0 });
break;
case AIStateType.Chase:
if (TryFindTarget(entity, World.Get<Position>(entity), out var target))
buffer.Add(entity, new ChaseData { Target = target });
break;
case AIStateType.Attack:
if (World.TryGet<ChaseData>(entity, out var chase))
buffer.Add(entity, new AttackData { Target = chase.Target });
break;
}
// Update state
state.Previous = state.Current;
state.Current = newState;
state.TimeInState = 0f;
}
private bool TryFindTarget(Entity self, Position pos, out Entity target)
{
target = default;
float closestDist = 300f; // Detection range
foreach (var candidate in World.Query<Position>().With<Player>())
{
var candidatePos = World.Get<Position>(candidate);
var dist = Distance(pos, candidatePos);
if (dist < closestDist)
{
closestDist = dist;
target = candidate;
}
}
return target.Id != 0;
}
private float Distance(Position a, Position b)
=> MathF.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
}
State Behavior Systems
public class PatrolBehaviorSystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AIState, PatrolData, Position>())
{
ref readonly var state = ref World.Get<AIState>(entity);
if (state.Current != AIStateType.Patrol)
continue;
ref var patrol = ref World.Get<PatrolData>(entity);
ref var pos = ref World.Get<Position>(entity);
// Patrol logic: move between waypoints
var waypoint = GetWaypoint(patrol.CurrentWaypointIndex);
MoveToward(ref pos, waypoint, 50f * deltaTime);
if (Distance(pos, waypoint) < 5f)
{
patrol.WaitTimer += deltaTime;
if (patrol.WaitTimer > 2f)
{
patrol.CurrentWaypointIndex = (patrol.CurrentWaypointIndex + 1) % WaypointCount;
patrol.WaitTimer = 0f;
}
}
}
}
}
public class ChaseBehaviorSystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AIState, ChaseData, Position>())
{
ref readonly var state = ref World.Get<AIState>(entity);
if (state.Current != AIStateType.Chase)
continue;
ref readonly var chase = ref World.Get<ChaseData>(entity);
ref var pos = ref World.Get<Position>(entity);
if (!World.IsAlive(chase.Target))
continue;
var targetPos = World.Get<Position>(chase.Target);
MoveToward(ref pos, targetPos, 100f * deltaTime);
}
}
}
public class AttackBehaviorSystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AIState, AttackData>())
{
ref readonly var state = ref World.Get<AIState>(entity);
if (state.Current != AIStateType.Attack)
continue;
ref var attack = ref World.Get<AttackData>(entity);
attack.Cooldown -= deltaTime;
if (attack.Cooldown <= 0 && World.IsAlive(attack.Target))
{
// Deal damage
World.Add(attack.Target, new DamageReceived { Amount = 10, Source = entity });
attack.Cooldown = 1f; // Attack once per second
}
}
}
}
Why This Works
State as Component
Using AIState as a component means:
- States are queryable: "Find all entities in Chase state"
- States are serializable: Save/load preserves AI state
- States are visible: Debug tools can inspect current state
- No inheritance: No
ChaseState : BaseStatehierarchies
State-Specific Data as Components
Each state's data lives in separate components:
- Only exists when in that state (via add/remove on transitions)
- Queries naturally filter:
Query<ChaseData>gets only chasing entities - No wasted memory: Idle entities don't carry patrol data
Separation of Concerns
- State Machine System: Handles transitions only
- Behavior Systems: Handle per-state logic
- Transition Logic: Centralized, easy to modify
Transition Clarity
The TransitionTo method explicitly:
- Exits the old state (cleanup)
- Enters the new state (setup)
- Resets timing
This makes state lifecycles predictable.
Variations
Hierarchical State Machines
public enum CombatSubState
{
Approaching,
Attacking,
Retreating
}
[Component]
public partial struct CombatState : IComponent
{
public CombatSubState SubState;
public float SubStateTimer;
}
// Combat state has its own sub-state machine
// Only active when AIState.Current == Combat
Utility AI Hybrid
Combine FSM with utility scoring:
public class UtilityAISystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AIState, Health, Position>())
{
ref var state = ref World.Get<AIState>(entity);
ref readonly var health = ref World.Get<Health>(entity);
ref readonly var pos = ref World.Get<Position>(entity);
// Score each possible state
var scores = new Dictionary<AIStateType, float>
{
[AIStateType.Idle] = 0.1f,
[AIStateType.Patrol] = 0.3f,
[AIStateType.Chase] = ScoreChase(entity, pos),
[AIStateType.Attack] = ScoreAttack(entity, pos),
[AIStateType.Flee] = ScoreFlee(health),
};
// Pick highest scoring state
var best = scores.MaxBy(kvp => kvp.Value).Key;
if (best != state.Current)
{
// Transition...
}
}
}
private float ScoreFlee(Health health)
{
// Higher score when low health
return 1f - (health.Current / (float)health.Max);
}
}
Event-Driven Transitions
[Component]
public partial struct StateTransitionRequest : IComponent
{
public AIStateType TargetState;
public Entity Source; // What triggered this transition
}
// External systems can request state changes
world.Add(enemy, new StateTransitionRequest
{
TargetState = AIStateType.Flee,
Source = loudNoise
});
Debug Visualization
public class AIDebugSystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AIState, Position>())
{
ref readonly var state = ref World.Get<AIState>(entity);
ref readonly var pos = ref World.Get<Position>(entity);
// Draw state name above entity
var color = state.Current switch
{
AIStateType.Idle => Color.Gray,
AIStateType.Patrol => Color.Blue,
AIStateType.Chase => Color.Yellow,
AIStateType.Attack => Color.Red,
AIStateType.Flee => Color.Purple,
_ => Color.White
};
DebugDraw.Text(state.Current.ToString(), pos, color);
DebugDraw.Text($"{state.TimeInState:F1}s", pos + new Vector2(0, 15), color);
}
}
}
See Also
- AI System Research - Comprehensive AI architecture
- Messaging Guide - Event-driven communication
- Health & Damage - Combat integration