A* Pathfinding in Phaser 3: Enemy AI That Chases You


A* Pathfinding in Phaser 3: Enemy AI That Chases You

A* (pronounced “A-star”) is a pathfinding algorithm that scores grid cells by f = g + h — the cost from the start plus an estimated distance to the goal — so an enemy can route around walls instead of walking into them. This guide walks you through building it yourself and wiring it into Phaser 3 (Red Blob Games’ A* introduction).

Transparency note: this tutorial is desk research against official Phaser documentation and the canonical A* references — it is not a hands-on tested build, and no demo was shipped or playtested for this post. The good news: the A* code below is plain, framework-agnostic JavaScript, so you can paste it into any grid game, Phaser or otherwise.

Why Naive Chase AI Fails on a Grid

A naive chase steers the enemy along a straight line toward the player and ignores geometry, so any wall or corner breaks it. The enemy slides along the obstacle, jittering, never reaching you. That works for continuous steering in open arenas, but on a tile grid you need a planner, not a magnet.

The distinction matters. Continuous steering treats movement as smooth vectors; grid movement treats the world as discrete cells the enemy hops between. Red Blob Games covers this well in its map representation guide. Also worth internalizing: Phaser’s Arcade Physics handles collision — stopping your enemy from walking through walls — but it never plans a route around them. Planning is your job, and A* is the standard tool for it.

Turning a Tile Grid into a Graph

A grid becomes a graph when each walkable tile is a node and each open neighboring tile is an edge. A* doesn’t care about sprites or tilemaps — it only sees nodes and edges — so the first step is representing your level as something the algorithm can traverse. That’s simpler than it sounds.

Start with a 2D array where 0 means walkable and 1 means wall. Neighbors are found by bounds-checking the four adjacent cells (up, down, left, right). You can allow 8-directional movement (adding diagonals), but this tutorial sticks to 4-directional movement because it keeps the heuristic simple — more on that in the tuning section. For bookkeeping, JavaScript’s Map stores scores per node and Set tracks visited nodes. In production you’d read walkability from Phaser’s Tilemap class, but a plain array keeps the tutorial self-contained.

BFS, Dijkstra, Greedy Best-First, and A*

All four algorithms grow a frontier outward from the start cell, and they differ only in which cell they expand next. Once you understand that single shared loop, you can implement any of them — and A* simply makes the smartest choice about what to expand next.

  • Breadth-First Search (BFS) expands uniformly in all directions, like ripples in water. It’s simple and finds a path, but wastes time exploring cells that clearly lead away from the goal.
  • Dijkstra’s algorithm tracks cost_so_far for each cell and always expands the cheapest cell using a priority queue. It finds optimal paths, but still explores in all directions when step costs are equal.
  • Greedy Best-First always expands the cell that looks closest to the goal. It’s fast, but happily walks into dead-end traps because it ignores the cost already paid.
  • A* combines both: it prioritizes cells by f = g + h, the cost paid so far plus the estimated cost remaining. Red Blob calls it “the best of both worlds.”

One crucial guarantee, faithfully paraphrased from Red Blob’s introduction: as long as the heuristic does not overestimate distances, A* finds an optimal path, just like Dijkstra’s algorithm does. A heuristic that never overestimates is called admissible.

The A* Loop: g, h, f, and the Priority Queue

The A* loop repeatedly pulls the lowest-f node from the open set, expands its neighbors, and stops the moment it pulls the goal node. Everything else is bookkeeping around that loop. Here’s what each piece means:

  • g — the actual cost from the start to this node (cost so far).
  • h — the heuristic: an estimate of the cost from this node to the goal.
  • f = g + h — the priority used to decide which node to expand next.
  • came_from — a map of “which node did I come from?” used to rebuild the path at the end.
  • closed set — nodes already expanded, so you never process them twice.
  • priority queue (open set) — the frontier, always handing back the lowest-f node.

