Behavior Trees

Create complex AI behaviors using behavior trees for decision making and action execution with a powerful node-based system.

Overview#

Behavior Trees (BTs) are a hierarchical node-based system for AI decision making. They provide a structured way to build complex behaviors from simple, reusable components. Unlike state machines, behavior trees are easier to design, visualize, and maintain as complexity grows.

Key Features#

Node Types

Composite nodes (Selector, Sequence, Parallel), Decorators (Inverter, Repeater, Timeout), and Leaf nodes (Action, Condition).

Blackboard System

Shared memory for communication between nodes with typed access to agent state and environment data.

Running State

Nodes can return RUNNING to span multiple frames, properly resuming execution where they left off.

Performance Optimized

Zero-GC design with object pooling, throttling support, and efficient tree execution.

Node Types#

Composite Nodes#

Composite nodes have multiple children and control their execution order and logic.

Selector (OR Node)#

Executes children left-to-right until one succeeds. Returns SUCCESS if any child succeeds, FAILURE if all children fail.

selector.ts
typescript
import { BTSelector, BTCondition, BTAction, BTStatus } from '@web-engine-dev/core/engine/ai';
// Try options in priority order: attack, flee, or patrol
const selector = new BTSelector([
// Option 1: Attack if in range
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTCondition('inRange', (ctx) => {
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
return dist < 3.0;
}),
new BTAction('attack', (ctx) => {
// Perform attack
performAttack(ctx.eid);
return BTStatus.SUCCESS;
}),
]),
// Option 2: Flee if health is low
new BTSequence([
new BTCondition('isHealthLow', (ctx) => {
const health = ctx.blackboard.get<number>('health') ?? 100;
return health < 20;
}),
new BTAction('flee', (ctx) => {
fleeBehavior(ctx.eid);
return BTStatus.RUNNING;
}),
]),
// Option 3: Default to patrol
new BTAction('patrol', (ctx) => {
patrolBehavior(ctx.eid);
return BTStatus.RUNNING;
}),
]);

Sequence (AND Node)#

Executes children left-to-right until one fails. Returns SUCCESS if all children succeed, FAILURE if any child fails.

sequence.ts
typescript
import { BTSequence } from '@web-engine-dev/core/engine/ai';
// All steps must succeed
const sequence = new BTSequence([
// Step 1: Check prerequisites
new BTCondition('hasAmmo', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return ammo > 0;
}),
// Step 2: Aim at target
new BTAction('aimAtTarget', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid');
if (!targetEid) return BTStatus.FAILURE;
const aimed = aimWeapon(ctx.eid, targetEid, ctx.delta);
return aimed ? BTStatus.SUCCESS : BTStatus.RUNNING;
}),
// Step 3: Fire weapon
new BTAction('fireWeapon', (ctx) => {
fireWeapon(ctx.eid);
return BTStatus.SUCCESS;
}),
// Step 4: Wait for cooldown
new BTWait(0.5),
]);

Parallel Node#

Executes all children simultaneously. Uses success/failure policies to determine when to complete.

parallel.ts
typescript
import { BTParallel, BTParallelPolicy } from '@web-engine-dev/core/engine/ai';
// Move AND play animation at the same time
const parallel = new BTParallel(
BTParallelPolicy.REQUIRE_ALL, // Succeed when ALL children succeed
BTParallelPolicy.REQUIRE_ONE, // Fail when ANY child fails
[
// Action 1: Move to target
new BTAction('moveToTarget', (ctx) => {
const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');
if (!targetPos) return BTStatus.FAILURE;
const distance = moveTowards(ctx.eid, targetPos, ctx.delta);
return distance < 0.5 ? BTStatus.SUCCESS : BTStatus.RUNNING;
}),
// Action 2: Play walk animation
new BTAction('playWalkAnim', (ctx) => {
playAnimation(ctx.eid, 'walk');
return BTStatus.SUCCESS;
}),
// Action 3: Look at target
new BTAction('lookAtTarget', (ctx) => {
const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');
if (targetPos) {
lookAt(ctx.eid, targetPos);
}
return BTStatus.SUCCESS;
}),
]
);

