Skip to content

Navigation

Navigation moves an agent to a world point along a route that goes round whatever is in the way. The route is planned over the Nav resource’s active surface, and there are two of them:

  • NavGrid — a rectangular mask of walkable and blocked cells. What a tilemap already is, and the one you can flip a cell of while the game runs.
  • NavMesh — convex polygons baked out of a scene’s own collision geometry. It follows sloped and stacked ground, which no single grid can.

Everything above them — the agent, the overlay, nav.findWorldPath — asks the same questions of either, so nothing in a game has to know which one it was handed.

import { defineSystem, Res } from 'esengine';
import { Nav, NavGrid } from 'esengine/ai';
export const setupNav = defineSystem([Res(Nav)], (nav) => {
nav.setSurface(new NavGrid({
width: 60, // columns
height: 44, // rows
cellSize: 20, // world pixels per cell
origin: { x: -600, y: -440 }, // world position of cell (0,0)'s center
}));
}, { name: 'SetupNav' });
NavGrid option Type Description
width number Cell columns.
height number Cell rows.
cellSize number World pixels per cell (square).
origin Vec3 World position of cell (0,0)’s center. Its z is the depth every waypoint comes back at. Defaults to (0,0,0).
walkable Uint8Array Optional row-major width*height mask, 1 = walkable, 0 = blocked. Omitted → all walkable.

To derive a grid from a painted tilemap instead of hand-authoring the mask, use navGridFromTilemapLayer (which cells a layer’s solid tiles block) or navGridFromTiles. grid.setWalkable(x, y, false) closes a cell at any time — a door shuts, a tower goes up — and the next plan routes around it.

A route over a grid is string-pulled before it is handed back: A* answers in steps of one cell, and only the cells where the route actually has to turn survive. A shortcut is taken only along a line the search itself could have walked — every cell it touches fits the body, and a diagonal needs both of its shared orthogonals — so a smoothed route is exactly as legal as the staircase it replaced, and the same shape of route a mesh gives.

A 3D scene has geometry rather than a tile mask, so its surface is baked from the bodies it already collides against. Put a NavVolume on an entity and the nav plugin fills the box it describes:

NavVolume field Default Description
halfExtents 1000, 500, 1000 Half the box to bake, in world pixels; the entity’s Transform is its centre.
cellSize 50 Voxel size in the ground plane. Smaller hugs the geometry more closely and bakes more slowly.
cellHeight 10 Voxel size vertically — what decides whether a balcony and the floor under it are two levels or one.
maxSlopeDegrees 45 Steepest ground an agent can stand on. Anything steeper is a wall.
agentHeight 180 Headroom an agent needs. Ground with less is a crawlspace, not a corridor.
agentRadius 30 How wide the agent is. The mesh is pulled back from every wall by this.
stepHeight 40 How high a step the agent can climb rather than walk round.
layers 0 Physics layers the ground is taken from; 0 = every layer.

The bake voxelises the collision triangles in the box, keeps the surfaces an agent of that size could stand on, and turns what is left into convex polygons. Every column of the world keeps all of its floors, so a bridge and the road under it are two places and a route can use either.

Each volume is baked once, on the first frame its geometry is available — a mesh collider contributes nothing until its asset has loaded, so the bake waits for it. buildNavMesh(verts, indices, options) is callable directly if a game wants to bake on its own terms, and collectNavGeometry(world, box) is what gathers the triangles.

A mesh is baked from what an agent can WALK, so two floors with a gap between them are two places — which is the honest answer and not the whole one. A scene knows things the floor does not: that this ledge can be dropped off, that this ladder is climbable, that this plank reaches the roof. NavLink is where it says them.

NavLink field Default Description
start 0, 0, 0 Where the way starts, as an offset in the entity’s own frame.
end 0, 0, 150 Where it comes out.
bidirectional true Whether it can be taken both ways. A drop off a ledge cannot.
radius 50 How far from each end ground may be and still be joined.
enabled true Whether the way exists right now.

Both ends are offsets in the entity’s frame, so a link travels and turns with whatever carries it — a ladder inside a prefab is a ladder wherever the prefab is put down. A link whose end reaches no ground joins nothing at all: a route that ended in the air would be worse than no route.

Moving or switching a link costs a lookup, not a bake — it joins polygons that already exist. That is the difference between it and an obstacle, and the reason one is cheap to animate and the other is not.

The mesh answers where an agent CAN walk. NavArea is where a scene says what it would rather walk on:

NavArea field Default Description
halfExtents 200, 100, 200 Half the box, in world pixels; the Transform is its centre. It turns with the entity.
cost 3 What a unit of distance inside it costs, against 1 for open ground.
enabled true Whether the price applies right now.

Under 1 is preferred and over 1 is avoided — a road at half price is worth going out of the way for, a swamp at four times is walked round if there is any way round. It never blocks: an agent with nowhere else to go wades through, which is the difference between a swamp and a fence.

Moving or resizing a patch rebuilds the navigable world, the way an obstacle does. Changing its cost does not: the mesh carries which patch a polygon belongs to, and what that patch costs is read fresh on every search.