Red Blob’s implementation notes are the definitive reference for these structures; the code below follows the same shape.

A Complete A* Implementation in JavaScript

The implementation below is plain JavaScript with no Phaser dependency, so you can paste it into any grid game — Phaser 3, Phaser 4, or no engine at all. It’s split into four blocks: grid helpers, the open set, the main loop, and the path-rebuilding utilities.

Block 1 — grid helpers. 0 is walkable, 1 is a wall:

function makeGrid(rows) {
  return rows; // e.g. [[0,0,0,1,0],[0,1,0,1,0],[0,0,0,0,0]]
}

function isWalkable(grid, x, y) {
  return grid[y] && grid[y][x] === 0;
}

function neighbors4(grid, x, y) {
  const deltas = [[0,-1],[0,1],[-1,0],[1,0]];
  const result = [];
  for (const [dx, dy] of deltas) {
    const nx = x + dx, ny = y + dy;
    if (isWalkable(grid, nx, ny)) result.push({ x: nx, y: ny });
  }
  return result;
}

Block 2 — the open set. For small grids, a plain array sorted by f works fine as a priority queue:

// openSet: array of nodes, fScore: Map from node key to f value
// Push a newly discovered neighbor onto the frontier:
openSet.push(neighbor);
// Keep the lowest-f node at the front:
openSet.sort((a, b) => fScore.get(key(a)) - fScore.get(key(b)));
// Pop it when expanding:
const current = openSet.shift();

This array-as-priority-queue approach is fine for small grids; for large maps, upgrade to a binary heap.

Block 3 — the main loop:

function key(node) {
  return node.x + ',' + node.y;
}

function aStar(start, goal, grid) {
  const gScore = new Map();
  const fScore = new Map();
  const cameFrom = new Map();
  const closedSet = new Set();
  const openSet = [];

  gScore.set(key(start), 0);
  fScore.set(key(start), heuristic(start, goal));
  openSet.push(start);

  while (openSet.length > 0) {
    openSet.sort((a, b) => fScore.get(key(a)) - fScore.get(key(b)));
    const current = openSet.shift();

    if (current.x === goal.x && current.y === goal.y) {
      return reconstructPath(cameFrom, current);
    }

    closedSet.add(key(current));

    for (const neighbor of neighbors4(grid, current.x, current.y)) {
      if (closedSet.has(key(neighbor))) continue;
      const tentativeG = gScore.get(key(current)) + 1;
      if (tentativeG < (gScore.get(key(neighbor)) ?? Infinity)) {
        cameFrom.set(key(neighbor), current);
        gScore.set(key(neighbor), tentativeG);
        fScore.set(key(neighbor), tentativeG + heuristic(neighbor, goal));
        if (!openSet.some(n => n.x === neighbor.x && n.y === neighbor.y)) {
          openSet.push(neighbor);
        }
      }
    }
  }
  return null; // no path found
}

Note that nodes are compared by coordinates via the key() helper, never by object identity — otherwise Map and Set lookups silently fail when the same tile is represented by different objects.

Block 4 — heuristic and path reconstruction:

function manhattan(a, b) {
  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}

const heuristic = manhattan;

function reconstructPath(cameFrom, current) {
  const path = [current];
  while (cameFrom.has(key(current))) {
    current = cameFrom.get(key(current));
    path.unshift(current);
  }
  return path;
}

Wiring A* into a Phaser 3 Scene

With a path array in hand, you draw the tile grid in a Scene, move the enemy between tile centers with linear interpolation, and request a fresh path whenever the player changes tiles. The A* function itself never changes — only the Scene code around it does.

If you’re starting from scratch, configure the game with mode: Phaser.Scale.FIT and autoCenter: Phaser.Scale.CENTER_BOTH so the canvas scales cleanly (Scale Manager). Game logic lives in a Scene’s create() and update() methods, and you can visualize the path with a Graphics game object.