Decorator Nodes#

Decorators wrap a single child and modify its behavior or result.

DecoratorDescriptionUse Case
InverterInverts SUCCESS to FAILURE and vice versaNOT conditions, reverse logic
RepeaterRepeats child N times or infinitelyAttack combos, continuous actions
TimeoutFails child if it takes too longLimit search duration, prevent stuck states
SucceederAlways returns SUCCESSOptional behaviors that shouldn't fail parent
UntilFailRepeats until child failsKeep doing action until condition changes
CooldownPrevents execution within cooldown periodAbility cooldowns, rate limiting
RateLimiterLimits success frequencyEvent throttling, spawn limiting

Decorator Examples#

decorators.ts
typescript
import {
BTInverter,
BTRepeater,
BTTimeout,
BTCooldown,
BTSucceeder,
} from '@web-engine-dev/core/engine/ai';
// Inverter: NOT at target
const notAtTarget = new BTInverter(
new BTCondition('atTarget', (ctx) => isAtTarget(ctx.eid))
);
// Repeater: Attack 3 times
const tripleAttack = new BTRepeater(
new BTAction('attack', attackAction),
3, // limit
true // abortOnFailure
);
// Timeout: Give up searching after 10 seconds
const timedSearch = new BTTimeout(
new BTAction('searchForTarget', searchAction),
10.0
);
// Cooldown: Can only use ability every 2 seconds
const specialAbility = new BTCooldown(
new BTAction('useSpecialAbility', abilityAction),
2.0
);
// Succeeder: Play animation but don't fail if already playing
const optionalAnimation = new BTSucceeder(
new BTAction('playAnimation', playAnimAction)
);
// Combined: Timeout + Repeater
const patrolForLimitedTime = new BTTimeout(
new BTRepeater(
new BTAction('patrolWaypoint', patrolAction),
-1 // infinite
),
30.0 // but timeout after 30 seconds
);

Leaf Nodes#

Leaf nodes perform the actual work: checking conditions or executing actions.

Condition Node#

Evaluates a predicate function. Returns SUCCESS if true, FAILURE if false. Never returns RUNNING.

conditions.ts
typescript
import { BTCondition } from '@web-engine-dev/core/engine/ai';
// Simple boolean check
const hasTarget = new BTCondition('hasTarget', (ctx) => {
return ctx.blackboard.has('targetEid');
});
// Numeric comparison
const isHealthLow = new BTCondition('isHealthLow', (ctx) => {
const health = ctx.blackboard.get<number>('health') ?? 100;
return health < 30;
});
// Distance check
const isInRange = new BTCondition('isInRange', (ctx) => {
const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');
if (!targetPos) return false;
const myPos = Transform.position[ctx.eid];
const dx = targetPos[0] - myPos[0];
const dy = targetPos[1] - myPos[1];
const dz = targetPos[2] - myPos[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
return distance < 5.0;
});
// Complex logic
const canAttack = new BTCondition('canAttack', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
const cooldown = ctx.blackboard.get<number>('attackCooldown') ?? 0;
const hasTarget = ctx.blackboard.has('targetEid');
return ammo > 0 && cooldown <= 0 && hasTarget;
});

Action Node#

Executes game logic and returns SUCCESS, FAILURE, or RUNNING. Can span multiple frames.

