← Labs

Pathfinding Visualizer

Pick an algorithm — A*, Dijkstra, BFS, or Greedy Best-First — draw walls, and watch it find the path. Built in Rust with egui, compiled to WebAssembly.

How to use

Click any grid cell to toggle a wall. Left-click and drag to draw walls quickly; right-click and drag to erase them. The green cell is the start, coral is the goal — both are fixed. Pick an algorithm from the panel, adjust the animation speed, then press Play or tap Space to run. Step advances one node expansion at a time. Reset keeps your walls but clears the search state. Clear wipes everything.

The indigo wash is the closed set (fully expanded nodes). The brighter indigo cells are the open frontier. The warm-white line is the final path once the goal is reached.

The algorithms

All four share a single data structure — a SearchState — holding the frontier, closed set, predecessor map, and g-scores. A single step() call expands one node and returns Continue, Found(path), or NoPath. This makes it easy to compare them side-by-side without duplicating grid traversal logic.

BFS uses a FIFO queue. On a uniform-cost grid it always finds the shortest path. It expands layer by layer in all directions, which is visually distinctive — you’ll see a clean ripple spreading outward from the start.

Dijkstra uses a min-heap keyed by accumulated cost. On this grid (uniform cost of 1 per step) it is behaviorally identical to BFS, but the heap overhead makes it slower. Worth selecting to confirm the equivalence visually.

A* adds a heuristic to Dijkstra’s priority: f = g + h, where h is the Manhattan distance to the goal. This focuses the search toward the goal, dramatically reducing the number of nodes expanded compared to Dijkstra on open terrain. The heuristic is admissible — it never overestimates, so the found path is guaranteed optimal.

// A* priority: g (cost so far) + h (Manhattan distance to goal).
let f = new_g + manhattan(neighbor, goal);
self.heap.push(Reverse((f, neighbor.0, neighbor.1)));
// Manhattan distance — admissible for 4-connected grids.
fn manhattan(a: (usize, usize), b: (usize, usize)) -> u32 {
let dr = (a.0 as i32 - b.0 as i32).unsigned_abs();
let dc = (a.1 as i32 - b.1 as i32).unsigned_abs();
dr + dc
}

Greedy Best-First drops the g term entirely — it prioritizes purely by h. This makes it the fastest searcher visually (it rushes straight at the goal), but it is not optimal. Draw an L-shaped wall that forces a detour and watch Greedy find a longer path than A*.

One implementation note: A* and Dijkstra both use lazy deletion from the heap. When a shorter path to a node is found, a new entry is pushed rather than updating the existing one. Stale entries are detected and skipped when popped:

// Skip stale heap entries (lazy deletion).
if self.closed_positions.contains(&current) {
return StepResult::Continue;
}

Building for the web with egui + eframe

The entire visualizer is a single Rust crate compiled to WebAssembly with wasm-pack. The egui immediate-mode model maps naturally to a game-loop: each frame the app rebuilds the UI from scratch based on current state, then egui diffs it against the previous frame. No virtual DOM, no useState cascades — state lives in a plain Rust struct.

eframe’s WebRunner takes a canvas element id and manages the event loop, input handling, and DPI scaling using ResizeObserver and devicePixelContentBoxSize (physical pixels, bypassing window.devicePixelRatio). The canvas sits in a React island that initializes the WASM module, hands off the canvas id, then gets out of the way.

The trade-off is real: egui uses its own font rasterizer and draws everything to a WebGL canvas. You lose native browser text rendering, system fonts, CSS transitions, and accessibility semantics. For a visualizer where the interaction model is the point, it is a reasonable trade. For anything content-heavy, it is the wrong tool.