AI & NPCs
The @web-engine-dev/ai and @web-engine-dev/pathfinding packages provide a complete NPC intelligence toolkit: A* and flow-field pathfinding, behavior trees, GOAP planners, utility AI, and steering behaviors.
Pathfinding
NavMesh Setup
typescript
import {
NavMeshGenerator,
NavMesh,
NavMeshResource,
NavMeshAgent,
} from '@web-engine-dev/pathfinding';
// Build a navmesh from your level geometry (OOP class)
const generator = new NavMeshGenerator();
const navMesh: NavMesh = generator.generate({
vertices: levelGeometryVertices,
indices: levelGeometryIndices,
agentHeight: 1.8,
agentRadius: 0.4,
maxSlope: 45,
cellSize: 0.25, // NavMesh voxel resolution
});
// Store as an ECS resource so systems can access it
world.insertResource(NavMeshResource, navMesh);
// Attach an agent component to an entity
world.insert(enemyEntity, NavMeshAgent, {
speed: 3.5, // m/s
acceleration: 8,
stoppingDistance: 1.0, // stop when within this distance of the target
avoidanceRadius: 0.5, // agent collision avoidance
avoidancePriority: 50, // higher priority pushes lower priority agents
});Moving an Agent to a Target
typescript
import { AStarPathfinder, NavMeshResource, NavMeshAgent } from '@web-engine-dev/pathfinding';
// Pathfinder operates on the NavMesh resource
const navMesh = world.getResource(NavMeshResource);
const pathfinder = new AStarPathfinder(navMesh);
// Find a path to a position
const path = pathfinder.findPath(world.get(enemyEntity, Position), { x: 200, y: 0, z: 150 });
// Store path on the agent component for the movement system to follow
if (path.length > 0) {
world.insert(enemyEntity, NavMeshAgent, {
...world.get(enemyEntity, NavMeshAgent),
currentPath: path,
pathIndex: 0,
});
}
// Stop (cleared path)
world.insert(enemyEntity, NavMeshAgent, {
...world.get(enemyEntity, NavMeshAgent),
currentPath: [],
});
// Check if we've arrived
const agent = world.get(enemyEntity, NavMeshAgent);
const arrived = agent.remainingDistance <= agent.stoppingDistance;Flow Fields (Optimal for Many Agents)
When 100+ agents need to navigate to the same destination, flow fields are dramatically faster than individual A* queries:
typescript
import { FlowFieldPathfinder, NavigationGrid } from '@web-engine-dev/pathfinding';
// FlowFieldPathfinder is an OOP class
const grid = new NavigationGrid({ width: 100, height: 100, cellSize: 32 });
grid.markObstacles(levelObstacleList);
const flowField = new FlowFieldPathfinder(grid);
// Compute a flow field targeting the player (store the result)
const field = flowField.compute(playerPosition);
// All enemies sample the same flow field
function EnemyMovementSystem(world: World): void {
const posQuery = world.query().with(Position, Enemy).build();
for (const {
entity,
components: [pos],
} of world.run(posQuery)) {
const direction = field.sample(pos);
world.insert(entity, Velocity, {
x: direction.x * ENEMY_SPEED,
y: direction.y * ENEMY_SPEED,
});
}
}Behavior Trees
typescript
import {
BehaviorTree,
Sequence,
Selector,
Parallel,
Condition,
Action,
Wait,
Repeat,
} from '@web-engine-dev/ai';
// Define a Patrol → Chase → Attack behavior tree
const enemyBT = new BehaviorTree('EnemyGuard', {
root: new Selector([
// When player is in attack range: attack
new Sequence([
new Condition(
'PlayerInAttackRange',
(ctx) => ctx.get('distanceToPlayer') < ctx.agent.attackRange
),
new Action('FacePlayer', (ctx) => {
ctx.faceTarget(ctx.get('playerPos'));
return 'success';
}),
new Action('Attack', (ctx) => {
ctx.emit(AttackEvent, { target: ctx.get('playerEntity'), damage: ctx.agent.attackDamage });
return 'success';
}),
new Wait('AttackCooldown', (ctx) => ctx.agent.attackCooldown),
]),
// When player is detected: chase
new Sequence([
new Condition(
'PlayerDetected',
(ctx) => ctx.get('distanceToPlayer') < ctx.agent.detectionRadius
),
new Action('SetChaseTarget', (ctx) => {
ctx.set('chaseTarget', ctx.get('playerPos'));
return 'success';
}),
new Action('MoveToChaseTarget', (ctx) => {
ctx.pathfinding.moveTo(ctx.entity, ctx.get('chaseTarget'));
return ctx.pathfinding.hasArrived(ctx.entity) ? 'success' : 'running';
}),
]),
// Default: patrol between waypoints
new Action('Patrol', (ctx) => {
const waypoints = ctx.get('patrolWaypoints') as Vec3[];
const idx = (ctx.get('patrolIndex') as number) ?? 0;
ctx.pathfinding.moveTo(ctx.entity, waypoints[idx]);
if (ctx.pathfinding.hasArrived(ctx.entity)) {
ctx.set('patrolIndex', (idx + 1) % waypoints.length);
}
return 'running';
}),
]),
});
// Attach to an enemy entity
world.insert(enemyEntity, BehaviorTreeInstance, {
tree: enemyBT,
blackboard: {
patrolWaypoints: [
{ x: 100, y: 0, z: 200 },
{ x: 300, y: 0, z: 200 },
{ x: 300, y: 0, z: 50 },
],
attackRange: 2.0,
detectionRadius: 12.0,
attackDamage: 25,
attackCooldown: 1.5,
},
});Goal-Oriented Action Planning (GOAP)
GOAP lets NPCs figure out the steps needed to achieve a goal:
typescript
import { GOAPAgent, defineGoal, defineAction } from '@web-engine-dev/ai';
// Actions available to the NPC
const actions = [
defineAction('Attack', {
preconditions: { hasWeapon: true, targetInRange: true },
effects: { targetDead: true },
cost: 1,
execute: (ctx) => { ctx.emit(AttackEvent, ...); },
}),
defineAction('PickupWeapon', {
preconditions: { weaponNearby: true },
effects: { hasWeapon: true },
cost: 2,
execute: (ctx) => { ctx.pathfinding.moveTo(ctx.entity, ctx.get('nearestWeaponPos')); },
}),
defineAction('FleeFromTarget', {
preconditions: { targetInSight: true, health: (h) => h < 0.3 },
effects: { targetInSight: false },
cost: 1,
execute: (ctx) => { ctx.pathfinding.flee(ctx.entity, ctx.get('playerPos'), 200); },
}),
defineAction('HealSelf', {
preconditions: { hasPotion: true, health: (h) => h < 0.5 },
effects: { health: 1.0 },
cost: 2,
execute: (ctx) => { ctx.emit(HealEvent, { target: ctx.entity, amount: 50 }); },
}),
];
world.insert(smartEnemyEntity, GOAPAgent, {
actions,
goal: defineGoal({ targetDead: true }),
worldStateProvider: (ctx) => ({
hasWeapon: ctx.hasComponent(ctx.entity, WeaponEquipped),
targetInRange: ctx.get('distanceToPlayer') < ctx.agent.meleeRange,
weaponNearby: ctx.get('nearestWeaponDistance') < 50,
health: ctx.get('healthNormalized'),
}),
});Steering Behaviors
Low-level steering for smooth movement:
typescript
import { SteeringAgent } from '@web-engine-dev/ai';
world.insert(enemyEntity, SteeringAgent, {
maxSpeed: 150,
maxForce: 400,
behaviors: [
{ type: 'seek', weight: 1.0, target: 'playerEntity' },
{ type: 'separation', weight: 2.0, radius: 40 }, // avoid crowding
{ type: 'avoidance', weight: 3.0, obstacleRadius: 30 },
{ type: 'wander', weight: 0.5, radius: 50, distance: 80 },
],
});Finite State Machine (NPC States)
For simple NPCs, a plain FSM is often sufficient and easier to debug:
typescript
import { StateMachine, State } from '@web-engine-dev/state';
const guardFSM = new StateMachine('Guard', {
initial: 'idle',
states: {
idle: new State({
onEnter: (ctx) => ctx.animation.play('idle'),
onUpdate: (ctx) => {
if (ctx.get('distanceToPlayer') < ctx.agent.alertRadius) {
ctx.fsm.transition('alert');
}
},
}),
alert: new State({
onEnter: (ctx) => {
ctx.animation.play('alert');
ctx.emit(AlertNearbyGuardsEvent, { alerter: ctx.entity });
ctx.runAfter(2.0, () => {
if (ctx.get('distanceToPlayer') > ctx.agent.alertRadius) {
ctx.fsm.transition('patrol');
} else {
ctx.fsm.transition('chase');
}
});
},
}),
chase: new State({
onEnter: (ctx) => ctx.animation.play('run'),
onUpdate: (ctx) => {
ctx.pathfinding.follow(ctx.entity, ctx.get('playerEntity'));
if (ctx.get('distanceToPlayer') <= ctx.agent.attackRange) {
ctx.fsm.transition('attack');
}
},
}),
attack: new State({
onEnter: (ctx) => ctx.animation.playOneShot('attack'),
onUpdate: (ctx) => {
if (!ctx.animation.isPlaying('attack')) {
ctx.fsm.transition(
ctx.get('distanceToPlayer') <= ctx.agent.attackRange ? 'attack' : 'chase'
);
}
},
}),
patrol: new State({
onEnter: (ctx) => ctx.animation.play('walk'),
onUpdate: (ctx) => {
// Patrol between waypoints
const waypoints = ctx.get('patrolWaypoints');
ctx.pathfinding.moveTo(ctx.entity, waypoints[ctx.get('patrolIdx')]);
if (ctx.pathfinding.hasArrived(ctx.entity)) {
ctx.set('patrolIdx', (ctx.get('patrolIdx') + 1) % waypoints.length);
}
if (ctx.get('distanceToPlayer') < ctx.agent.alertRadius) {
ctx.fsm.transition('alert');
}
},
}),
},
});
world.insert(guardEntity, StateMachineInstance, { fsm: guardFSM });NPC Senses
typescript
import { PerceptionSystem, SightSensor, HearingSensor } from '@web-engine-dev/ai';
world.addSystem(PerceptionSystem);
// Vision cone sensor
world.insert(enemyEntity, SightSensor, {
range: 200,
angle: 120, // degrees: field of view
height: 2.0, // 3D: vertical FOV
occluded: true, // blocked by walls (uses raycasts)
layers: ['player', 'vehicles'], // what to detect
});
// Hearing sensor
world.insert(enemyEntity, HearingSensor, {
range: 150,
detectLayers: ['player-footsteps', 'gunshots'],
});
// Read perception results
function EnemyPerceptionSystem(world: World): void {
const sightQuery = world.query().with(SightSensor, Enemy).build();
for (const {
entity,
components: [sight],
} of world.run(sightQuery)) {
const detectedPlayer = sight.detected.find((e) => world.has(e, Player));
if (detectedPlayer) {
world.insertIntoBlackboard(entity, 'playerEntity', detectedPlayer);
world.insertIntoBlackboard(entity, 'lastKnownPlayerPos', world.get(detectedPlayer, Position));
}
}
}