actions.ts
typescript
import { BTAction, BTStatus } from '@web-engine-dev/core/engine/ai';
// Instant action (completes in one tick)
const fireWeapon = new BTAction('fireWeapon', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid');
if (!targetEid) return BTStatus.FAILURE;
spawnBullet(ctx.eid, targetEid);
playSound(ctx.eid, 'gunshot');
return BTStatus.SUCCESS;
});
// Running action (spans multiple frames)
const moveToTarget = new BTAction('moveToTarget', (ctx) => {
const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');
if (!targetPos) return BTStatus.FAILURE;
const myPos = Transform.position[ctx.eid];
const dx = targetPos[0] - myPos[0];
const dy = targetPos[1] - myPos[1];
const dz = targetPos[2] - myPos[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distance < 0.5) {
return BTStatus.SUCCESS; // Reached target
}
// Move toward target
const speed = 3.0;
const dirX = dx / distance;
const dirY = dy / distance;
const dirZ = dz / distance;
myPos[0] += dirX * speed * ctx.delta;
myPos[1] += dirY * speed * ctx.delta;
myPos[2] += dirZ * speed * ctx.delta;
return BTStatus.RUNNING; // Still moving
});
// Action with cleanup callback
const searchForTarget = new BTAction(
'searchForTarget',
(ctx) => {
// Search logic
const foundTarget = scanForEnemies(ctx.eid);
if (foundTarget) {
ctx.blackboard.set('targetEid', foundTarget);
return BTStatus.SUCCESS;
}
return BTStatus.RUNNING;
},
() => {
// Cleanup when reset
console.log('Search cancelled');
}
);

Blackboard System#

The blackboard is a shared memory system for communication between nodes. It stores typed data that all nodes in the tree can read and write.

blackboard.ts
typescript
import { BTBlackboard } from '@web-engine-dev/core/engine/ai';
// Create blackboard
const blackboard = new BTBlackboard();
// Store different types of data
blackboard.set('health', 100);
blackboard.set('maxHealth', 100);
blackboard.set('ammo', 30);
blackboard.set('targetEid', 42);
blackboard.set('isAggressive', true);
blackboard.set('lastSeenPosition', new Float32Array([10, 0, 5]));
blackboard.set('patrolPoints', [
new Float32Array([0, 0, 0]),
new Float32Array([10, 0, 0]),
new Float32Array([10, 0, 10]),
]);
// Retrieve data with type safety
const health = blackboard.get<number>('health') ?? 100;
const targetEid = blackboard.get<number>('targetEid');
const lastSeenPos = blackboard.get<Float32Array>('lastSeenPosition');
// Check existence
if (blackboard.has('targetEid')) {
// Target exists
}
// Delete data
blackboard.delete('targetEid');
// Clear all data
blackboard.clear();
// Use in conditions
const hasAmmo = new BTCondition('hasAmmo', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return ammo > 0;
});
// Use in actions
const consumeAmmo = new BTAction('consumeAmmo', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
ctx.blackboard.set('ammo', Math.max(0, ammo - 1));
return BTStatus.SUCCESS;
});

Building Behavior Trees#

Manual Construction#

manual-tree.ts
typescript
import {
BTSelector,
BTSequence,
BTCondition,
BTAction,
BTStatus,
} from '@web-engine-dev/core/engine/ai';
// Build tree manually
const combatTree = new BTSelector([
// Priority 1: Reload if out of ammo
new BTSequence([
new BTCondition('needsReload', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return ammo === 0;
}),
new BTAction('reload', (ctx) => {
ctx.blackboard.set('ammo', 30);
playAnimation(ctx.eid, 'reload');
return BTStatus.SUCCESS;
}),
]),
// Priority 2: Attack if in range
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTCondition('inAttackRange', (ctx) => {
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
return dist < 10.0;
}),
new BTCondition('hasAmmo', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return ammo > 0;
}),
new BTAction('aimAndFire', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
aimAt(ctx.eid, targetEid);
fireWeapon(ctx.eid);
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
ctx.blackboard.set('ammo', ammo - 1);
return BTStatus.SUCCESS;
}),
]),
// Priority 3: Chase target if visible
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTAction('chaseTarget', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid');
if (!targetEid) return BTStatus.FAILURE;
chaseEntity(ctx.eid, targetEid, ctx.delta);
return BTStatus.RUNNING;
}),
]),
// Priority 4: Search for target
new BTAction('searchForTarget', (ctx) => {
const foundTarget = scanForEnemies(ctx.eid);
if (foundTarget) {
ctx.blackboard.set('targetEid', foundTarget);
return BTStatus.SUCCESS;
}
return BTStatus.RUNNING;
}),
// Priority 5: Idle
new BTAction('idle', (ctx) => {
playAnimation(ctx.eid, 'idle');
return BTStatus.SUCCESS;
}),
]);

