Maze generator & solver · qwen3.8-2.4t-a95b
Generate a random maze with the recursive-backtracker algorithm on a canvas, then animate an A-star or BFS solver finding the path from start to finish.
transcript · 12 messages
Generate a random maze with the recursive-backtracker algorithm on a canvas, then animate an A-star or BFS solver finding the path from start to finish.
thinking
Write index.html containing a canvas, maze generation via recursive backtracker, and animation of A* solving. Self-contained, no external resources. Add controls: new maze, size slider, speed, algorithm selection. Keep it clean.
/tmp/ws/index.html (15345 bytes)Successfully wrote 15345 bytes to /tmp/ws/index.html
thinking
Let me re-check the BFS logic — it was a bit awkward. In the BFS branch of `startSolving`, after building the object I'm doing a weird queue reset. Let me trace through it: `queue = [[0,0]]; head=0;`, then I overwrite `solver.open` with an object that has push/pop/size, then do `solver.open.pop = ...`, then `solver.open.push(0, 0)` — this pushes [0,0]... wait no, push(x,y) pushes [x,y], so pushing (0,0) again would duplicate [0,0]. After that I do `queue.length = 0; head = 0; queue.push([0,0]);` to clear it and re-add. OK, so the final queue is [[0,0]] — works but ugly. Also the BFS expansion logic:
```js
if (!s.cameFrom.has(nkey) && !(nx === 0 && ny === 0)) {
s.cameFrom.set(nkey, key);
} else if (nx === 0 && ny === 0) {
continue;
}
if (!s.cameFrom.has(nkey) && !(nx === 0 && ny === 0)) continue;
s.open.push(nx, ny);
```
`push(nx, ny)` pushes [nx,ny] — correct as push(x,y).
Trace of BFS expansion: for an unvisited, undiscovered neighbor: first if is true → cameFrom gets set. Second if: cameFrom.has(nkey) is now true → false, so we don't continue. Then push. Good. For an already-discovered neighbor: first if is false, else-if is also false (since it's not the start). Second if: `!cameFrom.has && !start` → false, so it doesn't continue and gets pushed again → duplicate. Hmm, so already-discovered cells get pushed repeatedly — wasteful but not incorrect, because the closed-set check `if (s.closed.has(nkey)) return true;` skips them. Wait no, in the BFS branch of solveStep: `if (s.closed.has(idx(x,y))) return true;` — returns true to continue. Good. But duplicates bloat the queue. Also the start cell: continue skips it. OK.
Better to clean up the BFS logic to avoid duplicates. Let me tidy it up: track enqueued separately with a discovered set.
Actually simpler: in BFS, use cameFrom or a discovered set, and only push if undiscovered. The start cell (0,0) doesn't have cameFrom, so handle it specially. Let me rewrite the BFS expansion:
```js
if (nkey === 0) continue; // start already handled
if (!s.cameFrom.has(nkey)) {
s.cameFrom.set(nkey, key);
s.open.push(nx, ny);
...
}
```
Wait, nkey for start = idx(0,0) = 0. Good.
Also fix the init. Let me edit.
Now let me verify the logic and clean up the BFS branch which I wrote awkwardly:
{"path":"/tmp/ws/index.html","edits":[{"oldText":" } else {\n const queue = [[0, 0]];\n let head = 0;\n solver.open = {\n Successfully replaced 2 block(s) in /tmp/ws/index.html.
thinking
Also, in solveStep, the "skip stale entries" in the BFS branch of the A* loop — no problem. One more thing to check: in the astar solveStep, the do-while pops, but if the heap becomes empty during the loop, `s.open.pop()` returns undefined — already handled with `if (!node) return false;`. Good. Trace loop: after the goal is found, traceStep traces cameFrom back to the start cell (0,0). The key for the start cell is 0, and it's not included in cameFrom. So pathNode ends up as null after the start cell. But wait — the goal check `s.pathNode === null` in solveLoop runs after the trace step. Also, `traceStep` returns false when pathNode becomes null. But there's an edge case: N=1 is not possible (the minimum is 15). No problem. Also, the check at the start of traceStep: `s.pathNode === undefined || null` — no problem. One issue: in the found branch of solveLoop, when `traceStep()` returns false (path complete, pathNode is null), we redraw and confirm that `s.pathNode === null` → complete. Good. Also verify syntax. If node is available, run a quick syntax check.
Let me verify the JavaScript syntax is valid:
{"command":"cd /tmp/ws && node -e \"\nconst html = require('fs').readFileSync('index.html','utf8');\nconst m = html.match(/<script>([\\s\\S]JS syntax OK, script length: 11085 -rw-r--r--. 1 node node 15033 Aug 13 01:30 index.html
thinking
Also, please do a quick smoke test of the maze generation + solver logic headlessly — simulate the algorithm without the canvas. This will require stubbing out the DOM. An easier approach: extract the core algorithm and test it in pure Node. Let's write a simple test that reproduces the logic (recursive backtracker + BFS/A* over the wall grid) to confirm that the path actually exists and that the cameFrom trace works correctly. In practice, this is a fair amount of duplication. Alternative: stub out a minimal DOM using node. I'll go with doing a lightweight headless test using a fake canvas.
Let me run a headless smoke test with a stubbed DOM to verify generation and solving actually complete end-to-end:
{"command":"cd /tmp/ws && node -e \"\nconst fs = require('fs');\nconst html = fs.readFileSync('index.html','utf8');\nconst js = html.match(/After generation: Maze generated (15×15). Press Solve.
thinking
Generation works headlessly. Next, we need to fire the Solve click. In my stub, the listeners are inside a closure of makeEl, so they're not accessible. To simulate a click, let's rebuild the test with a better stub.
Generation completes. Now let me extend the test to click "Solve" and verify both algorithms finish with a valid path:
{"command":"cd /tmp/ws && node -e \"\nconst fs = require('fs');\nconst js = fs.readFileSync('index.html','utf8').match(/<script>([\\s\\S]*)<[astar] gen: Maze generated (15×15). Press Solve. [astar] solve: Solved! Path length: 141 · Explored: 184 cells --- [bfs] gen: Maze generated (15×15). Press Solve. [bfs] solve: Solved! Path length: 131 · Explored: 199 cells
thinking
Both algorithms work end-to-end. Both find the optimal path (a perfect maze has exactly one path, so the path lengths should be identical... wait, 141 vs 131? This is because the mazes are different — different random seeds per run. No problem.) Done. Final files are all in place.
Done! `/tmp/ws/index.html` is a fully self-contained single-page app (no external resources), verified end-to-end with headless tests: - **A\***: `Solved! Path length: 141 · Explored: 184 cells` - **BFS**: `Solved! Path length: 131 · Explored: 199 cells` ## Features **Maze generation — Recursive Backtracker (animated):** - Iterative DFS with a stack: carve walls to random unvisited neighbors, backtrack when stuck - Live green "drill head" and trail show the carving process in real time - Produces a *perfect maze* (exactly one path between any two cells) **Solver — A\* or BFS (animated):** - **A\*** with Manhattan-distance heuristic and a binary-heap priority queue (with stale-entry skipping) - **BFS** with a FIFO queue as an alternative - Frontier cells shown bright blue, visited cells dimmer, then the solution is traced back in yellow from finish to start - Live stats: cells explored, final path length **Controls:** - Grid size (15×15 up to 61×61), algorithm choice, animation speed slider (steps/frame) - "New Maze" regenerates; "Solve" runs the animated search - Color legend: start (teal), finish (pink), visited/frontier (blue), path (yellow) The canvas auto-sizes to the viewport, and buttons lock appropriately during animations to prevent state conflicts.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Maze generates via recursive backtracker and completes, but solver never visibly runs the A* pathfinding animation in any panel shown.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.