Skip to main content

Enemy AI System

EnemyAISystem drives enemy behaviour each frame. It runs after KnockbackSystem and delegates to each enemy's strategy, which in turn selects and runs a behaviour.

The AI is split into two layers:

LayerProtocolResponsibility
StrategyEnemyStrategyDecides which behaviour runs this frame (state machine)
BehaviourEnemyBehaviourExecutes the chosen action (writes velocity, manages components)

Components Required

For an enemy to be processed, it must have:

  • EnemyStateComponent — holds the enemy's strategy instance
  • TransformComponent — provides the enemy's current position
  • VelocityComponent — written each frame by the active behaviour

Note: Enemies that currently have a KnockbackComponent are skipped — knockback takes priority over AI-driven movement.


EnemyStateComponent

EnemyStateComponent holds a single strategy instance that drives all behaviour decisions for that enemy.

public class EnemyStateComponent: Component {
public var strategy: any EnemyStrategy

public init(strategy: any EnemyStrategy = StandardStrategy())
}

To give an enemy a different AI personality, supply a different strategy at creation time or replace the component entirely after spawning.


BehaviourContext

Every strategy and behaviour receives a BehaviourContext each frame — a lightweight snapshot of world state with convenience accessors:

PropertyTypeDescription
entityEntityThe enemy being updated
playerPosSIMD2<Float>Player's world position this frame
transformTransformComponentEnemy's transform (read-only snapshot)
worldWorldFull ECS world for component queries
distToPlayerFloatEuclidean distance to the player
healthFractionFloat?Current HP as a 0–1 fraction; nil if no HealthComponent
roomBoundsRoomBounds?Bounds of the room this enemy belongs to; nil if no RoomMemberComponent

Strategy Protocol

public protocol EnemyStrategy {
func update(entity: Entity, context: BehaviourContext)
}

Strategies call activate(_:from:for:context:) (provided by a protocol extension on EnemyStrategy) instead of invoking a behaviour directly. This helper:

  1. Detects when the chosen behaviour changes (comparing ActiveBehaviourComponent.behaviourID).
  2. Calls onDeactivate on the outgoing behaviour.
  3. Calls onActivate on the incoming behaviour.
  4. Calls update on the chosen behaviour.

Behaviour Protocol

public protocol EnemyBehaviour {
var id: String { get }
func update(entity: Entity, context: BehaviourContext)
func onActivate(entity: Entity, context: BehaviourContext)
func onDeactivate(entity: Entity, context: BehaviourContext)
}
  • id defaults to the type name (e.g. "WanderBehaviour"). CompositeBehaviour derives its id from both children.
  • onActivate / onDeactivate have empty default implementations — only override when the behaviour needs to add or remove components on transition.
  • ActiveBehaviourComponent is added lazily to the entity by the strategy on first use and stores the currently active behaviour's id.

Built-in Strategies

StandardStrategy

Wanders when idle; attacks when the player enters detectionRadius. Keeps attacking until the player leaves loseRadius (hysteresis). A nil loseRadius means the enemy never disengages.

ParameterDefaultDescription
detectionRadius150Distance at which the enemy switches from wander to attack
loseRadius225Distance at which the enemy returns to wander; nil = never disengage
wanderBehaviourWanderBehaviour()Behaviour used when idle
attackBehaviourChaseBehaviour()Behaviour used when engaging
// Default — chases the player
StandardStrategy()

// Shooter that orbits while firing; never disengages
StandardStrategy(
detectionRadius: 200,
loseRadius: nil,
attackBehaviour: CompositeBehaviour(OrbitBehaviour(), ShooterBehaviour())
)

TimidStrategy

Like StandardStrategy but adds a flee response. Priority order: flee > attack > wander.

ParameterDefaultDescription
detectionRadius150Distance at which the enemy switches from wander to attack
loseRadius225Distance at which the enemy returns to wander; nil = never disengage
fleeThreshold0.2HP fraction below which the enemy flees regardless of distance
wanderBehaviourWanderBehaviour()Behaviour used when idle
attackBehaviourChaseBehaviour()Behaviour used when engaging
fleeBehaviourFleeBehaviour()Behaviour used when HP is low
// Default — flees below 20% HP
TimidStrategy()

// More aggressive flee trigger
TimidStrategy(fleeThreshold: 0.5)

Built-in Behaviours

WanderBehaviour

Moves the enemy to random points within wanderRadius, constrained to the room the enemy belongs to. On arrival (within 8 pt of the target) a new candidate is picked. Candidates are validated against the room's safe area (RoomBounds inset by wallMargin) — preventing the enemy from wandering into walls. If all directed candidates fall outside the safe area the behaviour falls back to a random point anywhere inside it. If the enemy has no RoomMemberComponent the room constraint is skipped.

ParameterDefaultDescription
wanderRadius100Max distance from current position for a new target
wanderSpeed40Movement speed while wandering
wallMargin40Inset from room boundary when validating candidates
WanderBehaviour(wanderRadius: 100, wanderSpeed: 40, wallMargin: 40)