Builder API (Fluent)#

Use the fluent builder API for cleaner, more readable tree construction.

builder.ts
typescript
import { BTBuilder } from '@web-engine-dev/core/engine/ai';
const tree = BTBuilder.build()
.selector()
// Option 1: Combat
.sequence()
.condition('hasTarget', (ctx) => ctx.blackboard.has('targetEid'))
.condition('inRange', (ctx) => {
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
return dist < 10;
})
.action('attack', (ctx) => {
performAttack(ctx.eid);
return BTStatus.SUCCESS;
})
.end()
// Option 2: Patrol
.sequence()
.condition('hasPatrolRoute', (ctx) => ctx.blackboard.has('patrolPoints'))
.action('patrol', (ctx) => {
patrolWaypoints(ctx.eid);
return BTStatus.RUNNING;
})
.end()
// Option 3: Idle
.action('idle', (ctx) => {
playIdleAnimation(ctx.eid);
return BTStatus.SUCCESS;
})
.end()
.build();

Common AI Patterns#

Patrol Pattern#

patrol-pattern.ts
typescript
// Patrol with waypoint cycling
const patrolTree = new BTSequence([
// Ensure we have patrol points
new BTCondition('hasPatrolPoints', (ctx) => {
return ctx.blackboard.has('patrolPoints');
}),
// Move to current waypoint
new BTAction('moveToWaypoint', (ctx) => {
const patrolPoints = ctx.blackboard.get<Float32Array[]>('patrolPoints')!;
const currentIndex = ctx.blackboard.get<number>('patrolIndex') ?? 0;
const targetPos = patrolPoints[currentIndex];
const distance = moveTowards(ctx.eid, targetPos, ctx.delta);
if (distance < 0.5) {
// Reached waypoint, advance to next
const nextIndex = (currentIndex + 1) % patrolPoints.length;
ctx.blackboard.set('patrolIndex', nextIndex);
return BTStatus.SUCCESS;
}
return BTStatus.RUNNING;
}),
// Wait at waypoint
new BTWait(2.0),
// Always succeed to loop
new BTAction('continuePatrol', () => BTStatus.SUCCESS),
]);

Chase Pattern#

chase-pattern.ts
typescript
// Chase target with line of sight check
const chaseTree = new BTSequence([
// Check we have a target
new BTCondition('hasTarget', (ctx) => {
return ctx.blackboard.has('targetEid');
}),
// Check line of sight
new BTCondition('hasLineOfSight', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
return checkLineOfSight(ctx.eid, targetEid);
}),
// Chase using steering behaviors
new BTAction('pursue', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
// Get target position and velocity
const targetPos = Transform.position[targetEid];
const targetVel = Velocity.linear[targetEid];
// Use pursue steering behavior
const agent = {
position: Transform.position[ctx.eid],
velocity: Velocity.linear[ctx.eid],
maxSpeed: 5.0,
maxForce: 10.0,
radius: 0.5,
mass: 1.0,
};
const target = {
position: targetPos,
velocity: targetVel,
};
const result = SteeringBehaviors.pursue(agent, target);
if (result.active) {
applySteeringForce(ctx.eid, result.force, ctx.delta);
}
// Check if caught up
const distance = calculateDistance(ctx.eid, targetEid);
return distance < 2.0 ? BTStatus.SUCCESS : BTStatus.RUNNING;
}),
]);

Attack Pattern#

