Build a Pathfinding Visualizer
From scratch in vanilla JavaScript: a canvas grid where you can draw walls, pick start/end points, and watch Dijkstra or A* explore the maze cell by cell. Along the way you'll implement a binary min-heap priority queue, admissible heuristics, and smooth step-by-step animation.
- JavaScript arrays, objects, classes
-
Canvas 2D API basics (
fillRect,clearRect) - Basic understanding of graphs (nodes + edges)
Grid Data Structure and Rendering
The grid is a flat Uint8Array for cache efficiency. Each
cell is: 0 = open, 1 = wall, 2 = start, 3 = end, 4 = visited, 5 =
path:
const COLS = 40, ROWS = 30;
const CELL = 20; // pixels per cell
const canvas = document.getElementById('grid');
const ctx = canvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const grid = new Uint8Array(COLS * ROWS); // flat array
const idx = (c, r) => r * COLS + c;
const COLORS = {
0: '#1a1a2e', // open
1: '#334155', // wall
2: '#22c55e', // start
3: '#ef4444', // end
4: '#1e40af', // visited
5: '#facc15', // path
};
function render() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = COLORS[grid[idx(c, r)]];
ctx.fillRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
Binary Min-Heap Priority Queue
Both Dijkstra and A* need to efficiently pop the minimum-cost node. A binary min-heap gives O(log N) push/pop:
class MinHeap {
constructor() { this.data = []; }
push(item) {
this.data.push(item);
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._sinkDown(0); }
return top;
}
get size() { return this.data.length; }
_bubbleUp(i) {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.data[parent].f <= this.data[i].f) break;
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
i = parent;
}
}
_sinkDown(i) {
const n = this.data.length;
while (true) {
let smallest = i;
const l = 2*i+1, r = 2*i+2;
if (l < n && this.data[l].f < this.data[smallest].f) smallest = l;
if (r < n && this.data[r].f < this.data[smallest].f) smallest = r;
if (smallest === i) break;
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
i = smallest;
}
}
}
Dijkstra's Algorithm
Dijkstra guarantees the shortest path on uniform-cost grids. It explores in all directions simultaneously, like water flooding outward:
function dijkstra(startC, startR, endC, endR) {
const dist = new Float32Array(COLS * ROWS).fill(Infinity);
const prev = new Int32Array(COLS * ROWS).fill(-1);
const heap = new MinHeap();
const start = idx(startC, startR);
dist[start] = 0;
heap.push({ f: 0, i: start });
const steps = []; // for animation
while (heap.size) {
const { i } = heap.pop();
if (i === idx(endC, endR)) break;
const c = i % COLS, r = Math.floor(i / COLS);
steps.push({ type: 'visit', i });
// 4-directional neighbours
for (const [dc, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nc = c + dc, nr = r + dr;
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
const ni = idx(nc, nr);
if (grid[ni] === 1) continue; // wall
const newDist = dist[i] + 1;
if (newDist < dist[ni]) {
dist[ni] = newDist;
prev[ni] = i;
heap.push({ f: newDist, i: ni });
}
}
}
// Reconstruct path
const path = [];
let cur = idx(endC, endR);
while (cur !== -1) { path.push(cur); cur = prev[cur]; }
return { steps, path: path.reverse() };
}
A* with Manhattan Heuristic
A* adds a heuristic to guide expansion towards the goal. On a grid without diagonals, Manhattan distance is admissible (never overestimates) and efficient:
Dijkstra
- f = g (cost from start)
- Explores all directions equally
- Guaranteed shortest path
- Slower on large open grids
A*
- f = g + h (cost + heuristic)
- Biased towards the goal
- Optimal if h is admissible
- Much faster in practice
// Manhattan distance heuristic
function heuristic(c, r, endC, endR) {
return Math.abs(c - endC) + Math.abs(r - endR);
}
function aStar(startC, startR, endC, endR) {
const g = new Float32Array(COLS * ROWS).fill(Infinity);
const prev = new Int32Array(COLS * ROWS).fill(-1);
const heap = new MinHeap();
const steps = [];
const start = idx(startC, startR);
g[start] = 0;
heap.push({ f: heuristic(startC, startR, endC, endR), i: start });
while (heap.size) {
const { i } = heap.pop();
if (i === idx(endC, endR)) break;
const c = i % COLS, r = Math.floor(i / COLS);
steps.push({ type: 'visit', i });
for (const [dc, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nc = c + dc, nr = r + dr;
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
const ni = idx(nc, nr);
if (grid[ni] === 1) continue;
const ng = g[i] + 1;
if (ng < g[ni]) {
g[ni] = ng;
prev[ni] = i;
const h = heuristic(nc, nr, endC, endR);
heap.push({ f: ng + h, i: ni });
}
}
}
const path = [];
let cur = idx(endC, endR);
while (cur !== -1) { path.push(cur); cur = prev[cur]; }
return { steps, path: path.reverse() };
}
Step-by-Step Animation
function animate(steps, path, speed = 15) {
let stepIndex = 0;
function frame() {
// Process `speed` steps per frame
for (let s = 0; s < speed && stepIndex < steps.length; s++, stepIndex++) {
const { i } = steps[stepIndex];
if (grid[i] !== 2 && grid[i] !== 3) grid[i] = 4; // mark visited
}
render();
if (stepIndex < steps.length) {
requestAnimationFrame(frame);
} else {
// Draw path when done
for (const i of path) {
if (grid[i] !== 2 && grid[i] !== 3) grid[i] = 5;
}
render();
}
}
requestAnimationFrame(frame);
}
Mouse Interaction — Draw Walls
let painting = false;
let paintType = 1; // 1 = wall, 0 = erase
canvas.addEventListener('mousedown', e => {
painting = true;
paintType = e.button === 2 ? 0 : 1; // right-click erases
paint(e);
});
canvas.addEventListener('mousemove', e => { if (painting) paint(e); });
canvas.addEventListener('mouseup', () => painting = false);
canvas.addEventListener('contextmenu', e => e.preventDefault());
function paint(e) {
const rect = canvas.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return;
const i = idx(c, r);
if (grid[i] === 2 || grid[i] === 3) return; // don't overwrite start/end
grid[i] = paintType;
render();
}
Comparing Algorithms Visually
To run both and compare visited counts, run each on a copy of the grid:
function runAndCompare() {
const startC = 1, startR = 1, endC = COLS-2, endR = ROWS-2;
const resD = dijkstra(startC, startR, endC, endR);
const resA = aStar(startC, startR, endC, endR);
console.log(`Dijkstra visited: ${resD.steps.length}, path: ${resD.path.length}`);
console.log(`A* visited: ${resA.steps.length}, path: ${resA.path.length}`);
// On an open grid A* typically visits 60-80% fewer nodes than Dijkstra
}
A* with Manhattan heuristic typically explores 40-80% fewer nodes than Dijkstra on open grids. They find identically-optimal paths. In mazes the difference shrinks because forced exploration is needed in both cases.
Frequently Asked Questions
What will I learn in this tutorial?
Build an interactive A* and Dijkstra pathfinding visualizer in vanilla JavaScript: grid construction, priority queue, heuristics, and step-by-step animation.
What topics are covered in this tutorial?
This tutorial covers: Grid data structure and rendering, Binary min-heap priority queue, Dijkstra's algorithm, A* with Manhattan heuristic, Step-by-step animation, Mouse interaction — draw walls, Comparing algorithms visually.
What tools and technologies does this tutorial use?
This tutorial uses JavaScript, Canvas 2D, Algorithms.
How long does this tutorial take?
This tutorial takes approximately 55 minutes to complete.
What prerequisites do I need before starting?
Before starting, you should have: JavaScript arrays, objects, classes; Canvas 2D API basics ( fillRect , clearRect ); Basic understanding of graphs (nodes + edges).