Loading demo...
algorithmsvisualizationinteractivepathfinding

Search Algorithm Visualizer

Watch BFS, DFS, Dijkstra, A*, and Greedy Best-First race through a maze — one at a time or side by side.

An interactive visualizer for grid-based pathfinding. Drop a start and a goal in a maze, pick an algorithm, and watch it explore. The visited cells get a faded hue, the frontier (the open set) is bright, and the reconstructed path lights up in bold once the goal is reached. Run a single algorithm to study how it thinks, or flip on compare mode and let five algorithms race the same maze in parallel — each panel gets its own color so the differences pop:

  • BFS — blue
  • DFS — purple
  • Dijkstra — lime
  • A* — pink
  • Greedy Best-First — orange

Algorithms

BFS

Breadth-First Search expands cells in concentric rings from the start. Because every step has equal cost on this grid, BFS is guaranteed to find a shortest path in terms of cells traversed — but it does so by exploring everything within radius d before peeking at radius d+1. Visually: a uniform balloon that pops once it engulfs the goal.

queue = [start]
while queue not empty:
  node = queue.pop_front()
  if node == goal: return reconstruct(node)
  for neighbor in unvisited(node):
    mark visited, set parent
    queue.push_back(neighbor)

Time: O(V + E). Optimal for unweighted graphs.

DFS

Depth-First Search dives. It picks a neighbor, recurses, and only backtracks when it dead-ends. It often finds a path very fast — but rarely the shortest. On a perfect maze, DFS will happily snake around the entire board chasing a wrong corridor before doubling back.

stack = [start]
while stack not empty:
  node = stack.pop()
  if node == goal: return reconstruct(node)
  for neighbor in unvisited(node):
    set parent
    stack.push(neighbor)

Time: O(V + E). Not optimal. Useful for connectivity checks, topological sorts, and showing students why "fast to find a path" and "fast to find the shortest path" are very different problems.

Dijkstra

Dijkstra's algorithm generalizes BFS to weighted graphs by replacing the FIFO queue with a min-priority queue keyed on cumulative cost. On a uniform-cost grid it behaves identically to BFS — same exploration order, same shortest path — but flip diagonals on (cost √2 per diagonal step) and the difference appears: Dijkstra still finds the true shortest path while BFS would treat diagonals as one step and produce a longer real-distance route.

gScore[start] = 0
push (start, 0) onto heap
while heap not empty:
  node = heap.pop_min()
  if node == goal: return reconstruct(node)
  for neighbor of node:
    tentative = gScore[node] + edge_cost
    if tentative < gScore[neighbor]:
      gScore[neighbor] = tentative
      set parent
      push (neighbor, tentative) onto heap

Time: O((V + E) log V) with a binary heap. Optimal for non-negative edge weights.

A*

A* adds a heuristic: an estimate of the remaining distance from each candidate to the goal. The priority queue is keyed on g + h — known cost plus estimated cost — so the search biases toward cells that look promising. If the heuristic never overestimates (admissibility), A* is optimal and complete, and it typically explores a fraction of what Dijkstra explores on the same map.

gScore[start] = 0
push (start, h(start)) onto heap
while heap not empty:
  node = heap.pop_min()
  if node == goal: return reconstruct(node)
  for neighbor of node:
    tentative = gScore[node] + edge_cost
    if tentative < gScore[neighbor]:
      gScore[neighbor] = tentative
      set parent
      push (neighbor, tentative + h(neighbor)) onto heap

Time: O((V + E) log V) worst case, much faster in practice on grids. Optimal when h is admissible.

Greedy Best-First

Greedy Best-First strips A* down to just the heuristic: pick the cell with the smallest h(node) and run with it. The result is fast — often visibly faster than A* — and almost always suboptimal. Watch the orange panel for a side-by-side comparison: Greedy tunnels straight at the goal, ignoring the fact that the path it's committing to might be 30% longer than necessary.

push (start, h(start)) onto heap
while heap not empty:
  node = heap.pop_min()
  if node == goal: return reconstruct(node)
  for neighbor of node:
    if not visited(neighbor):
      set parent
      push (neighbor, h(neighbor)) onto heap

Time: O((V + E) log V). Not optimal. The hare to A*'s tortoise — except A* usually still wins on real maps.

Heuristics

The visualizer auto-picks the heuristic based on the Diagonals toggle:

  • 4-way (Manhattan) — |Δx| + |Δy|. The minimum number of grid steps when you can only move horizontally or vertically. Admissible and tight when diagonals are off.
  • 8-way (Octile) — max(|Δx|, |Δy|) + (√2 − 1) · min(|Δx|, |Δy|). The minimum cost when diagonals are allowed and cost √2. Also admissible; the diagonal moves are charged their true Euclidean length.

Picking the right heuristic matters: feed A* a Manhattan heuristic on an 8-way grid and it stops being optimal. The visualizer keeps the heuristic and the movement model in sync so each algorithm gets a fair shake.

Maze generators

  • Recursive Backtracker — DFS-style carving from a starting cell. Produces a "perfect" maze: exactly one path between any two cells, lots of long winding corridors.
  • Prim's — randomized frontier expansion. Also a perfect maze but with shorter dead-ends and a more uniform texture.
  • Random Obstacles — open field with sparse walls. The most useful generator for showing off A*: huge open spaces let the heuristic actually do work.
  • Empty — no walls. Pure heuristic showcase. Watch BFS expand in a perfect diamond, A* in a tight wedge pointed at the goal, and Greedy fire a laser beam.

Tech

Every cell is one byte in a Uint8Array — 0 for open, 1 for wall. Maze generators carve into this buffer; algorithms read it. Each searcher keeps its own Uint8Array of visit state (untouched / in frontier / visited), an Int32Array of parent pointers for path reconstruction, and a Float32Array of g-scores when relevant. Priority queues are a hand-rolled binary min-heap that stores (cell, priority) pairs in two parallel arrays — no dependency, no object churn per insert.

The animation loop is a single requestAnimationFrame tick that advances every active searcher by an accumulator-balanced number of steps per frame, then redraws each panel imperatively with fillRects. Stats counters (nodes explored, frontier size, path length, status) are read off each searcher at the end of each frame and bumped into React state via a statsTick counter — once per frame, not once per step.

In compare mode, all panels share a single set of walls so they're solving the same maze. Each searcher has its own state buffers and its own canvas; nothing crosses between panels. That keeps the race honest and makes the differences between A* and BFS visually unmistakable.