attack-pattern.ts
typescript
// Complete attack sequence with cooldown
const attackTree = new BTSequence([
// Prerequisites
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTCondition('inRange', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
const distance = calculateDistance(ctx.eid, targetEid);
return distance < 3.0;
}),
new BTCondition('hasAmmo', (ctx) => {
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return ammo > 0;
}),
// Wind-up animation
new BTAction('windUp', (ctx) => {
playAnimation(ctx.eid, 'attack_windup');
return BTStatus.SUCCESS;
}),
// Wait for wind-up
new BTWait(0.3),
// Execute attack
new BTAction('executeAttack', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
// Deal damage
dealDamage(targetEid, 10);
// Consume ammo
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
ctx.blackboard.set('ammo', ammo - 1);
// Play attack animation
playAnimation(ctx.eid, 'attack');
playSound(ctx.eid, 'sword_slash');
return BTStatus.SUCCESS;
}),
// Recovery/cooldown
new BTWait(0.5),
]);
// Wrap with cooldown decorator
const attackWithCooldown = new BTCooldown(attackTree, 1.0);

Combine behavior trees with NavMesh pathfinding for intelligent navigation.

navmesh-integration.ts
typescript
import { NavMeshPath } from '@web-engine-dev/core/engine/ai';
// Pathfinding action that uses NavMesh
const navigateToTarget = new BTAction('navigateToTarget', (ctx) => {
const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');
if (!targetPos) return BTStatus.FAILURE;
// Check if we have an active path
let path = ctx.blackboard.get<NavMeshPath>('currentPath');
if (!path) {
// Request new path
const myPos = Transform.position[ctx.eid];
path = requestNavMeshPath(myPos, targetPos);
if (!path || path.length === 0) {
return BTStatus.FAILURE; // No path found
}
ctx.blackboard.set('currentPath', path);
ctx.blackboard.set('pathIndex', 0);
}
// Follow path
const pathIndex = ctx.blackboard.get<number>('pathIndex') ?? 0;
if (pathIndex >= path.length) {
// Path complete
ctx.blackboard.delete('currentPath');
ctx.blackboard.delete('pathIndex');
return BTStatus.SUCCESS;
}
const waypointPos = path[pathIndex];
const distance = moveTowards(ctx.eid, waypointPos, ctx.delta);
if (distance < 0.5) {
// Reached waypoint, advance to next
ctx.blackboard.set('pathIndex', pathIndex + 1);
}
return BTStatus.RUNNING;
});
// Complete AI with NavMesh pathfinding
const aiTree = new BTSelector([
// Combat behavior
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTCondition('inAttackRange', (ctx) => {
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
return dist < 2.0;
}),
new BTAction('attack', attackAction),
]),
// Navigate to target
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTAction('setTargetPosition', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
const targetPos = Transform.position[targetEid];
ctx.blackboard.set('targetPosition', targetPos);
return BTStatus.SUCCESS;
}),
navigateToTarget,
]),
// Patrol using NavMesh
new BTSequence([
new BTAction('getNextPatrolPoint', (ctx) => {
const points = ctx.blackboard.get<Float32Array[]>('patrolPoints')!;
const index = ctx.blackboard.get<number>('patrolIndex') ?? 0;
ctx.blackboard.set('targetPosition', points[index]);
return BTStatus.SUCCESS;
}),
navigateToTarget,
new BTAction('advancePatrol', (ctx) => {
const points = ctx.blackboard.get<Float32Array[]>('patrolPoints')!;
const index = ctx.blackboard.get<number>('patrolIndex') ?? 0;
ctx.blackboard.set('patrolIndex', (index + 1) % points.length);
return BTStatus.SUCCESS;
}),
]),
]);

ECS Integration#

Setting Up an AI Agent#

setup-agent.ts
typescript
import {
AIAgent,
initAIAgent,
registerBehaviorTree,
assignBehaviorTree,
AIBehaviorTreeSystem,
} from '@web-engine-dev/core/engine/ai';
import { addComponent } from 'bitecs';
// 1. Register your behavior tree
registerBehaviorTree('EnemyAI', () => {
return new BTSelector([
// Your tree definition
combatBehavior,
patrolBehavior,
idleBehavior,
]);
});
// 2. Create entity with AI component
const enemyEid = addEntity(world);
addComponent(world, AIAgent, enemyEid);
addComponent(world, Transform, enemyEid);
addComponent(world, Velocity, enemyEid);
// 3. Initialize AI agent
initAIAgent(enemyEid);
// 4. Assign behavior tree
assignBehaviorTree(enemyEid, 'EnemyAI');
// 5. Configure agent
AIAgent.enabled[enemyEid] = 1;
AIAgent.updateInterval[enemyEid] = 0.1; // Update every 100ms
AIAgent.priority[enemyEid] = 10;
// 6. Add to game loop
function gameLoop(delta: number, time: number) {
// Run behavior tree system
AIBehaviorTreeSystem(world, delta, time);
}