Anything with a collider already blocks, because the mesh is baked from the scene’s collision geometry. NavObstacle is for what blocks without being geometry, or what stops blocking without moving:

NavObstacle field Default Description
halfExtents 50, 100, 50 Half the blocking box, in world pixels; the Transform is its centre. Unlike the volume, it turns with the entity.
enabled true Whether it blocks right now. A door that opens is this field.

Blocking is a bake input, not a filter over the answer: an obstacle takes its ground away before the walkable area is eroded, so routes keep the agent’s full width clear of it rather than scraping along its face. Changing one — moving it, resizing it, switching it on or off — rebuilds the navigable world, at most a few times a second.

// A door swinging open, from any system with world access:
const door = world.get(entity, NavObstacle);
door.enabled = false;
world.set(entity, NavObstacle, door);

Both surfaces answer to it: a mesh is baked again, a grid has the cells under the box marked. The grid’s own walkability is kept separate, so a door closing and opening gives back exactly the ground that was there.

The volume’s wireframe says where the bake looked. To see what it found while you are still authoring, turn on View → Show Navigation Mesh (or the Navigation button on the viewport toolbar): the editor bakes the scene’s volume and draws the walkable faces where they are, with the edges they stop at picked out. It rebakes as the scene changes, so widening agentRadius pulls the mesh back from the walls in front of you.

The same picture from inside a running game comes from the NavDebugDraw resource:

app.getResource(NavDebugDraw).enabled = true;

Every walkable face is outlined where it actually is — at the height of the ground for a mesh, in the scene’s own plane for a grid — and every edge the walkable world stops at is marked in red. Those edges are the only thing that explains a route going the long way round. Off by default (a surface is thousands of loops a frame), and it stops after a few thousand faces: cellSize is the knob for seeing more of a big world.

Attach a NavAgent, then point it at a destination. The built-in nav plugin plans the path and steps the agent along it each frame — you just set the goal.

import { NavAgent, setNavDestination, stopNavAgent } from 'esengine/ai';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(NavAgent, { speed: 140, arriveRadius: 8 });
// From any system/action with world access — safe to call every frame:
setNavDestination(world, entity, { x: 320, y: -120 });
// …and to halt in place:
stopNavAgent(world, entity);
NavAgent field Default Description
speed 120 Movement speed in world pixels per second.
radius 0 How wide the body is, in pixels. On a grid, planning routes it around anything it would not fit through; on a mesh this is the volume’s agentRadius instead.
arriveRadius 6 Stop distance from the final goal.
repathInterval 0.5 Seconds between replans while moving; 0 = replan only when the target changes.
hasTarget false Whether a destination is set (managed for you).
targetX, targetY, targetZ 0 Current destination in world pixels.
arrived false Set true the frame the agent reaches its goal.

setNavDestination is safe to call every frame to chase a moving target — the agent only replans when the target actually moves or repathInterval elapses. Read agent.arrived (or Perception) to know when to switch behavior.

An agent whose entity also carries a CharacterController3D is steered rather than moved: the plugin writes the controller’s horizontal velocity toward the next waypoint and lets the character’s own solver do the walking, so the agent collides with the world, steps up what it can and falls where it should. The vertical axis is never written — that one belongs to the world, which is what makes a route down a step a fall rather than a glide.

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 200, z: 0 } })
.insert(CharacterController3D, { radius: 30, halfHeight: 50 })
.insert(NavAgent, { speed: 400, arriveRadius: 90 });

Distances are measured in the ground plane for such an agent, because a character stands with its capsule’s centre above the floor the route was planned on. Set the volume’s agentRadius to the controller’s radius (and agentHeight to roughly twice halfHeight plus the radius) so the mesh is the set of places that body fits.

Without a controller the agent’s Transform is moved along the path directly, which is what every flat game wants and what a 3D one gets before it has a body.

A route is planned against a world that does not move. Agents do, and a dozen of them sent to one place all plan the same route and walk it as one body. An agent whose radius is greater than zero declares that it IS a body, and from then on it steers round the other bodies walking the same ground:

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(NavAgent, { speed: 300, radius: 40, arriveRadius: 60 });

Everyone assumes everyone else is doing the same, so each takes half the avoidance and two meeting head-on part rather than both dodging the same way and meeting again. Where that is not enough to break the tie — a perfect mirror — the convention is keep right.

A step aside is checked against the navigable world before it is taken, so steering never walks an agent into a wall. Two bodies too wide to pass in one corridor therefore do not pass: waiting is the honest answer, and going through the wall is not.

An agent with radius left at 0 is routed as a point, gives way to nobody and is given way to by nobody — which is what every flat game had before this, and what a scripted mover still wants.

On a grid, set radius to the body’s actual half-width and planning keeps it out of gaps it cannot fit through. Left at 0 an agent is routed as a point, which is what you want for something that has no collider — and what produces the classic sight of an enemy walking confidently into a doorway and stopping there, because the cell it was routed to is walkable and the half of it hanging over the next one is not.

On a mesh the same thing is settled at bake time by agentRadius, which is why a route over one is already clear of the walls with nothing asked of the planner.

A goal the body cannot stand on (a pickup in a corner) is still reached either way: the plan ends as close as the body fits, and arriveRadius covers the rest.