A* combines Dijkstra's guaranteed-optimal search with a heuristic that steers it toward the goal. The result: optimal paths found orders of magnitude faster than brute-force on grids, maps and game worlds. Here is how it works — and how to implement it from scratch.
Given a graph G = (V, E) where each edge (u, v) has a non-negative cost w(u,v), find the minimum-cost path from a start node s to a goal node g.
For game grids the graph is implicit: cells are nodes, orthogonal/diagonal neighbours are edges with cost 1 (or √2 for diagonals). Obstacles are simply absent edges. The same A* code works on explicit graphs (road networks, metro maps) by providing a neighbour list instead of a grid lookup.
Finds shortest path in terms of hops on unweighted graphs. O(V+E). Explores all directions equally.
Optimal on weighted graphs. Explores in order of accumulated cost g from start. O((V+E) log V) with a heap.
Expands node with smallest heuristic h. Very fast but not optimal — ignores already-paid cost.
Expands by f = g + h. Optimal and efficient. Dijkstra when h=0; best-first when g=0.
A* assigns each node n a priority score:
The algorithm maintains two sets:
At each step A* pops the node with the lowest f from the open set, expands its neighbours, and for each neighbour computes a candidate g = g(current) + w(current, neighbour). If this is better than any previously known path to that neighbour, update and enqueue it.
cameFrom pointers is the
optimal (lowest-cost) path —
provided the heuristic is admissible (never overestimates the
true cost).
S = Start (0,0), G = Goal (4,4), walls in grey. Numbers in cells show the f-score. Blue = closed (already expanded), green = open (in queue), orange = final path.
A heuristic h(n) is admissible if h(n) ≤ h*(n) for all n, where h*(n) is the true optimal cost from n to the goal. Admissibility guarantees A* finds the optimal path. A consistent (monotone) heuristic also satisfies h(n) ≤ w(n,n') + h(n') for every edge — this means the f-value never decreases along a path, simplifying the closed-set logic.
h = |Δx| + |Δy|
Admissible on 4-directional grids (no diagonals). The most common choice for game maps. Fast to compute.
h = √(Δx² + Δy²)
Admissible for any-angle movement. Slightly over-informs on 4-dir grids (Euclidean < Manhattan).
h = max(|Δx|, |Δy|)
For 8-directional movement (diagonals cost 1). Admissible and consistent for equal-cost 8-dir grids.
h = max(|Δx|,|Δy|) + (√2−1)·min(|Δx|,|Δy|)
Exact for 8-dir with diagonals costing √2. Tightest admissible h for that case.
The following implementation works on a flat array grid where
grid[y * width + x] is true for walkable
cells. It returns the optimal path as an array of
{x, y} objects, or null if no path exists.
// MinHeap — simple binary heap keyed on .f
class MinHeap {
constructor() { this.data = []; }
push(node) {
this.data.push(node);
this._bubbleUp(this.data.length - 1);
}
pop() {
const top = this.data[0];
const last = this.data.pop();
if (this.data.length) { this.data[0] = last; this._siftDown(0); }
return top;
}
get size() { return this.data.length; }
_bubbleUp(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.data[p].f <= this.data[i].f) break;
[this.data[p], this.data[i]] = [this.data[i], this.data[p]];
i = p;
}
}
_siftDown(i) {
const n = this.data.length;
while (true) {
let min = i, l = 2*i+1, r = 2*i+2;
if (l < n && this.data[l].f < this.data[min].f) min = l;
if (r < n && this.data[r].f < this.data[min].f) min = r;
if (min === i) break;
[this.data[min], this.data[i]] = [this.data[i], this.data[min]];
i = min;
}
}
}
// Octile distance heuristic (8-directional, diagonal cost = √2)
function octile(ax, ay, bx, by) {
const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
return Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy);
}
/**
* @param {boolean[]} grid - flat array, true = walkable
* @param {number} width - grid width
* @param {{x,y}} start
* @param {{x,y}} goal
* @param {boolean} [diag=true] - allow diagonal movement
* @returns {{x,y}[]|null}
*/
function aStar(grid, width, start, goal, diag = true) {
const height = grid.length / width;
const key = (x, y) => y * width + x;
const gScore = new Float32Array(grid.length).fill(Infinity);
const cameFrom = new Int32Array(grid.length).fill(-1);
const closed = new Uint8Array(grid.length);
const heap = new MinHeap();
const sk = key(start.x, start.y);
gScore[sk] = 0;
heap.push({ x: start.x, y: start.y, f: octile(start.x, start.y, goal.x, goal.y) });
const dirs = diag
? [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]]
: [[0,-1],[-1,0],[1,0],[0,1]];
while (heap.size) {
const cur = heap.pop();
if (cur.x === goal.x && cur.y === goal.y) return reconstruct(cameFrom, width, key(cur.x, cur.y));
const ck = key(cur.x, cur.y);
if (closed[ck]) continue;
closed[ck] = 1;
for (const [dx, dy] of dirs) {
const nx = cur.x + dx, ny = cur.y + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
const nk = key(nx, ny);
if (!grid[nk] || closed[nk]) continue;
const moveCost = (dx !== 0 && dy !== 0) ? Math.SQRT2 : 1;
const tentG = gScore[ck] + moveCost;
if (tentG < gScore[nk]) {
gScore[nk] = tentG;
cameFrom[nk] = ck;
heap.push({ x: nx, y: ny, f: tentG + octile(nx, ny, goal.x, goal.y) });
}
}
}
return null; // no path
}
function reconstruct(cameFrom, width, goalKey) {
const path = [];
let k = goalKey;
while (k !== -1) {
path.push({ x: k % width, y: Math.floor(k / width) });
k = cameFrom[k];
}
return path.reverse();
}
Float32Array for g-scores and Uint8Array for
the closed set avoid object overhead. For a 1024×1024 grid (~1M cells)
the arrays fit in 5 MB — well within browser limits — and cache-line
access patterns are far better than a Map or plain
object.
The efficiency of A* depends entirely on the priority queue. A binary min-heap gives O(log N) push and pop. For very large grids, a Fibonacci heap gives amortised O(1) decrease-key but is rarely worth the implementation complexity in practice.
Tie-breaking is crucial for performance. When many nodes share the same f-score, A* explores them in arbitrary order. Adding a small cross-product tie-breaker (prefer nodes whose current direction aligns with the start→goal vector) reduces explored nodes by 30–50% on open terrain:
// Nudge f slightly toward the straight-line corridor
function hTieBreak(nx, ny, gx, gy, sx, sy) {
const dx1 = nx - gx, dy1 = ny - gy;
const dx2 = sx - gx, dy2 = sy - gy;
const cross = Math.abs(dx1*dy2 - dx2*dy1);
return octile(nx, ny, gx, gy) + cross * 0.001;
}
A* is a generalisation. Setting h to different values recovers well-known algorithms:
In game AI, Hierarchical Pathfinding A* (HPA*) builds a two-level hierarchy: cluster-level abstract graph + within-cluster paths. Long-range queries run on the small abstract graph; fine paths are only computed near the agent. This is how RTS games handle hundreds of units on 512×512 maps in real time.
Jump Point Search (JPS) exploits uniformly-weighted grid symmetry: it skips redundant symmetric paths and jumps to "interesting" nodes, reducing explored nodes by 10–40× compared to plain A* on open grids with no preprocessing.
Interactive grid: paint walls, move start/goal, compare A*, Dijkstra and BFS side by side. Open and closed sets animate in real time.