Accessing Blackboard from ECS#

ecs-blackboard.ts
typescript
import { getBTBlackboard, setBlackboardValue } from '@web-engine-dev/core/engine/ai';
// Get entity's blackboard
const blackboard = getBTBlackboard(enemyEid);
if (blackboard) {
// Set initial data
blackboard.set('health', 100);
blackboard.set('maxHealth', 100);
blackboard.set('ammo', 30);
blackboard.set('patrolPoints', [
new Float32Array([0, 0, 0]),
new Float32Array([10, 0, 0]),
new Float32Array([10, 0, 10]),
]);
}
// Update blackboard from game events
function onTakeDamage(eid: number, damage: number) {
const blackboard = getBTBlackboard(eid);
if (blackboard) {
const health = blackboard.get<number>('health') ?? 100;
blackboard.set('health', Math.max(0, health - damage));
if (health <= 0) {
blackboard.set('isDead', true);
}
}
}
// Read blackboard in other systems
function renderHealthBar(eid: number) {
const blackboard = getBTBlackboard(eid);
if (blackboard) {
const health = blackboard.get<number>('health') ?? 100;
const maxHealth = blackboard.get<number>('maxHealth') ?? 100;
drawHealthBar(eid, health / maxHealth);
}
}

Debugging Behavior Trees#

Monitoring Tree Status#

debug-status.ts
typescript
import { getBTStatus, getBTInstance } from '@web-engine-dev/core/engine/ai';
// Check current status
const status = getBTStatus(enemyEid);
console.log(`BT Status: ${status}`); // SUCCESS, FAILURE, or RUNNING
// Get full instance for debugging
const instance = getBTInstance(AIAgent.controllerId[enemyEid]);
if (instance) {
console.log('Active:', instance.active);
console.log('Last Status:', instance.lastStatus);
console.log('Entity ID:', instance.eid);
// Access blackboard
const health = instance.runner.blackboard.get<number>('health');
console.log('Health:', health);
}

Adding Debug Logging#

debug-logging.ts
typescript
// Wrap actions with logging
function debugAction(name: string, action: (ctx: BTContext) => BTStatus): BTAction {
return new BTAction(name, (ctx) => {
console.log(`[${ctx.eid}] Action: ${name} - START`);
const status = action(ctx);
console.log(`[${ctx.eid}] Action: ${name} - ${status}`);
return status;
});
}
// Wrap conditions with logging
function debugCondition(name: string, predicate: (ctx: BTContext) => boolean): BTCondition {
return new BTCondition(name, (ctx) => {
const result = predicate(ctx);
console.log(`[${ctx.eid}] Condition: ${name} - ${result ? 'TRUE' : 'FALSE'}`);
return result;
});
}
// Use in tree
const debugTree = new BTSelector([
new BTSequence([
debugCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
debugAction('attack', (ctx) => {
performAttack(ctx.eid);
return BTStatus.SUCCESS;
}),
]),
debugAction('idle', (ctx) => {
return BTStatus.SUCCESS;
}),
]);

Tree Structure Visualization#

