Make enemies chase around obstacles
Goal
FPS enemies route around crates instead of walking straight into them. They keep their character bodies, attack damage and cooldown, with a behaviour tree choosing idle, chase or attack each fixed step.
Files you will edit
src/systems/enemyAI.tssrc/game.ts
Steps
In
src/systems/enemyAI.ts, keep the mode constants,aiMode,aiCooldown,MAX_ENTITIES,ENEMIES,AI_STATE_NAMESand the centre imports. Replace the SDK import with this public import:tsimport { bt, BtStatus, character, createBehaviorTree, createCrowd, createNavGrid, Enemy, faceToward, Health, query, Transform, type Crowd, type GameContext, } from 'gameable';Add this storage and replace
resetEnemyAIwith the version below. Bounds must cover the level's colliders and spawn points; enlarge these bounds if you change the arena. Build after the host has loaded the static colliders.buildFromPhysics()casts one downward ray per cell, one ray batch per grid row against static geometry. It belongs in init, never in a system.tsconst nav = createNavGrid({ min: [-20, -20], max: [20, 20], cellSize: 0.5, agentRadius: 0.4, maxClimb: 0.4, }); let crowd: Crowd; const nextTarget = new Float64Array(MAX_ENTITIES); const velocity = { x: 0, z: 0 }; const facing = { x: 0, z: 0 }; const hitSound = { entity: 0, volume: 0.5 }; /** * Build navigation and register the enemies after spawning in init. * * @param ctx The game context. * @returns Nothing. */ export function resetEnemyAI(ctx: GameContext): void { aiMode.fill(0); aiCooldown.fill(0); nextTarget.fill(0); // No floor under the grid means no static colliders: a headless test's mock host has no // physics. Walk the open plane then, so the same code runs in tests and in the game. if (nav.buildFromPhysics() === 0) nav.buildFlat(0); crowd = createCrowd(nav, { maxAgents: MAX_ENTITIES }); // A high maxAccel keeps the template's snap to full speed; lower it for a run-up. const options = { maxSpeed: Number(ctx.rules.enemySpeed ?? 2.2), maxAccel: 200 }; const entities = query(ctx.world, ENEMIES); for (let i = 0; i < entities.length; i += 1) { const e = entities[i]; tree.reset(e); crowd.add(e, options); crowd.setPosition(e, Transform.x[e], Transform.z[e]); } }Crowd construction and registration happen once, after spawning. The radius should cover the enemy capsule;
move: 'character'keeps movement going throughmoveCharacterwhen applied in step 3. All objects used by the fixed-step code are reused.Add the decision tree and actions at module scope in the same file. The priority is idle when the player is dead or outside sight, attack within reach, otherwise chase. The sequence guards recheck every tick.
ts/** * @param ctx The frame context. * @param e The enemy. * @returns Ground-plane distance to the player. */ function distanceToPlayer(ctx: GameContext, e: number): number { const dx = Transform.x[ctx.player] - Transform.x[e]; const dz = Transform.z[ctx.player] - Transform.z[e]; return Math.sqrt(dx * dx + dz * dz); } /** * @param _ctx The frame context. * @param e The enemy. * @returns Running while this branch is selected. */ function idle(_ctx: GameContext, e: number): BtStatus { aiMode[e] = AI_IDLE; crowd.stop(e); return BtStatus.Running; } /** * @param ctx The frame context. * @param e The enemy. * @returns Running while this branch is selected. */ function chase(ctx: GameContext, e: number): BtStatus { aiMode[e] = AI_CHASE; if (ctx.elapsed >= nextTarget[e]) { // At least half a second between target requests, even across mode changes. nextTarget[e] = ctx.elapsed + 0.5; crowd.moveTo(e, Transform.x[ctx.player], Transform.z[ctx.player]); } return BtStatus.Running; } /** * @param ctx The frame context. * @param e The enemy. * @returns Running while this branch is selected. */ function attack(ctx: GameContext, e: number): BtStatus { aiMode[e] = AI_ATTACK; crowd.stop(e); facing.x = Transform.x[ctx.player]; facing.z = Transform.z[ctx.player]; faceToward(e, facing); if (aiCooldown[e] > 0) return BtStatus.Running; const dy = Transform.y[ctx.player] + PLAYER_CENTRE - (Transform.y[e] + ENEMY_CENTRE); if (Math.abs(dy) > 2) return BtStatus.Running; aiCooldown[e] = Number(ctx.rules.enemyAttackInterval ?? 1.4); Health.current[ctx.player] = Math.max( 0, Health.current[ctx.player] - Number(ctx.rules.enemyDamage ?? 6), ); hitSound.entity = ctx.player; ctx.audio.play('sfx.hit', hitSound); return BtStatus.Running; } const tree = createBehaviorTree<GameContext>( bt.selector<GameContext>( bt.sequence<GameContext>( bt.condition( (ctx, e) => Health.current[ctx.player] <= 0 || distanceToPlayer(ctx, e) > Number(ctx.rules.enemySight ?? 14), ), bt.action(idle), ), bt.sequence<GameContext>( bt.condition((ctx, e) => distanceToPlayer(ctx, e) <= Number(ctx.rules.enemyReach ?? 1.5)), bt.action(attack), ), bt.action(chase), ), { maxEntities: MAX_ENTITIES }, );The attack keeps the existing cooldown, height check, damage and hit sound.
faceTowardreplaces the old hand-written yaw calculation. A failed path search retries on the next half-second deadline, without allocating a path array in the system.Replace
enemyAIin the same file. Synchronise crowd positions before requesting paths, tick the tree, then step the crowd once. Apply its output yourself so each living enemy gets exactly onemoveCharactercommand, and animation receives that same velocity.ts/** * Run decisions, steering, movement and animation for one fixed step. * * @param ctx The frame context. * @returns Nothing. */ export function enemyAI(ctx: GameContext): void { if (ctx.player === 0) return; const entities = query(ctx.world, ENEMIES); for (let i = 0; i < entities.length; i += 1) { const e = entities[i]; if (Health.current[e] <= 0) { crowd.remove(e); tree.reset(e); ctx.despawn(e); continue; } crowd.setPosition(e, Transform.x[e], Transform.z[e]); if (aiCooldown[e] > 0) aiCooldown[e] -= ctx.dt; tree.tick(ctx, e, ctx.dt); } crowd.step(ctx.dt); for (let i = 0; i < entities.length; i += 1) { const e = entities[i]; if (!crowd.has(e)) continue; crowd.velocity(e, velocity); if (aiMode[e] !== AI_CHASE) { // stop() decelerates internally; brake the applied velocity immediately. velocity.x = 0; velocity.z = 0; } else { // A chasing enemy keeps its eyes on the player while it walks round the crates. faceToward(e, ctx.player); } ctx.physics.moveCharacter(e, velocity.x, 0, velocity.z); character.setState(e, AI_STATE_NAMES[aiMode[e]], velocity.x, 0, velocity.z, true); } }Remove the old hand-written decision branches and straight-line chase.
moveToasks for a new route to the player at most twice a second per chasing enemy;crowd.stepsteers every fixed step and replans a blocked route by itself. Do not also callcrowd.update(ctx): it would step again and send a second movement command. Idle and attacking enemies stay in the crowd, so chasers still walk round them, but their applied velocity is zeroed for the template's immediate stop. Register enemies spawned later the same way, and reset their tree state and targeting deadline before they tick.In
src/game.ts, remove the oldresetEnemyAI()call near the start of init. Insert this call immediately after the existing enemy spawn loop:tsresetEnemyAI(ctx);Keep
enemyAIin its existing position insystems. The reset now sees all spawned enemies and builds the grid once per game initialization. Search for otherresetEnemyAI()callers and pass their initialized game context too; a no-argument call no longer compiles. The current FPS headless test boots throughgame.initand has no separate reset call.
Verify
From the FPS template's game directory:
sh
npm test
npm run devThe template's headless tests pass unchanged. Their mock host has no physics, so buildFromPhysics() returns 0 and the grid falls back to the open plane: the enemies chase in straight lines there, at the same speed and facing the player. In the game the level's colliders exist before init, so the grid is built from them.
In the browser, click to capture the pointer, put a crate between you and an enemy, and stay within sight range. Enemies should route around the crates instead of sticking to them, keep facing you, stop in attack range and deal the same damage on the same cooldown. Jumping more than two metres above their attack centre should still prevent hits.
See also
- Navigation and AI
- Add an enemy
- ECS and game code
packages/sdk/README.md— gameable