Version honesty, stated plainly: the current stable release is Phaser v4.2.1 “Giedi”, released 9 July 2026 (stable download), while this site’s existing playable demos load Phaser 3.90.0 from jsDelivr. The A* code is framework-agnostic either way — the Scene wiring below targets Phaser 3-era APIs like the ones our demos use.

const TILE = 32;
const GRID = makeGrid([
  [0,0,0,1,0,0,0,0],
  [0,1,0,1,0,1,1,0],
  [0,1,0,0,0,0,1,0],
  [0,0,0,1,1,0,0,0],
  [1,1,0,0,0,0,1,0],
  [0,0,0,1,0,0,0,0],
]);

class ChaseScene extends Phaser.Scene {
  constructor() { super('chase'); }

  create() {
    // Draw the grid
    const gfx = this.add.graphics();
    for (let y = 0; y < GRID.length; y++) {
      for (let x = 0; x < GRID[y].length; x++) {
        gfx.fillStyle(GRID[y][x] === 1 ? 0x4444aa : 0x222244, 1);
        gfx.fillRect(x * TILE, y * TILE, TILE - 2, TILE - 2);
      }
    }

    this.playerTile = { x: 7, y: 0 };
    this.enemyTile = { x: 0, y: 0 };
    this.cursors = this.input.keyboard.createCursorKeys();
    this.path = aStar(this.enemyTile, this.playerTile, GRID) || [];
    this.pathIndex = 0;

    this.player = this.add.circle(
      this.playerTile.x * TILE + TILE / 2,
      this.playerTile.y * TILE + TILE / 2, 10, 0x00ff88);
    this.enemy = this.add.circle(
      this.enemyTile.x * TILE + TILE / 2,
      this.enemyTile.y * TILE + TILE / 2, 10, 0xff4466);

    // Re-path every 500 ms
    this.time.addEvent({
      delay: 500,
      loop: true,
      callback: () => {
        this.path = aStar(this.enemyTile, this.playerTile, GRID) || [];
        this.pathIndex = 0;
      }
    });
  }

  update(time, delta) {
    // Move the player one tile per arrow-key press; the timer below re-paths the chase
    const p = this.playerTile;
    if (Phaser.Input.Keyboard.JustDown(this.cursors.left)  && isWalkable(GRID, p.x - 1, p.y)) p.x--;
    if (Phaser.Input.Keyboard.JustDown(this.cursors.right) && isWalkable(GRID, p.x + 1, p.y)) p.x++;
    if (Phaser.Input.Keyboard.JustDown(this.cursors.up)    && isWalkable(GRID, p.x, p.y - 1)) p.y--;
    if (Phaser.Input.Keyboard.JustDown(this.cursors.down)  && isWalkable(GRID, p.x, p.y + 1)) p.y++;
    this.player.setPosition(p.x * TILE + TILE / 2, p.y * TILE + TILE / 2);

    // Move enemy toward the next tile center with a simple lerp
    if (this.pathIndex < this.path.length - 1) {
      const next = this.path[this.pathIndex + 1];
      const targetX = next.x * TILE + TILE / 2;
      const targetY = next.y * TILE + TILE / 2;
      const t = Math.min(1, delta / 200);
      this.enemy.x = Phaser.Math.Linear(this.enemy.x, targetX, t);
      this.enemy.y = Phaser.Math.Linear(this.enemy.y, targetY, t);
      if (Math.abs(this.enemy.x - targetX) < 2 &&
          Math.abs(this.enemy.y - targetY) < 2) {
        this.enemyTile = { x: next.x, y: next.y };
        this.pathIndex++;
      }
    }
  }
}

new Phaser.Game({
  type: Phaser.AUTO,
  width: GRID[0].length * TILE,
  height: GRID.length * TILE,
  scale: {
    mode: Phaser.Scale.FIT,
    autoCenter: Phaser.Scale.CENTER_BOTH
  },
  scene: ChaseScene
});