visualize.ts
typescript
import { serializeBTNode } from '@web-engine-dev/core/engine/ai';
// Serialize tree to JSON for visualization
function visualizeTree(tree: BTNode): void {
const json = serializeBTNode(tree);
console.log('Tree Structure:', JSON.stringify(json, null, 2));
}
// Print tree structure
function printTree(node: BTNode, indent: number = 0): void {
const spaces = ' '.repeat(indent);
console.log(`${spaces}${node.name}`);
if (node instanceof BTComposite) {
const composite = node as unknown as { children: BTNode[] };
for (const child of composite.children) {
printTree(child, indent + 1);
}
} else if (node instanceof BTDecorator) {
const decorator = node as unknown as { child: BTNode };
printTree(decorator.child, indent + 1);
}
}
// Use it
printTree(enemyAITree);
// Output:
// Selector
// Sequence
// Condition: hasTarget
// Action: attack
// Sequence
// Condition: hasPatrolPoints
// Action: patrol
// Action: idle

Performance Considerations#

Performance Tips

  • Use update intervals to throttle AI updates for distant agents
  • Combine conditions to reduce node traversal
  • Use decorators like Cooldown to limit expensive operations
  • Pool behavior tree instances - the system handles this automatically
  • Keep tree depth reasonable (5-7 levels max)
  • Use parallel nodes sparingly - they execute all children every tick

Update Throttling#

throttling.ts
typescript
// Throttle based on distance from camera
function updateAIThrottling(eid: number, cameraPos: Float32Array) {
const pos = Transform.position[eid];
const dx = pos[0] - cameraPos[0];
const dy = pos[1] - cameraPos[1];
const dz = pos[2] - cameraPos[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (distance < 10) {
AIAgent.updateInterval[eid] = 0.0; // Every frame
} else if (distance < 30) {
AIAgent.updateInterval[eid] = 0.1; // 10 Hz
} else if (distance < 50) {
AIAgent.updateInterval[eid] = 0.25; // 4 Hz
} else {
AIAgent.updateInterval[eid] = 0.5; // 2 Hz
}
}
// Or use LOD system
AIAgent.updateInterval[nearbyEnemy] = 0.0; // Full rate
AIAgent.updateInterval[mediumEnemy] = 0.1; // 10 Hz
AIAgent.updateInterval[distantEnemy] = 0.5; // 2 Hz
AIAgent.updateInterval[veryFarEnemy] = 1.0; // 1 Hz

Tree Optimization#

optimize.ts
typescript
// BAD: Deep nesting and redundant checks
const inefficientTree = new BTSelector([
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')), // Duplicate!
new BTCondition('inRange', (ctx) => true),
new BTSequence([
new BTCondition('hasAmmo', (ctx) => true),
new BTAction('attack', attackAction),
]),
]),
]),
]);
// GOOD: Flat structure, combined checks
const efficientTree = new BTSelector([
new BTSequence([
// Combine conditions when possible
new BTCondition('canAttack', (ctx) => {
const hasTarget = ctx.blackboard.has('targetEid');
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
const ammo = ctx.blackboard.get<number>('ammo') ?? 0;
return hasTarget && dist < 10 && ammo > 0;
}),
new BTAction('attack', attackAction),
]),
new BTAction('idle', idleAction),
]);
// Use early-out patterns
const earlyOutTree = new BTSelector([
// Cheapest checks first
new BTCondition('isDisabled', (ctx) => AIAgent.enabled[ctx.eid] === 0),
// Then more expensive checks
new BTSequence([
new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),
new BTCondition('hasLineOfSight', expensiveLineOfSightCheck),
new BTAction('combat', combatAction),
]),
]);

Best Practices#

Design Guidelines

  • Keep leaf nodes (actions/conditions) simple and focused on one task
  • Use selectors for priority-based decisions (try A, else B, else C)
  • Use sequences for step-by-step tasks that must all complete
  • Store complex state in the blackboard, not in node closures
  • Name nodes descriptively for debugging (e.g., 'isHealthBelowThreshold' not 'check1')
  • Use decorators to modify behavior rather than duplicating nodes
  • Reset trees when agent state changes significantly (e.g., death, teleport)
  • Test trees in isolation before integrating with full game logic

Complete Example: Enemy AI#

