Navigation — navmesh + agents

Characters that find their own way: a navigation mesh (baked once from the level's static colliders) says where an agent can walk, and agents (NavAgent) walk it — pathfinding, steering and avoiding each other run natively (Recast/Detour); the game decides where everyone goes. A player steered by hand is a CharacterController (or an agent's move()).

  • make enemies walk to the player around walls
  • find a path between two points of a level
  • bake a navmesh from the level's colliders
  • keep a crowd of characters from bumping into each other
  • make characters avoid mud or prefer roads
  • pick a random reachable spot for a character to wander to
  • know when a character arrives or gets stuck

Platforms: iOS and Android (the full engine variant), desktop, the web player, the le.codes viewer app and headless tests; not on the web (Web Lite) — check the result of NavMesh.load (null = no navigation on this host). The bake runs the app in the desktop host.

Add navigation to a scene file: env.navmesh

A level that is a scene file needs one block and no code:

TypeScript
export default defineScene({
  env: {
    navmesh: {
      data: asset('../assets/nav/yard.navmesh'),
      agent: { radius: 0.4, height: 1.8, climb: 0.4, slope: 50 },   // the size the mesh is built for
    },
  },
  nodes: { /* … */ },
})

What is walkable is derived, not declared: every node with a Shape and Physics { motion: 'static' } — the surfaces the CharacterController collides with. Barrels and crates (dynamic) are not part of the mesh; decoration without a collider does not block anyone. Per-node overrides: nav: false (leave this body out), nav: 'unwalkable' (nobody stands on or crosses its footprint), navArea: 'road' (a named area — see below).

Bake the navmesh: lecodes navmesh bake

Once per layout change:

TypeScript
lecodes navmesh bake            # → assets/nav/yard.navmesh

