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.
import { BTSelector, BTCondition, BTAction, BTStatus } from '@web-engine-dev/core/engine/ai'; // Try options in priority order: attack, flee, or patrolconst 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.
import { BTSequence } from '@web-engine-dev/core/engine/ai'; // All steps must succeedconst 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.
import { BTParallel, BTParallelPolicy } from '@web-engine-dev/core/engine/ai'; // Move AND play animation at the same timeconst 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.
| Decorator | Description | Use Case |
|---|---|---|
| Inverter | Inverts SUCCESS to FAILURE and vice versa | NOT conditions, reverse logic |
| Repeater | Repeats child N times or infinitely | Attack combos, continuous actions |
| Timeout | Fails child if it takes too long | Limit search duration, prevent stuck states |
| Succeeder | Always returns SUCCESS | Optional behaviors that shouldn't fail parent |
| UntilFail | Repeats until child fails | Keep doing action until condition changes |
| Cooldown | Prevents execution within cooldown period | Ability cooldowns, rate limiting |
| RateLimiter | Limits success frequency | Event throttling, spawn limiting |
Decorator Examples#
import { BTInverter, BTRepeater, BTTimeout, BTCooldown, BTSucceeder,} from '@web-engine-dev/core/engine/ai'; // Inverter: NOT at targetconst notAtTarget = new BTInverter( new BTCondition('atTarget', (ctx) => isAtTarget(ctx.eid))); // Repeater: Attack 3 timesconst tripleAttack = new BTRepeater( new BTAction('attack', attackAction), 3, // limit true // abortOnFailure); // Timeout: Give up searching after 10 secondsconst timedSearch = new BTTimeout( new BTAction('searchForTarget', searchAction), 10.0); // Cooldown: Can only use ability every 2 secondsconst specialAbility = new BTCooldown( new BTAction('useSpecialAbility', abilityAction), 2.0); // Succeeder: Play animation but don't fail if already playingconst optionalAnimation = new BTSucceeder( new BTAction('playAnimation', playAnimAction)); // Combined: Timeout + Repeaterconst 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.
import { BTCondition } from '@web-engine-dev/core/engine/ai'; // Simple boolean checkconst hasTarget = new BTCondition('hasTarget', (ctx) => { return ctx.blackboard.has('targetEid');}); // Numeric comparisonconst isHealthLow = new BTCondition('isHealthLow', (ctx) => { const health = ctx.blackboard.get<number>('health') ?? 100; return health < 30;}); // Distance checkconst 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 logicconst 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.
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 callbackconst 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.
import { BTBlackboard } from '@web-engine-dev/core/engine/ai'; // Create blackboardconst blackboard = new BTBlackboard(); // Store different types of datablackboard.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 safetyconst health = blackboard.get<number>('health') ?? 100;const targetEid = blackboard.get<number>('targetEid');const lastSeenPos = blackboard.get<Float32Array>('lastSeenPosition'); // Check existenceif (blackboard.has('targetEid')) { // Target exists} // Delete datablackboard.delete('targetEid'); // Clear all datablackboard.clear(); // Use in conditionsconst hasAmmo = new BTCondition('hasAmmo', (ctx) => { const ammo = ctx.blackboard.get<number>('ammo') ?? 0; return ammo > 0;}); // Use in actionsconst 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#
import { BTSelector, BTSequence, BTCondition, BTAction, BTStatus,} from '@web-engine-dev/core/engine/ai'; // Build tree manuallyconst 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.
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 with waypoint cyclingconst 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 target with line of sight checkconst 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#
// Complete attack sequence with cooldownconst 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 decoratorconst attackWithCooldown = new BTCooldown(attackTree, 1.0);NavMesh Integration#
Combine behavior trees with NavMesh pathfinding for intelligent navigation.
import { NavMeshPath } from '@web-engine-dev/core/engine/ai'; // Pathfinding action that uses NavMeshconst 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 pathfindingconst 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#
import { AIAgent, initAIAgent, registerBehaviorTree, assignBehaviorTree, AIBehaviorTreeSystem,} from '@web-engine-dev/core/engine/ai';import { addComponent } from 'bitecs'; // 1. Register your behavior treeregisterBehaviorTree('EnemyAI', () => { return new BTSelector([ // Your tree definition combatBehavior, patrolBehavior, idleBehavior, ]);}); // 2. Create entity with AI componentconst enemyEid = addEntity(world);addComponent(world, AIAgent, enemyEid);addComponent(world, Transform, enemyEid);addComponent(world, Velocity, enemyEid); // 3. Initialize AI agentinitAIAgent(enemyEid); // 4. Assign behavior treeassignBehaviorTree(enemyEid, 'EnemyAI'); // 5. Configure agentAIAgent.enabled[enemyEid] = 1;AIAgent.updateInterval[enemyEid] = 0.1; // Update every 100msAIAgent.priority[enemyEid] = 10; // 6. Add to game loopfunction gameLoop(delta: number, time: number) { // Run behavior tree system AIBehaviorTreeSystem(world, delta, time);}Accessing Blackboard from ECS#
import { getBTBlackboard, setBlackboardValue } from '@web-engine-dev/core/engine/ai'; // Get entity's blackboardconst 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 eventsfunction 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 systemsfunction 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#
import { getBTStatus, getBTInstance } from '@web-engine-dev/core/engine/ai'; // Check current statusconst status = getBTStatus(enemyEid);console.log(`BT Status: ${status}`); // SUCCESS, FAILURE, or RUNNING // Get full instance for debuggingconst 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#
// Wrap actions with loggingfunction 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 loggingfunction 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 treeconst 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#
import { serializeBTNode } from '@web-engine-dev/core/engine/ai'; // Serialize tree to JSON for visualizationfunction visualizeTree(tree: BTNode): void { const json = serializeBTNode(tree); console.log('Tree Structure:', JSON.stringify(json, null, 2));} // Print tree structurefunction 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 itprintTree(enemyAITree);// Output:// Selector// Sequence// Condition: hasTarget// Action: attack// Sequence// Condition: hasPatrolPoints// Action: patrol// Action: idlePerformance 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#
// Throttle based on distance from camerafunction 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 systemAIAgent.updateInterval[nearbyEnemy] = 0.0; // Full rateAIAgent.updateInterval[mediumEnemy] = 0.1; // 10 HzAIAgent.updateInterval[distantEnemy] = 0.5; // 2 HzAIAgent.updateInterval[veryFarEnemy] = 1.0; // 1 HzTree Optimization#
// BAD: Deep nesting and redundant checksconst 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 checksconst 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 patternsconst 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#
import { BTBuilder, BTStatus, registerBehaviorTree, assignBehaviorTree, AIBehaviorTreeSystem,} from '@web-engine-dev/core/engine/ai'; // Register complete enemy AI behavior treeregisterBehaviorTree('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();}); // Usageconst guardEid = createGuardEntity(world);assignBehaviorTree(guardEid, 'EnemyGuard'); // Set up patrol routeconst 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 damagefunction 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); }}