complete-enemy-ai.ts
typescript
import {
BTBuilder,
BTStatus,
registerBehaviorTree,
assignBehaviorTree,
AIBehaviorTreeSystem,
} from '@web-engine-dev/core/engine/ai';
// Register complete enemy AI behavior tree
registerBehaviorTree('EnemyGuard', () => {
return BTBuilder.build()
.selector()
// Priority 1: React to damage
.sequence()
.condition('wasHit', (ctx) => {
const wasHit = ctx.blackboard.get<boolean>('wasHit');
ctx.blackboard.set('wasHit', false); // Clear flag
return wasHit ?? false;
})
.action('alert', (ctx) => {
playSound(ctx.eid, 'alert');
ctx.blackboard.set('alertLevel', 2);
return BTStatus.SUCCESS;
})
.end()
// Priority 2: Combat
.sequence()
.condition('hasTarget', (ctx) => ctx.blackboard.has('targetEid'))
.selector()
// Attack if in range
.sequence()
.condition('inAttackRange', (ctx) => {
const dist = ctx.blackboard.get<number>('distanceToTarget') ?? Infinity;
return dist < 2.0;
})
.action('attack', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
dealDamage(targetEid, 10);
playAnimation(ctx.eid, 'attack');
return BTStatus.SUCCESS;
})
.wait(1.0) // Attack cooldown
.end()
// Chase if out of range
.action('chase', (ctx) => {
const targetEid = ctx.blackboard.get<number>('targetEid')!;
chaseEntity(ctx.eid, targetEid, ctx.delta);
return BTStatus.RUNNING;
})
.end()
.end()
// Priority 3: Search for intruders
.sequence()
.condition('isAlert', (ctx) => {
const alertLevel = ctx.blackboard.get<number>('alertLevel') ?? 0;
return alertLevel > 0;
})
.action('search', (ctx) => {
const foundTarget = scanForEnemies(ctx.eid);
if (foundTarget) {
ctx.blackboard.set('targetEid', foundTarget);
ctx.blackboard.set('alertLevel', 2);
return BTStatus.SUCCESS;
}
// Decrease alert over time
const alertLevel = ctx.blackboard.get<number>('alertLevel') ?? 0;
ctx.blackboard.set('alertLevel', Math.max(0, alertLevel - ctx.delta * 0.1));
return BTStatus.RUNNING;
})
.end()
// Priority 4: Patrol
.sequence()
.condition('hasPatrolRoute', (ctx) => {
return ctx.blackboard.has('patrolPoints');
})
.action('patrol', (ctx) => {
const points = ctx.blackboard.get<Float32Array[]>('patrolPoints')!;
const index = ctx.blackboard.get<number>('patrolIndex') ?? 0;
const targetPos = points[index];
const distance = moveTowards(ctx.eid, targetPos, ctx.delta);
if (distance < 0.5) {
const nextIndex = (index + 1) % points.length;
ctx.blackboard.set('patrolIndex', nextIndex);
return BTStatus.SUCCESS;
}
return BTStatus.RUNNING;
})
.wait(2.0) // Wait at waypoint
.end()
// Priority 5: Idle
.action('idle', (ctx) => {
playAnimation(ctx.eid, 'idle');
return BTStatus.SUCCESS;
})
.end()
.build();
});
// Usage
const guardEid = createGuardEntity(world);
assignBehaviorTree(guardEid, 'EnemyGuard');
// Set up patrol route
const blackboard = getBTBlackboard(guardEid);
if (blackboard) {
blackboard.set('patrolPoints', [
new Float32Array([0, 0, 0]),
new Float32Array([10, 0, 0]),
new Float32Array([10, 0, 10]),
new Float32Array([0, 0, 10]),
]);
blackboard.set('patrolIndex', 0);
blackboard.set('health', 100);
blackboard.set('alertLevel', 0);
}
// React to damage
function onGuardTakeDamage(eid: number, damage: number, attackerEid: number) {
const blackboard = getBTBlackboard(eid);
if (blackboard) {
const health = blackboard.get<number>('health') ?? 100;
blackboard.set('health', health - damage);
blackboard.set('wasHit', true);
blackboard.set('targetEid', attackerEid);
blackboard.set('alertLevel', 2);
}
}
Documentation | Web Engine