The bake runs the app in the desktop host up to the point where the level is complete (the scene's env.navmesh, or a hand-written NavMesh.load), dumps the static collision triangles, and builds the mesh in the CLI (tiled, for the agent size in agent). The file is named after the scene file that declares navmesh: (--name overrides, --entry picks the file that builds the level); --obj also writes the polygons as a .obj for a look in any viewer. lecodes navmesh bake --help lists the Recast knobs (--cell, --cell-height, --tile, …).

Bake before the first run: an asset() whose file does not exist fails the compile. The bake writes a placeholder before it compiles and replaces it with the mesh; a placeholder (a bake that built nothing) loads as no navmesh — NavMesh.load resolves null with a warning and agents stand still.

Make a character walk to a target: NavAgent

An agent walks its node to a target around walls and the other agents: attach NavAgent, then goTo the point — an enemy chasing the player calls it again with the player's position.

TypeScript
const spawn = new Node()
spawn.position = [4, 0, -6]                      // place the node FIRST: the agent joins the crowd where it stands
const op = spawn.aspect(NavAgent, { speed: 3.2 })
scene.add(op)
op.agent.goTo(player.position)                   // false = nothing walkable near that point (the old target stays)
op.agent.on('arrive', () => console.log('there'))
op.agent.on('blocked', () => op.agent.goTo(nav.randomPoint(op.position, 8) ?? op.position))
Option Default
radius, height 0.4, 1.8 m — agent size (avoidance, corridor width). Not read from the mesh: match the bake's agent yourself
speed, acceleration 3, 8 m/s, m/s²
turnSpeed 540 deg/s the node turns toward its velocity; 0 = leave the rotation alone
avoidance 'medium' agent-vs-agent avoidance: 'off' | 'low' | 'medium' | 'high'
separation 2 separation force (0 = none)
drive 'position' 'position': the crowd places the node (no physics, every host). 'controller': the velocity feeds the node's CharacterController.move — the capsule collides with everything, the crowd follows the body
stopDistance 0.3 reached = within this many metres along the corridor
include / exclude all / none area names the agent may / may never walk

State: agent.state (idle | moving | arrived | blocked | offmesh), agent.velocity, agent.desiredVelocity, agent.remainingDistance (m along the corridor, −1 = no target), agent.target, agent.hasPath. Commands: goTo(p), stop() (drop the target and slow to a halt), move(vx, vz) (steer by velocity instead — a player on WASD: call it every fixed step; the crowd keeps the node on the mesh and out of the others' way; move(0, 0) stops; it cancels goTo), warp(p) (teleport; the target is kept). Every option is live — change it on agent at any time.

Note

a 'position'-driven agent's transform belongs to the crowd — a node.position write is overwritten on the next step (the node snaps back to its slot). Move it with warp(p), and set the node's position before attaching the aspect, which joins the crowd at the node's position.

Know when an agent arrives or gets stuck: arrive, blocked

Events: arrive and blocked (the target is unreachable — the agent stopped at the closest point, or stood still for 3 s on the way: a jam, a door that closed). A crowd never lets everyone reach the same point, so arrive also fires after a second spent within stopDistance + 8 × radius of the goal — a lone agent can report arrival short of the spot (measured: 1.4 m short at 3 m/s). If the exact spot matters, finish the approach yourself.

When agents move: the crowd's fixed step, NavCrowd

Agents tick in the fixed phase: the whole crowd advances once per step (NavCrowd, a system the mesh attaches to its scene — you never create it; order a system against it with static before = [NavCrowd]), then every agent applies its slot. A crowd of 50 costs one native update and one flat read per step. An agent attached before any mesh is loaded waits inert and joins once one is.

Find a path, the nearest walkable point or a random spot: NavMesh queries

TypeScript
const nav = NavMesh.of(scene)!           // or the result of NavMesh.load; NavMesh.active = the last loaded
const path = nav.findPath(a, b)          // Vec3[] corners; path.partial = b is unreachable
const p = nav.nearest(candidate)         // closest walkable point (or null)
const r = nav.raycast(a, b)              // { hit, point, normal } — a wall between a and b?
const spot = nav.randomPoint(center, 8)  // a reachable spot within 8 m (walking distance)
const any = nav.randomPoint()            // anywhere on the mesh
nav.debug = true                         // draw the polygons (a blue surface 3 cm up)

All synchronous and cheap enough to call every frame. Each takes { include?, exclude?, extents? } (findPath also maxCorners, up to 256). findPath returns an empty array when an endpoint is off the mesh (further than extents, 2 / 4 / 2 m by default); a partial path ends at the closest point it could reach. raycast from a point off the mesh reports a hit at its start. randomPoint is seeded: the same calls give the same points on every run. nav.info is the bake's header (stats, cell, cellHeight, …), nav.agent the size it was built for, nav.areas the area ids.

Roads, mud and no-go zones: navigation areas

Named areas price or forbid parts of the mesh: declare them with a cost, paint static bodies into them, and let queries / agents refer to them by name.

TypeScript
env: { navmesh: { data, areas: { road: 0.8, mud: 4 } } }      // ids are assigned in this order
nodes: { swamp: { model: MUD, navArea: 'mud', aspects: [use(Shape), use(Physics, { motion: 'static' })] } }

nav.findPath(a, b, { exclude: ['mud'] })
nav.cost('mud', 10)                                            // live
enemy.aspect(NavAgent, { exclude: ['mud'] })

Up to 15 areas; an unlisted body is plain ground (cost 1). Three things to know:

  • An area is ground of its own. The painted body must be the floor there: a patch laid on top of another static floor merges into it and stays plain ground (measured: a 4 cm road slab on a floor kept no road polygons, while a strip of its own came out as mud).
  • A cost steers the search; exclude is the rule. Measured on a 4 m mud strip with a 36 m way around: costs up to 10 still crossed it (16 m straight), 100 went round.
  • include lists every area allowed — plain ground has no name, so an include list shuts it out (a path that starts on plain ground comes back empty).
TypeScript
NavMesh.exclude(decorativeFence)    // before load: this static body does not shape the mesh
NavMesh.unwalkable(pit)             // its footprint is cut out
NavMesh.area(swamp, 'mud')          // (scene files: nav: false / 'unwalkable', navArea)

const main = async () => {
  // after the level's static bodies exist (the bake fires here under `lecodes navmesh bake`)
  const nav = await NavMesh.load(scene, { data: asset('./assets/nav/level.navmesh'), agent: { radius: 0.4 }, areas: { mud: 4 } })
  if (!nav) return                  // no navigation on this host, a placeholder, or the bake itself
  scene.add(new Node().aspect(NavAgent))
}
main()

NavMesh.load resolves null on a host without navigation (a warning; agents stay inert), for a placeholder file, and while the CLI bakes. NavMesh.baking is true while the CLI bakes — skip menus and build the level straight away. NavMesh.clear() forgets every override (a level rebuild). nav.dispose() releases the mesh (a scene teardown); agents fall back to inert. Top-level await does not compile in an app — see conventions.

A flow test (the headless renderer) sees the same paths and agent positions as --sim; Web Lite has no navigation.

  • One navmesh per scene, built for one agent size. A second size = a second bake.
  • Dynamic bodies are not in the mesh: a 'position'-driven agent walks through a barrel; give the agent a CharacterController (drive: 'controller') to collide. There is no runtime carving of obstacles.
  • Heights are quantized to the bake's cell height (--cell-height, 0.1 m): a 'position'-driven node can sit up to that much above the floor.
  • The SDK has no off-mesh links (jumps, ladders, doors) and no scene-editor overlay for the mesh.