Practical Tuning: Heuristics and Smoothing

The two cheapest wins are using an admissible heuristic that matches your movement model and smoothing the stair-step paths A* returns. Both take minutes to implement and make the difference between robotic and convincing enemy movement (Red Blob’s implementation notes).

Heuristic choice. On a 4-directional grid, Manhattan distance — Math.abs(x1 - x2) + Math.abs(y1 - y2) — is admissible, meaning it never overestimates the true remaining cost. If you add diagonal movement, Manhattan overestimates diagonal costs and can break optimality; you must either stay 4-directional or switch to a diagonal-aware heuristic such as Euclidean distance via Math.hypot.

Weighted terrain. Want swamps that slow the enemy? Give each tile a step cost instead of the flat + 1, and Dijkstra-style cost_so_far handles the rest. A* naturally favors cheap routes.

Tie-breaking. On grids where every step costs the same, many paths tie in total cost, and A* can return ugly staircase routes. Breaking ties — for example, preferring the node with the lower h — or post-processing the path produces straighter-looking movement.

Smoothing. The simplest fix: after A* returns a tile path, check whether the enemy can skip ahead two or more tiles in a straight unobstructed line, and drop the intermediate waypoints.

Performance Tips for Bigger Maps

On large grids, shrinking the graph matters more than micro-optimizing the search, because A*’s cost grows with the number of locations it expands. Red Blob puts it directly: “The best thing to do is to eliminate unnecessary locations in your graph.” Fewer nodes means every algorithm — A* included — finishes faster.

Three concrete levers, in order of impact:

  1. Shrink the graph. Don’t model tiles the enemy can never reach. Merge open rooms into larger walkable regions, or run pathfinding on a coarser grid than the one you render.
  2. Exit early. The implementation above already returns the moment it pulls the goal — keep it that way. Also cap the search with a maximum expansions limit if the goal might be unreachable.
  3. Throttle re-pathing. Don’t recompute every frame. Re-path every few hundred milliseconds, or only when the player’s tile changes — the time.addEvent timer in the Scene wiring does exactly this.

When to Reach for easystar.js

easystar.js is an asynchronous A* pathfinding API written in JavaScript for HTML5 games, and it sidesteps implementing the priority queue and heuristic tuning yourself. If your grid is large, your movement model is complicated, or you simply don’t want to maintain pathfinding code, it’s a solid choice.

The library was popular enough that Phaser’s official news feed once featured an Easystar + Phaser 3 tutorial, with a full written version on gamedevjs. One honest caveat: that tutorial targets Phaser 3.0-era APIs from 2018, not current Phaser — the Easystar grid-and-movement idea still holds, but don’t copy its Phaser API calls verbatim. Use a library when pathfinding becomes a core, performance-sensitive system; hand-roll it (as above) while you’re learning.

FAQ

A* raises the same handful of beginner questions about optimality, Phaser versions, and whether a grid is required. Here are direct answers to the three that come up most often.

Does A* always find the shortest path?

Yes — as long as your heuristic never overestimates the true remaining distance, A* finds an optimal path, just like Dijkstra’s algorithm (Red Blob’s introduction). If the heuristic overestimates (for example, Manhattan distance on a diagonal-moving grid), the path may be suboptimal, though usually still good.

Can I run this code with Phaser 4?

The A* algorithm itself is plain JavaScript and works in anything. The Scene wiring, however, uses Phaser 3-era APIs — the same version (3.90.0) this site’s demos load from jsDelivr. Phaser 4’s Scene and game-object APIs are broadly similar, but verify the specific calls against the current docs before porting.

Do I need a graph for every game?

No. A tile grid is just one way to represent your level as a graph — Red Blob’s grids and graphs guide covers alternatives like waypoints and navigation meshes. Small or open-map games often get away with simple steering; grids shine when your level is already tile-based.