Associated component: WanderTargetComponent — stores the current target as SIMD2<Float>?. Added lazily on first update; removed in onDeactivate.


ChaseBehaviour

Moves the enemy directly toward the player at a fixed speed.

ParameterDefaultDescription
speed70Movement speed while chasing
ChaseBehaviour(speed: 70)

FleeBehaviour

Moves the enemy directly away from the player at a fixed speed.

ParameterDefaultDescription
speed90Movement speed while fleeing
FleeBehaviour(speed: 90)

OrbitBehaviour

Moves the enemy in an arc around the player by hopping between polar-coordinate targets in an annular zone. Each hop picks a new angle offset (±arcRange) from the current bearing and a random radius, forming a zigzag orbit. The enemy briefly stops between hops.

Pair with ShooterBehaviour (via CompositeBehaviour) to get a shooter that moves and fires simultaneously.

ParameterDefaultDescription
innerRadius100Closest distance from the player
outerRadius200Furthest distance from the player
moveSpeed60Movement speed between hop targets
arcRangeπ/3Max angular deviation per hop (radians)
OrbitBehaviour(innerRadius: 100, outerRadius: 200, moveSpeed: 60, arcRange: .pi / 3)

Associated component: ShooterBasicComponent — stores the current hop target as polar coordinates (targetAngle, targetRadius) relative to the player. Added lazily; removed in onDeactivate.


ShooterBehaviour

Aims at the player and signals intent to fire each frame. Does not write to VelocityComponent — pair with a movement behaviour via CompositeBehaviour.

On activation: adds FacingComponent and InputComponent (if absent) and spawns + equips a weapon entity. On deactivation: destroys the weapon entity and clears isShooting.

ParameterDefaultDescription
weaponBase.enemyRangedDefaultThe weapon template to equip on activation
ShooterBehaviour(weaponBase: .enemyRangedDefault)

StationaryBehaviour

Does nothing — the enemy stays in place. Use as wanderBehaviour or attackBehaviour for tower-type enemies.

StationaryBehaviour()

CompositeBehaviour

Combines two behaviours into one, delegating all lifecycle calls and update to both in order. Useful for pairing a movement behaviour with an attack behaviour in a single strategy slot.

Its id is derived from both children ("PrimaryID+SecondaryID"), so ActiveBehaviourComponent tracks the pair as a single unit and transitions fire correctly.

// Orbit while shooting
CompositeBehaviour(OrbitBehaviour(), ShooterBehaviour())

// Stand still while shooting
CompositeBehaviour(StationaryBehaviour(), ShooterBehaviour())

Update Loop

Each frame, EnemyAISystem.update() does the following for every qualifying enemy:

  1. Skip if KnockbackComponent is present.
  2. Build a BehaviourContext snapshot.
  3. Call strategy.update(entity:context:) — the strategy decides which behaviour runs and calls activate(_:from:for:context:).
  4. activate handles transition lifecycle (onDeactivate / onActivate) and then calls behaviour.update.

The system itself does not write velocity — that is each behaviour's responsibility.


Adding a New Strategy

  1. Define a struct or final class conforming to EnemyStrategy.
  2. Implement update(entity:context:) — call activate(_:from:for:context:) with your chosen behaviour.
public struct MyStrategy: EnemyStrategy {
public var wanderBehaviour: any EnemyBehaviour = WanderBehaviour()
public var attackBehaviour: any EnemyBehaviour = ChaseBehaviour()

public func update(entity: Entity, context: BehaviourContext) {
let chosen = context.distToPlayer < 150 ? attackBehaviour : wanderBehaviour
activate(chosen, from: [wanderBehaviour, attackBehaviour], for: entity, context: context)
}
}

Adding a New Behaviour

  1. Define a struct conforming to EnemyBehaviour.
  2. Implement update — write to VelocityComponent (or other components) via context.world.
  3. Override onActivate / onDeactivate only if your behaviour needs to add or remove companion components on transition.
  4. If your behaviour needs per-entity state, create a companion Component class and add it lazily in update.
public struct MyBehaviour: EnemyBehaviour {
public var speed: Float = 50

public func update(entity: Entity, context: BehaviourContext) {
context.world.getComponent(type: VelocityComponent.self, for: entity)?.linear = /* ... */
}
}

Dependencies

DependencyRole
EnemyStateComponentHolds the strategy instance
TransformComponentRead for enemy and player positions
VelocityComponentWritten by the active behaviour each frame
KnockbackComponentPresence causes the enemy to be skipped this frame
PlayerTagComponentUsed to locate the player entity
ActiveBehaviourComponentTracks the currently running behaviour id; managed by EnemyStrategy
WanderTargetComponentPer-entity wander destination; managed by WanderBehaviour
ShooterBasicComponentPer-entity orbit state; managed by OrbitBehaviour
EquippedWeaponComponentWeapon slot; managed by ShooterBehaviour
RoomMemberComponentUsed by BehaviourContext.roomBounds to look up room boundaries
RoomMetadataComponentProvides RoomBounds for wander target validation