Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
Create complex AI behaviors using behavior trees for decision making and action execution with a powerful node-based system.
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.
Composite nodes (Selector, Sequence, Parallel), Decorators (Inverter, Repeater, Timeout), and Leaf nodes (Action, Condition).
Shared memory for communication between nodes with typed access to agent state and environment data.
Nodes can return RUNNING to span multiple frames, properly resuming execution where they left off.
Zero-GC design with object pooling, throttling support, and efficient tree execution.
Composite nodes have multiple children and control their execution order and logic.
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 rangenew 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 attackperformAttack(ctx.eid);return BTStatus.SUCCESS;}),]),// Option 2: Flee if health is lownew 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 patrolnew BTAction('patrol', (ctx) => {patrolBehavior(ctx.eid);return BTStatus.RUNNING;}),]);
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 prerequisitesnew BTCondition('hasAmmo', (ctx) => {const ammo = ctx.blackboard.get<number>('ammo') ?? 0;return ammo > 0;}),// Step 2: Aim at targetnew 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 weaponnew BTAction('fireWeapon', (ctx) => {fireWeapon(ctx.eid);return BTStatus.SUCCESS;}),// Step 4: Wait for cooldownnew BTWait(0.5),]);
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 succeedBTParallelPolicy.REQUIRE_ONE, // Fail when ANY child fails[// Action 1: Move to targetnew 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 animationnew BTAction('playWalkAnim', (ctx) => {playAnimation(ctx.eid, 'walk');return BTStatus.SUCCESS;}),// Action 3: Look at targetnew BTAction('lookAtTarget', (ctx) => {const targetPos = ctx.blackboard.get<Float32Array>('targetPosition');if (targetPos) {lookAt(ctx.eid, targetPos);}return BTStatus.SUCCESS;}),]);
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 |
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, // limittrue // 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 perform the actual work: checking conditions or executing actions.
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;});
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 targetconst 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 logicconst foundTarget = scanForEnemies(ctx.eid);if (foundTarget) {ctx.blackboard.set('targetEid', foundTarget);return BTStatus.SUCCESS;}return BTStatus.RUNNING;},() => {// Cleanup when resetconsole.log('Search cancelled');});
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;});
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 ammonew 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 rangenew 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 visiblenew 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 targetnew BTAction('searchForTarget', (ctx) => {const foundTarget = scanForEnemies(ctx.eid);if (foundTarget) {ctx.blackboard.set('targetEid', foundTarget);return BTStatus.SUCCESS;}return BTStatus.RUNNING;}),// Priority 5: Idlenew BTAction('idle', (ctx) => {playAnimation(ctx.eid, 'idle');return BTStatus.SUCCESS;}),]);
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();
// Patrol with waypoint cyclingconst patrolTree = new BTSequence([// Ensure we have patrol pointsnew BTCondition('hasPatrolPoints', (ctx) => {return ctx.blackboard.has('patrolPoints');}),// Move to current waypointnew 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 nextconst nextIndex = (currentIndex + 1) % patrolPoints.length;ctx.blackboard.set('patrolIndex', nextIndex);return BTStatus.SUCCESS;}return BTStatus.RUNNING;}),// Wait at waypointnew BTWait(2.0),// Always succeed to loopnew BTAction('continuePatrol', () => BTStatus.SUCCESS),]);
// Chase target with line of sight checkconst chaseTree = new BTSequence([// Check we have a targetnew BTCondition('hasTarget', (ctx) => {return ctx.blackboard.has('targetEid');}),// Check line of sightnew BTCondition('hasLineOfSight', (ctx) => {const targetEid = ctx.blackboard.get<number>('targetEid')!;return checkLineOfSight(ctx.eid, targetEid);}),// Chase using steering behaviorsnew BTAction('pursue', (ctx) => {const targetEid = ctx.blackboard.get<number>('targetEid')!;// Get target position and velocityconst targetPos = Transform.position[targetEid];const targetVel = Velocity.linear[targetEid];// Use pursue steering behaviorconst 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 upconst distance = calculateDistance(ctx.eid, targetEid);return distance < 2.0 ? BTStatus.SUCCESS : BTStatus.RUNNING;}),]);
// Complete attack sequence with cooldownconst attackTree = new BTSequence([// Prerequisitesnew 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 animationnew BTAction('windUp', (ctx) => {playAnimation(ctx.eid, 'attack_windup');return BTStatus.SUCCESS;}),// Wait for wind-upnew BTWait(0.3),// Execute attacknew BTAction('executeAttack', (ctx) => {const targetEid = ctx.blackboard.get<number>('targetEid')!;// Deal damagedealDamage(targetEid, 10);// Consume ammoconst ammo = ctx.blackboard.get<number>('ammo') ?? 0;ctx.blackboard.set('ammo', ammo - 1);// Play attack animationplayAnimation(ctx.eid, 'attack');playSound(ctx.eid, 'sword_slash');return BTStatus.SUCCESS;}),// Recovery/cooldownnew BTWait(0.5),]);// Wrap with cooldown decoratorconst attackWithCooldown = new BTCooldown(attackTree, 1.0);
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 pathlet path = ctx.blackboard.get<NavMeshPath>('currentPath');if (!path) {// Request new pathconst 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 pathconst pathIndex = ctx.blackboard.get<number>('pathIndex') ?? 0;if (pathIndex >= path.length) {// Path completectx.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 nextctx.blackboard.set('pathIndex', pathIndex + 1);}return BTStatus.RUNNING;});// Complete AI with NavMesh pathfindingconst aiTree = new BTSelector([// Combat behaviornew 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 targetnew 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 NavMeshnew 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;}),]),]);
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 definitioncombatBehavior,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 systemAIBehaviorTreeSystem(world, delta, time);}
import { getBTBlackboard, setBlackboardValue } from '@web-engine-dev/core/engine/ai';// Get entity's blackboardconst blackboard = getBTBlackboard(enemyEid);if (blackboard) {// Set initial datablackboard.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);}}
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 blackboardconst health = instance.runner.blackboard.get<number>('health');console.log('Health:', health);}
// 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;}),]);
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: idle
Performance Tips
// 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 Hz
// 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 possiblenew 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 firstnew BTCondition('isDisabled', (ctx) => AIAgent.enabled[ctx.eid] === 0),// Then more expensive checksnew BTSequence([new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')),new BTCondition('hasLineOfSight', expensiveLineOfSightCheck),new BTAction('combat', combatAction),]),]);
Design Guidelines
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 flagreturn 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 timeconst 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);}}