Skip to content

Pathfinding

The @yagejs/pathfinding package provides a grid graph with A* search. Coordinates in and out are world pixels. GridGraph takes a plain isWalkable(col, row) predicate, so it works on a code-drawn level with no tile grid at all — fill the predicate from raw wall data, from a @yagejs/physics world, or from a @yagejs/tilemap map.

import { GridGraph } from "@yagejs/pathfinding";
const cols = 20;
const rows = 15;
const walls = new Uint8Array(cols * rows); // 1 = blocked, filled by your game
const grid = new GridGraph({
cols,
rows,
tileWidth: 32,
tileHeight: 32,
isWalkable: (col, row) => walls[row * cols + col] === 0,
});
const path = grid.findPath({ x: 48, y: 48 }, { x: 600, y: 400 });
if (path) {
for (const wp of path.waypoints) {
// wp is a Vec2 in world pixels (tile centre).
}
}

isWalkable is called on every findPath call, never cached — flip a bit in walls (open a door, block a passage) and the next path reflects it, no rebuild step needed.

The constructor checks its numbers and throws naming the offending one: cols and rows must be whole counts of cells of at least 1, tileWidth/tileHeight must be finite and above 0, and an origin must be finite. Each of those values indexes cells or divides world pixels, so a bad one would otherwise make every findPath return null with no explanation.

gridFromTilemap lives behind a ./tilemap subpath so a grid-only consumer of the root @yagejs/pathfinding entry never pulls in @yagejs/tilemap.

import { Transform } from "@yagejs/core";
import { gridFromTilemap } from "@yagejs/pathfinding/tilemap";
// tilemap is a TilemapComponent; tilemap.data is its parsed TilemapData.
const grid = gridFromTilemap(tilemap.data, {
layers: ["collision"], // required: which tile layers block
blocked: (gid) => gid !== 0, // default: any non-empty tile blocks
origin: tilemap.entity.get(Transform).position, // map placement offset
});
const path = grid.findPath(agent.position, target.position);

layers chooses which tile layers are read, and is required — reading every layer would block each cell any layer paints, so a map with a filled ground layer would come out impassable. Every name must match a layer in the map; a name that matches nothing throws, because a typo would otherwise drop that layer’s walls and still return a grid.

A cell is blocked if any read layer’s cell satisfies blocked. The adapter precomputes a walkability + cost pass over the grid once, so the returned GridGraph doesn’t re-scan tilemap data on every search.

Some maps author walkability as Tiled objects (rects, ellipses, capsules, polygons) instead of, or alongside, tile layers. gridFromColliders builds a grid from those shapes:

import { gridFromColliders } from "@yagejs/pathfinding/tilemap";
const grid = gridFromColliders(tilemap.data, {
shapes: tilemap.getCollisionShapes("pathfinding"), // an object layer
origin: tilemap.entity.get(Transform).position,
});

There are two ways to feed it shapes:

  • A dedicated nav layer (recommended). Walkability and collision aren’t the same thing — shallow water or a low fence might block movement without blocking a projectile, or vice versa. Draw a separate object layer (e.g. "pathfinding") with only the shapes that should affect the grid, and pass its name to getCollisionShapes.
  • Point it at the collision layer directly. When walkability and collision genuinely coincide, reuse the same layer you already pass to your physics setup — tilemap.getCollisionShapes("collision") works for either.

A cell blocks if any shape overlaps any part of it — a shape only grazing a cell’s edge still blocks it. The overlap test is exact per shape (a rotated rect uses its true bounding rectangle, a polygon its real, possibly concave outline), not a bounding-box approximation, so a concave shape’s notch stays walkable. A shape drawn with Tiled’s Polygon tool is a filled region — its interior blocks, concave or not. A Polyline-tool shape is a thin wall: only the cells its segments cross block. Cost is 1 everywhere; there’s no per-shape cost option.

A code-drawn level — sprites placed by hand, obstacles given @yagejs/physics colliders, no tile grid anywhere — has no tilemap to read walkability from. GridGraph doesn’t need one: build the isWalkable predicate yourself with PhysicsWorld.queryShape, one query per cell, when the level loads:

import { GridGraph } from "@yagejs/pathfinding";
import { PhysicsWorldKey } from "@yagejs/physics";
const cols = 40, rows = 30, cell = 32;
const world = this.use(PhysicsWorldKey); // in a Scene's onEnter, or a Component
const blocked = new Set<number>();
const grid = new GridGraph({
cols, rows, tileWidth: cell, tileHeight: cell,
isWalkable: (col, row) => !blocked.has(row * cols + col),
});
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const centre = grid.cellToWorld(col, row);
if (world.queryShape({ type: "box", width: cell, height: cell }, centre).length > 0) {
blocked.add(row * cols + col);
}
}
}

The loop can run right after the level’s colliders are spawned — the query sees them without waiting for a physics step. Trigger zones (sensor: true) do not block; pass sensors: "include" to queryShape if they should.

For a 40×30 grid that’s 1200 shape queries — run the loop once at level build, not per frame. The result is a snapshot: colliders that move or spawn later don’t update blocked on their own. After a layout change you want reflected in paths, call blocked.clear() and re-run the loop — the loop only adds cells, so without the clear a removed wall stays blocked.

Both live on the same options object as isWalkable:

const grid = new GridGraph({
cols,
rows,
tileWidth,
tileHeight,
isWalkable,
cost: (col, row) => terrain[row * cols + col], // 1 = normal, 3 = mud, ...
diagonalMovement: "no-corner-cutting", // "never" | "always" | "no-corner-cutting"
heuristic: "octile", // omit to auto-pick: octile with diagonals, else manhattan
});
  • cost is a per-cell multiplier on the step entering that cell (default 1). A diagonal step costs Math.SQRT2 times the destination cell’s cost; an orthogonal step costs 1 times it. It must return a finite number — NaN (a lookup table missing an entry) or Infinity makes the cell unreachable without marking it blocked, so findPath throws instead. Costs must be >= 1 for the search to guarantee the shortest path — a lower cost still returns a path, just not necessarily the cheapest one.
  • diagonalMovement — "never" restricts movement to 4 directions. "always" allows all 8, including cutting a diagonal past a blocked wall corner. "no-corner-cutting" (the default) allows a diagonal step only when both orthogonal cells it would cut past are walkable.
  • heuristic — "manhattan", "chebyshev", "octile", or "euclidean". Left unset, it picks "octile" when diagonals are allowed and "manhattan" otherwise — the tightest admissible choice for each policy. All four are admissible for 4-connected movement; with diagonals enabled, manhattan overestimates and trades path optimality for speed.
interface Path {
waypoints: Vec2[]; // tile centres, world pixels, start cell through goal cell
cells: GridCell[]; // { col, row }, parallel to waypoints
cost: number;
}

findPath returns null when the start or goal cell is out of bounds, or when the goal cell isn’t walkable. The start cell may be blocked — an agent can straddle a blocked edge — only the goal must be walkable. When start and goal share a cell, it returns a one-waypoint path with cost: 0.

Path smoothing, async or time-sliced search for large grids, nearest-walkable goal snapping, endpoint snapping to the exact start/goal position, per-object cost, agent-radius inflation (a body wider than one cell can be routed through a one-cell gap), and waypoint/navmesh graphs or flow fields.