Skip to content

Navigation and AI ​

Navigation and decisions run inside the guest, through gameable. The same code runs unchanged in direct mode, the QuickJS wasm component and on the room server; there is no browser-only navigation service.

A grid over the floor ​

createNavGrid creates a 2.5D heightfield over XZ, with Y up: each cell has one floor height and a walkable bit. Choose bounds and cellSize for the level, and agentRadius for the bodies that must fit through its passages.

Call buildFromPhysics() in init after static colliders are available. It casts one downward ray per cell, in one physics.raycastBatch per grid row, against static geometry by default. height: [minY, maxY] bounds the probe. maxSlope rejects steep surfaces; maxClimb limits height differences between connected neighbours. Erosion shrinks walkable space by agentRadius, so paths leave clearance for a body rather than just its centre. Do not rebuild per frame.

It returns how many cells an agent can stand on. 0 means it found no floor: the level has no static colliders, or a headless test's mock host has no physics. The level's colliders are built before init, so a game can fall back to the open plane only for that case: if (nav.buildFromPhysics() === 0) nav.buildFlat(0);

buildFlat(y) instead makes every cell walkable at one height. Use it for 2D, tests or box-built levels, then mark walls and crates with addObstacle. It does not discover colliders for you.

Paths and shared goals ​

Use findPath for one agent travelling to one goal. It runs eight-connected A* without cutting corners, then straightens the path through visible cells. The caller supplies a Float32Array; the result is a point count, with x, y, z triples starting at the start and ending at the goal. Blocked endpoints snap to nearby walkable cells. A result of zero means there is no path.

For many agents sharing one goal, create one flow field in init and have them follow it. Tower-defense creeps heading for the same base can share one field:

ts
import { createCrowd, createNavGrid } from 'gameable';

const nav = createNavGrid({ min: [0, 0], max: [30, 30] });
nav.buildFlat(0);
const field = nav.createFlowField();
field.setGoal(28, 15);
const crowd = createCrowd(nav, { maxAgents: 128, move: 'character' });
crowd.add(1); // Use an existing character entity id in a game.
crowd.setPosition(1, 2, 15);
crowd.follow(1, field);

field.direction(x, z, out) writes a direction into a reused NavPoint and returns false at the goal or where it cannot be reached. field.distance returns walking distance, or Infinity when unreachable.

Obstacles that change ​

nav.addObstacle(x, z, halfX, halfZ) blocks a rectangle inflated by the agent radius, returning an id (-1 if obstacle capacity is exhausted). nav.removeObstacle(id) unblocks it; overlapping rectangles remain blocked until all obstacles covering them are removed.

To place a tower, tentatively add its rectangle, then call nav.hasPath(spawnX, spawnZ, baseX, baseZ). If it returns false, remove the obstacle and refuse the placement. Check every required entrance before accepting. Endpoints snap to walkable cells, so also use isWalkable to prevent placing a tower over a spawn or base. Only spawn the tower after these checks.

A flow field notices edits by itself: after an obstacle is added or removed it re-integrates towards its goal on its next query, so its cost lands in that frame. Agents following it that had no way through pick it up again when a way reopens. Individual moveTo paths replan when their next corner is blocked.

Crowd steering ​

createCrowd(nav, options) reserves agent storage once. Register entities with add, seed their XZ positions with setPosition before the first moveTo, and remove them when despawning. Each agent follows an individual moveTo path or a shared field selected by follow. Time-to-collision avoidance anticipates other agents, overlap separation pushes them apart, and wall sliding keeps steps out of blocked cells. This is local steering, not full ORCA or formations.

Call crowd.update(ctx) once per fixed step: it reads Transform, simulates, and applies velocities. move: 'character' uses physics.moveCharacter for Jolt character bodies; move: 'velocity' writes Velocity for entities moved by guest integration. Moving agents face their travel direction by default. An agent reaches full speed in maxSpeed / maxAccel seconds (default 3.5 / 20, under 0.2 s); raise maxAccel for a snap to speed. stop clears a goal and decelerates; it is not an instant brake.

crowd.step(dt) only simulates. Use it when managing position input and movement output yourself through setPosition, position and velocity; each query writes a caller-owned object. Do not call both step and update in one tick.

Behaviour trees ​

Build bt descriptions and compile them with createBehaviorTree once. Tick the tree per entity with tree.tick(ctx, entity, ctx.dt); timers and execution memory live in preallocated arrays per entity. Reset an entity with tree.reset(entity) before reusing its id.

A selector tries children in priority order until one succeeds or is running. A sequence runs children until one fails or is running. Both re-evaluate from the start every tick: a guard can interrupt a running chase when the player gets within attack range or dies. Put high-priority decisions first.

sequenceWithMemory resumes at its running child, skipping previously successful children. Use it for ordered work whose completed steps should stay completed; put interruptible guards outside that memory sequence. bt.wait(seconds) tracks time separately per entity and returns running until its duration passes. Interrupted branch memory resets on re-entry, and completed nodes reset their own memory. condition returns a boolean; action returns BtStatus.Success, Failure or Running. Keep callbacks allocation-free.

Costs and limits ​

field.setGoal runs a whole-grid search. Call it when the goal moves, never per frame. findPath also searches: throttle moving-target updates, for example to twice a second per enemy, and stagger enemies so they do not all search in the same frame. Allocate grids, fields, crowds, trees and query buffers outside the fixed-step loop.

Guest code runs in a QuickJS interpreter, many times slower than a browser's JavaScript engine. Measured in quickjs-emscripten (a stand-in for the guest's QuickJS; the real component was not measured) on one laptop, 2026-10-23:

WorkTime
Place a tower: add, hasPath, setGoal (60×40 cells)16 ms mean
Crowd step, 40 agents on a flow field3.4 ms
findPath across 80×80 cells with 30 crates8 ms mean, 29 worst
Behaviour tree, 200 entity ticks2.2 ms

A frame is 16.7 ms, so a tower placement costs about one frame once, and paths are the thing to ration.

The supported scale is up to 512×512 cells (262,144 total). There is only one floor per cell: bridges over walkways need another representation. There are no off-mesh links for jumps, ladders or teleports, and no area costs for mud or roads. Choose another level layout when those features are required.

See also ​