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.
Building a grid
Section titled “Building a grid”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 mesh for a 3D scene
Section titled “A mesh for a 3D scene”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.
Ways the ground does not provide
Section titled “Ways the ground does not provide”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.
Ground that costs something
Section titled “Ground that costs something”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.
Doors, gates and things placed in the way
Section titled “Doors, gates and things placed in the way”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.
Seeing what was baked
Section titled “Seeing what was baked”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.
Moving an agent
Section titled “Moving an agent”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.
Agents with a body
Section titled “Agents with a body”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.
Agents give way to each other
Section titled “Agents give way to each other”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.
Agents have width
Section titled “Agents have width”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.