How I made enemies attack while chasing you
Early on in building the enemy AI for my game, I ran into a problem that I think anyone who's built a behavior tree has hit. The tree picks one thing for the enemy to do: Chase the player. Patrol a route. Flee to safety. One behavior, one result. But enemies that stop moving to attack and then start moving again look terrible. They stutter. In any good action game, enemies attack while they're running at you. That's what I wanted.
The problem is structural. A behavior tree evaluates its nodes and returns a single result: success, running, or failure. If the active behavior is "chase the player," there's no obvious place for "also fire your weapon" to live. You could add attack logic inside every movement behavior, but that's a maintenance nightmare. You could alternate between move and attack nodes frame by frame, but that creates visible stuttering. Neither option is good.
Borrowing from game engines
The solution I landed on comes from behavior trees found in game engines: services. A service is a lightweight polling function that you attach to a composite node (like a selector or sequence). It runs as a side-effect at a configurable interval while the parent node is active. It doesn't return a result. It doesn't affect tree flow. It just does its thing in the background.
Here's the protocol:
public protocol BehaviorTreeService: AnyObject {
var name: String { get }
var interval: Float { get }
var accumulatedTime: Float { get set }
func tick(entity: Entity, context: AIContextComponent)
}
That's most of it. A name for debugging, an interval that controls how often it fires, accumulated time so it can track its own cadence, and a tick method that receives the entity and the current AI state. No return value. Fire and forget.
Attaching it to the tree
Services live on composite nodes. When the executor evaluates the tree, it runs the normal node logic first (which decides what the enemy is doing), then walks back through the active path and ticks any services it finds. Here's what a basic enemy tree looks like in the factory:
static func createBasicEnemyTree(attackTags: [GameplayTag] = []) -> AIBehaviorNode {
return SelectorNode(
name: "BasicEnemyRoot",
children: [
ChaseBehavior(),
SearchBehavior(),
ReturnToAggroPositionBehavior(),
PatrolBehavior(),
IdleBehavior()
],
services: [AttackService(abilityTags: attackTags)]
)
}
The AttackService is attached to the root selector. So no matter which child behavior is active (chasing, searching, patrolling, whatever), the attack service ticks alongside it. The enemy moves AND attacks. No stuttering. No duplicated logic across behaviors.
The execution order matters
The executor handles this in a specific sequence each tick:
1. Check for hard crowd control (stun, freeze, stagger). If CC'd, stop everything.
2. Clear previous results and service flags.
3. Execute the behavior tree. This picks the active behavior and issues movement commands.
4. Tick services on the active path. This fires attacks.
Services only tick on composite nodes that were actually evaluated. If a subtree wasn't part of the active path this frame, its services stay quiet. This keeps things efficient and predictable.
A side benefit: the debugger
Because services report their status ("Fired", "Cooldown 2.3s", "Chilled"), the standalone debugger app I built can show attack state right alongside the behavior tree visualization. You can see exactly when an enemy is attacking, when it's on cooldown, and when a chill effect is slowing its attack rate. That last part was a fun one. The AttackService respects a chill multiplier, so ice-based builds in the game make enemies attack slower without stopping them entirely.
Why this worked
The key insight is separating decisions from side-effects. The behavior tree decides what the enemy is doing. Services handle the things that should happen regardless of what it's doing. Movement is a decision. Attacking is a side-effect. Once I framed it that way, the whole system clicked into place and made adding new periodic behaviors trivial.
If you're building behavior trees in Swift (or any language, really), services are a concept worth implementing. They solve a real problem with very little code.