Skip to content

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

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));
    }
  }
}

Next Steps

  • Physics, agent collision and obstacle avoidance
  • Animation, blending NPC locomotion cycles
  • Scripting, custom NPC logic as scripts

Proprietary software. All rights reserved.