Tutorial
⏱️ ~55 minutes 🎓 Intermediate 🛠️ JavaScript · Canvas 2D · Algorithms

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.

Prerequisites

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.

Continue Learning

🛠

Experiment in Playground

Implement your own pathfinding variant — write and run code directly in the browser.

Open Playground → View Simulation ↗