Maze generator & solver · minimax-m3
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
The user wants me to build a single-page web app that:
1. Generates a random maze using the recursive-backtracker algorithm on a canvas
2. Animates a pathfinding algorithm (A* or BFS) solving the maze from start to finish
Let me plan this out:
**Maze representation:**
- Grid of cells, each with 4 walls (N, S, E, W)
- Recursive backtracker: DFS with backtracking, carving passages
**Rendering:**
- HTML5 Canvas
- Two phases: maze generation animation, then pathfinding animation
- Use requestAnimationFrame for smooth animation
**Pathfinding:**
- I'll do A* with Manhattan heuristic
- Animate frontier/visited cells and final path
**UI:**
- Controls: size selector, speed, regenerate button
- Start (top-left) and finish (bottom-right) marked clearly
- Color legend
Let me build this in a single index.html file with all CSS and JS inline, no external resources.
Let me write the complete file:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Maze Generator & A* Solver</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0f0f1a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
h1 {
margin-bottom: 12px;
font-weight: 300;
letter-spacing: 2px;
color: #6cf;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
align-items: center;
background: #1a1a2e;
padding: 12px 18px;
border-radius: 8px;
border: 1px solid #2a2a40;
}
.control {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
label { color: #aaa; }
input[type="range"] { width: 100px; }
select, button {
background: #2a2a40;
color: #e0e0e0;
border: 1px solid #3a3a55;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover { background: #3a3a55; }
button.primary {
background: #2a4a7a;
border-color: #4a7aba;
}
button.primary:hover { background: #3a5a8a; }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#status {
margin-bottom: 12px;
font-size: 14px;
color: #aaa;
height: 20px;
}
canvas {
background: #0a0a14;
border: 2px solid #2a2a40;
border-radius: 4px;
max-width: 100%;
}
.legend {
margin-top: 16px;
display: flex;
gap: 18px;
flex-wrap: wrap;
justify-content: center;
font-size: 13px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #333;
}
</style>
</head>
<body>
<h1>MAZE GENERATOR & A* SOLVER</h1>
<div class="controls">
<div class="control">
<label>Size:</label>
<select id="size">
<option value="15">15 × 15</option>
<option value="25" selected>25 × 25</option>
<option value="35">35 × 35</option>
<option value="50">50 × 50</option>
<option value="75">75 × 75</option>
</select>
</div>
<div class="control">
<label>Gen speed:</label>
<input type="range" id="genSpeed" min="1" max="200" value="40">
</div>
<div class="control">
<label>Solve speed:</label>
<input type="range" id="solveSpeed" min="1" max="500" value="40">
</div>
<button id="generateBtn" class="primary">Generate New Maze</button>
<button id="solveBtn">Solve</button>
<button id="resetPathBtn">Reset Path</button>
</div>
<div id="status">Click "Generate New Maze" to start</div>
<canvas id="canvas"></canvas>
<div class="legend">
<div class="legend-item"><div class="swatch" style="background:#0a0a14"></div>Wall</div>
<div class="legend-item"><div class="swatch" style="background:#1a1a2e"></div>Empty</div>
<div class="legend-item"><div class="swatch" style="background:#00d4ff"></div>Visited</div>
<div class="legend-item"><div class="swatch" style="background:#ffd700"></div>Frontier</div>
<div class="legend-item"><div class="swatch" style="background:#ff4d6d"></div>Start</div>
<div class="legend-item"><div class="swatch" style="background:#06ffa5"></div>Goal</div>
<div class="legend-item"><div class="swatch" style="background:#ff6b9d"></div>Path</div>
</div>
<script>
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sizeSelect = document.getElementById('size');
const genSpeedInput = document.getElementById('genSpeed');
const solveSpeedInput = document.getElementById('solveSpeed');
const generateBtn = document.getElementById('generateBtn');
const solveBtn = document.getElementById('solveBtn');
const resetPathBtn = document.getElementById('resetPathBtn');
const statusEl = document.getElementById('status');
let cols, rows, cellSize;
let grid = [];
let visited = []; // for maze gen
let stack = [];
let genStepCount = 0;
// For solver
let solverState = null;
// Animation handles
let genAnimHandle = null;
let solveAnimHandle = null;
const COLORS = {
wall: '#0a0a14',
bg: '#1a1a2e',
grid: '#252540',
visited: '#00d4ff',
frontier: '#ffd700',
start: '#ff4d6d',
goal: '#06ffa5',
path: '#ff6b9d',
current: '#ff8c42'
};
// Directions: N=0(top,-1), E=1(0,+1), S=2(+1,0), W=3(0,-1)
const DX = [-1, 0, 1, 0];
const DY = [0, 1, 0, -1];
const OPP = [2, 3, 0, 1];
function setStatus(t) { statusEl.textContent = t; }
function initCanvas() {
cols = parseInt(sizeSelect.value);
rows = cols;
// Determine cell size based on grid size
const maxCanvas = Math.min(window.innerWidth - 40, 700);
cellSize = Math.floor(maxCanvas / cols);
if (cellSize > 30) cellSize = 30;
if (cellSize < 6) cellSize = 6;
canvas.width = cols * cellSize;
canvas.height = rows * cellSize;
}
function makeGrid() {
grid = [];
visited = [];
for (let r = 0; r < rows; r++) {
const row = [];
const vrow = [];
for (let c = 0; c < cols; c++) {
row.push({ walls: [true, true, true, true] }); // N E S W
vrow.push(false);
}
grid.push(row);
visited.push(vrow);
}
}
function resetSolver() {
solverState = null;
}
function resetPath() {
// Clear all solved markings but keep maze
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c]) {
grid[r][c].inPath = false;
grid[r][c].visitedS = false;
grid[r][c].inFrontier = false;
}
}
}
resetSolver();
setStatus('Path reset. Click "Solve" to find a new path.');
drawMaze();
}
function neighbors(r, c) {
const result = [];
for (let i = 0; i < 4; i++) {
const nr = r + DX[i];
const nc = c + DY[i];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
result.push({ nr, nc, dir: i });
}
}
return result;
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function startGeneration() {
cancelAnimationFrame(genAnimHandle);
cancelAnimationFrame(solveAnimHandle);
resetSolver();
initCanvas();
makeGrid();
// Start recursive backtracker from (0,0)
const startR = 0, startC = 0;
stack.push({ r: startR, c: startC });
visited[startR][startC] = true;
grid[startR][startC].isCurrent = true;
genStepCount = 0;
setStatus('Generating maze with recursive backtracker...');
generateBtn.disabled = true;
solveBtn.disabled = true;
resetPathBtn.disabled = true;
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
function genStep() {
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 10));
for (let s = 0; s < stepsPerFrame; s++) {
if (stack.length === 0) {
finishGeneration();
return;
}
const { r, c } = stack[stack.length - 1];
grid[r][c].isCurrent = true;
const candidates = shuffle(neighbors(r, c).filter(n => !visited[n.nr][n.nc]));
if (candidates.length === 0) {
// Backtrack
grid[r][c].isCurrent = false;
stack.pop();
if (stack.length > 0) {
const prev = stack[stack.length - 1];
grid[prev.r][prev.c].isCurrent = true;
}
} else {
// Carve to chosen neighbor
const { nr, nc, dir } = candidates[0];
// Remove wall between (r,c) and (nr,nc)
grid[r][c].walls[dir] = false;
grid[nr][nc].walls[OPP[dir]] = false;
visited[nr][nc] = true;
grid[r][c].isCurrent = false;
stack.push({ r: nr, c: nc });
genStepCount++;
}
}
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
function finishGeneration() {
setStatus(`Maze complete (${genStepCount} cells carved). Click "Solve" to find path.`);
generateBtn.disabled = false;
solveBtn.disabled = false;
resetPathBtn.disabled = false;
drawMaze();
}
// ============ A* Solver ============
// Priority queue using simple array (works fine for small grid)
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 > 0) {
this.data[0] = last;
this.sinkDown(0);
}
return top;
}
bubbleUp(i) {
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.data[i].f < this.data[parent].f) {
[this.data[i], this.data[parent]] = [this.data[parent], this.data[i]];
i = parent;
} else break;
}
}
sinkDown(i) {
const n = this.data.length;
while (true) {
let l = 2 * i + 1, r = 2 * i + 2, smallest = i;
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) {
[this.data[i], this.data[smallest]] = [this.data[smallest], this.data[i]];
i = smallest;
} else break;
}
}
get size() { return this.data.length; }
}
function manhattan(r1, c1, r2, c2) {
return Math.abs(r1 - r2) + Math.abs(c1 - c2);
}
function canMove(r, c, dir) {
// Check if wall in direction `dir` at (r,c) is open
return !grid[r][c].walls[dir];
}
function startSolve() {
if (!grid || grid.length === 0) return;
cancelAnimationFrame(solveAnimHandle);
resetSolver();
// Clear previous solver marks
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
grid[r][c].inPath = false;
grid[r][c].visitedS = false;
grid[r][c].inFrontier = false;
}
}
const startR = 0, startC = 0;
const goalR = rows - 1, goalC = cols - 1;
const open = new MinHeap();
const cameFrom = {};
const gScore = {};
const startKey = `${startR},${startC}`;
gScore[startKey] = 0;
open.push({ r: startR, c: startC, f: manhattan(startR, startC, goalR, goalC), g: 0 });
solverState = {
open,
cameFrom,
gScore,
goalR, goalC,
startR, startC,
found: false,
finalPath: null,
phase: 'search'
};
setStatus(`Solving with A* from (${startR},${startC}) to (${goalR},${goalC})...`);
solveBtn.disabled = true;
generateBtn.disabled = true;
resetPathBtn.disabled = false;
drawMaze();
solveAnimHandle = requestAnimationFrame(solveStep);
}
function solveStep() {
const speed = parseInt(solveSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 8));
const { open, cameFrom, gScore, goalR, goalC } = solverState;
let pathDrawn = false;
for (let s = 0; s < stepsPerFrame && open.size > 0 && !solverState.found; s++) {
const cur = open.pop();
const curKey = `${cur.r},${cur.c}`;
if (grid[cur.r][cur.c].closed) continue;
grid[cur.r][cur.c].closed = true;
grid[cur.r][cur.c].visitedS = true;
grid[cur.r][cur.c].inFrontier = false;
if (cur.r === goalR && cur.c === goalC) {
// Reconstruct path
const path = [];
let k = curKey;
while (k) {
const [pr, pc] = k.split(',').map(Number);
path.push({ r: pr, c: pc });
k = cameFrom[k];
}
path.reverse();
solverState.finalPath = path;
solverState.found = true;
pathDrawn = true;
break;
}
for (let i = 0; i < 4; i++) {
if (!canMove(cur.r, cur.c, i)) continue;
const nr = cur.r + DX[i];
const nc = cur.c + DY[i];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const nKey = `${nr},${nc}`;
if (grid[nr][nc].closed) continue;
const tentativeG = cur.g + 1;
if (gScore[nKey] === undefined || tentativeG < gScore[nKey]) {
cameFrom[nKey] = curKey;
gScore[nKey] = tentativeG;
const f = tentativeG + manhattan(nr, nc, goalR, goalC);
open.push({ r: nr, c: nc, f, g: tentativeG });
grid[nr][nc].inFrontier = true;
}
}
}
drawMaze();
if (solverState.found) {
drawPath();
setStatus(`Path found! Length: ${solverState.finalPath.length} cells.`);
solveBtn.disabled = false;
generateBtn.disabled = false;
} else if (open.size === 0) {
setStatus('No path exists!');
solveBtn.disabled = false;
generateBtn.disabled = false;
} else {
solveAnimHandle = requestAnimationFrame(solveStep);
}
}
function drawPath() {
if (!solverState || !solverState.finalPath) return;
for (let i = 0; i < solverState.finalPath.length; i++) {
const { r, c } = solverState.finalPath[i];
const x = c * cellSize;
const y = r * cellSize;
const pad = Math.max(1, Math.floor(cellSize * 0.25));
ctx.fillStyle = COLORS.path;
ctx.fillRect(x + pad, y + pad, cellSize - 2 * pad, cellSize - 2 * pad);
}
// Redraw start & goal on top
drawStartGoal();
}
function drawStartGoal() {
const pad = Math.max(2, Math.floor(cellSize * 0.2));
// Start
ctx.fillStyle = COLORS.start;
ctx.fillRect(pad, pad, cellSize - 2 * pad, cellSize - 2 * pad);
// Goal
ctx.fillStyle = COLORS.goal;
const gx = (cols - 1) * cellSize + pad;
const gy = (rows - 1) * cellSize + pad;
ctx.fillRect(gx, gy, cellSize - 2 * pad, cellSize - 2 * pad);
}
function drawMaze() {
const w = canvas.width;
const h = canvas.height;
// Background
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
// Draw cells by walls
ctx.strokeStyle = COLORS.wall;
ctx.lineWidth = Math.max(1, Math.floor(cellSize / 12));
ctx.lineCap = 'square';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * cellSize;
const y = r * cellSize;
const cell = grid[r][c];
if (!cell) continue;
// Fill bg behind walls (already bg color)
// Draw walls: only draw N wall of (r,c) and W wall to avoid duplicates
// Actually simpler: draw N and W walls of each cell
ctx.beginPath();
// N wall
if (cell.walls[0]) {
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
}
// E wall (only for rightmost col or use this cell's E)
if (cell.walls[1]) {
ctx.moveTo(x + cellSize, y);
ctx.lineTo(x + cellSize, y + cellSize);
}
// S wall (only for bottom row)
if (cell.walls[2] && r === rows - 1) {
ctx.moveTo(x, y + cellSize);
ctx.lineTo(x + cellSize, y + cellSize);
}
// W wall (only for leftmost col)
if (cell.walls[3] && c === 0) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + cellSize);
}
ctx.stroke();
}
}
// Draw solver overlays - only render if cells exist
if (cellSize >= 8) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = grid[r][c];
if (!cell) continue;
const x = c * cellSize;
const y = r * cellSize;
const pad = Math.max(1, Math.floor(cellSize * 0.18));
if (cell.inFrontier) {
ctx.fillStyle = COLORS.frontier;
ctx.fillRect(x + pad, y + pad, cellSize - 2 * pad, cellSize - 2 * pad);
} else if (cell.visitedS && cellSize >= 4) {
ctx.fillStyle = COLORS.visited;
ctx.globalAlpha = 0.5;
ctx.fillRect(x + pad, y + pad, cellSize - 2 * pad, cellSize - 2 * pad);
ctx.globalAlpha = 1;
}
if (cell.isCurrent && cellSize >= 6) {
ctx.fillStyle = COLORS.current;
ctx.globalAlpha = 0.8;
ctx.fillRect(x + pad, y + pad, cellSize - 2 * pad, cellSize - 2 * pad);
ctx.globalAlpha = 1;
}
}
}
}
// Start and goal
drawStartGoal();
}
// Event handlers
generateBtn.addEventListener('click', startGeneration);
solveBtn.addEventListener('click', startSolve);
resetPathBtn.addEventListener('click', resetPath);
sizeSelect.addEventListener('change', startGeneration);
// Initial setup
initCanvas();
makeGrid();
drawMaze();
setStatus('Ready. Click "Generate New Maze" to start.');
})();
</script>
</body>
</html>
```
Wait — I should double check a couple of things:
1. The maze generation using recursive backtracker: this DFS approach can hit stack overflow for large mazes (e.g., 75x75 = 5625 cells). My implementation uses an explicit stack, so that's fine!
2. For very large grids with fast speed, the genStep loop could run too long per frame. Let me cap it.
3. The resetSolver when changing size — make sure to clear all the cell markings from prev maze.
4. I should think about the `closed` flag on cells. I'm using grid[r][c].closed but I initialize via clearing with `for`. Let me make sure that's set. Actually I'm only setting it to true when popped; never to false. On new solve I clear other flags but not `closed`. Wait yes that's an issue. Let me add `closed: false` to clearing loop.
5. UI: I should disable buttons appropriately. Looking good.
6. Edge case: when changing size, the previous grid may have wrong `cols`/`rows`. The `sizeSelect` change handler calls `startGeneration` which does the init - good.
7. The visual: walls are drawn with strokes. Looks good but I want to make sure all walls render correctly. The recursive backtracker removes walls between adjacent cells, so for shared walls:
- When I draw N wall of cell (r,c), it's the same as S wall of cell (r-1,c).
- By drawing N wall for every cell, I cover all internal horizontal walls.
- Similarly W wall covers all vertical internal walls.
- Plus E for rightmost col and S for bottom row, and W for leftmost col (already covered by my "W wall (only for leftmost col)" - which is just decorative).
Actually wait, if both cells have their walls, removing one should remove both. Let me check the gen code: yes, when carving, I set both grid[r][c].walls[dir] = false AND grid[nr][nc].walls[OPP[dir]] = false. Good.
For drawing: I draw N wall of (r,c) if cell.walls[0] is true. So I cover all internal N walls. I also need to draw E walls for the rightmost column (and N wall for bottom row - no, bottom row has no "S" internal, but I need to draw S wall for bottom row regardless).
Wait actually the bottom row needs its S wall drawn (since there's no row+1 to draw from). And rightmost col needs its E wall drawn.
Let me re-examine my drawMaze:
```
N wall: all cells (internal + top boundary)
E wall: all cells (this is wrong! internal E walls would be duplicated)
```
Hmm, if I draw E wall of EVERY cell, then for cell (r,c) and cell (r,c+1), I'd draw the E wall twice. That's fine visually — overlapping lines look the same. But it's redundant work.
Actually a better approach: draw N wall for all cells (this is the top boundary for row 0 and internal for others, since I removed both directions when carving). Draw W wall similarly for all cells. Then for the boundary: right edge (E wall of rightmost column) and bottom edge (S wall of bottom row).
That's cleaner. Let me update.
Let me rewrite the drawMaze part:
```javascript
function drawMaze() {
const w = canvas.width;
const h = canvas.height;
// Background fill for cells
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, w, h);
// Draw walls
ctx.strokeStyle = COLORS.wall;
ctx.lineWidth = Math.max(1, Math.floor(cellSize / 12));
ctx.lineCap = 'square';
ctx.beginPath();
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = grid[r][c];
if (!cell) continue;
const x = c * cellSize;
const y = r * cellSize;
// N wall
if (cell.walls[0]) {
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
}
// W wall
if (cell.walls[3]) {
ctx.moveTo(x, y);
ctx.lineTo(x, y + cellSize);
}
// E wall for rightmost col
if (cell.walls[1] && c === cols - 1) {
ctx.moveTo(x + cellSize, y);
ctx.lineTo(x + cellSize, y + cellSize);
}
// S wall for bottom row
if (cell.walls[2] && r === rows - 1) {
ctx.moveTo(x, y + cellSize);
ctx.lineTo(x + cellSize, y + cellSize);
}
}
}
ctx.stroke();
// Solver overlays...
}
```
For animation performance, I want the highlights inside the maze to look good at all sizes. For cellSize < 8, I'll skip the overlay fills (or just keep walls).
Actually, let me think about this more carefully. The animation rate is controlled by the speed slider. With cellSize = 6 and 75x75 grid, that's a lot of cells. Let me make sure performance is reasonable.
Also: the visited cells in solver — for BFS-like display, I can use a lighter overlay color so the path is visible at the end.
Let me also ensure when generating for fast speed, we don't completely finish too quickly — actually fast should make it instant, so that's fine.
One more improvement: the maze generation visual currently marks just the current cell. It would be cool to highlight the "trail" of the DFS — i.e., color cells in the stack. But that's more complex. Let me just show the current cell.
Actually showing the stack trail would be nice. Let me add a simple field `inStack` for cells in the stack, but that means updating on every push/pop. Let me add a `inRecPath` flag.
Hmm let me just keep it simple with `isCurrent`. Actually let me also add a faint highlight for cells that have been "carved" recently or are in the current recursion path. I'll track this with a count of recent carved cells.
Actually, let me keep it minimal — current cell only. That's clear enough.
Let me also fix the bug where after `solveStep` finishes, we call `drawMaze` and then `drawPath` only if found. But drawPath doesn't get called from inside the stepsPerFrame loop, just from the outer check. Let me re-examine...
Actually looking at my code:
```
drawMaze();
if (solverState.found) {
drawPath();
...
}
```
So after the steps loop completes, we redraw the maze with current state, then if we just found the goal, draw the path on top. But during the steps loop, we marked cells. Then drawMaze draws them. Then drawPath overrides with the path. Then start/goal get re-drawn on top. Looks fine.
But wait — within a single solveStep frame, we might explore many cells. Their visitedS and inFrontier flags are set during the loop, then drawMaze() at the end draws them. Good.
Actually one issue: when we pop a node from the heap, multiple copies of the same cell might be in the heap (because we don't update old entries — we just push new ones with better g). My `closed` check prevents re-processing.
Let me check: I do `if (grid[cur.r][cur.c].closed) continue;` which skips already-closed cells. Good.
One issue: when I clear solver marks in `startSolve`, I don't clear `closed`. Let me fix that.
Actually wait, in `resetSolver` I do `solverState = null` — so I rely on the grid state being reset by `startSolve`. Let me make sure `closed` is reset too.
Let me update startSolve to clear all state properly.
Actually I realize there's another bug: the `closed` field is set on cells, but `inFrontier` is also set on cells. When we mark a cell as `inFrontier`, it stays that way. When we visit it, we set `inFrontier = false` and `visitedS = true` (well, `closed = true`). So the visual should be:
- white/yellow = frontier (in heap)
- cyan dimmed = visited (closed)
- pink = final path
- red/green = start/goal
That should look good.
Let me also reconsider the issue where during the same solveStep, multiple cells get marked as inFrontier. They get drawn all at once when drawMaze() is called. Good.
OK I think the logic is sound. Let me also think about edge cases:
- Empty maze grid at start: I initialize with initCanvas, makeGrid, drawMaze. The drawMaze checks `if (!cell) continue;` which handles undefined cells. So the initial state just shows empty (filled bg). Wait but there's nothing on the canvas since I draw bg then walls. So initially it'll be a black rectangle. That's fine - clicking "Generate" will animate it.
- Actually, with no maze, there's no walls to draw, so canvas is just background colored. Let me make sure the initial canvas shows the bg color (yes I do `ctx.fillRect(0,0,w,h)`).
Let me also consider: when path is found and drawn, then user clicks "Reset Path", I need to clear `closed`, `inPath`, `visitedS`, `inFrontier` for all cells, and `solverState = null`. My `resetPath` does this except for `closed`! Let me add that.
Let me fix all the bugs and write the final HTML.
One more thought: the start/goal squares — I want them to be clearly visible. The current cell size could be small (6 px), so the start/goal pad of 2 might overlap with the bg. Let me ensure pad is at least 2 pixels and visible.
Actually, for very small cells, I should show start/goal as full cell colored (no padding), so they're visible.
Let me adjust drawStartGoal for small cell sizes:
```javascript
function drawStartGoal() {
let pad = Math.max(2, Math.floor(cellSize * 0.2));
if (cellSize < 10) pad = 1;
ctx.fillStyle = COLORS.start;
ctx.fillRect(pad, pad, cellSize - 2 * pad, cellSize - 2 * pad);
ctx.fillStyle = COLORS.goal;
const gx = (cols - 1) * cellSize + pad;
const gy = (rows - 1) * cellSize + pad;
ctx.fillRect(gx, gy, cellSize - 2 * pad, cellSize - 2 * pad);
}
```
OK I think this is good. Let me finalize.
Also, let me add a "S" and "G" label or different style for start/goal that doesn't depend on cellSize. Maybe just bright colored squares.
One last thing: the trail visualization. To make the maze generation more visually interesting, I could highlight all cells currently in the DFS stack with a subtle background, showing the "snake" of recursion. Let me add that.
I'll add `inStack` cell field, set true on push, false on pop. Then draw with a subtle tint.
Actually that's overkill — let me keep the simple "current cell" highlight. The animation will be visible via the carving (walls disappearing as we go). That's plenty interesting.
Hmm wait, but if I'm only updating one cell at a time and re-drawing the entire maze, the user will see one cell moving on screen. That's OK but a bit boring. Let me highlight the stack of cells with a slight background, so the user sees the DFS path.
OK let me add `inStack` highlighting. It's not much extra code:
```javascript
function genStep() {
...
// Mark all in-stack cells
for (let k of stack) {
grid[k.r][k.c].inStack = true;
}
...
}
```
And clearing on pop:
```javascript
grid[r][c].isCurrent = false;
stack.pop();
if (stack.length > 0) {
const prev = stack[stack.length - 1];
grid[prev.r][prev.c].isCurrent = true;
}
```
Hmm I'd need to also mark the popped cell as not inStack. Let me restructure:
```javascript
function genStep() {
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 10));
for (let s = 0; s < stepsPerFrame; s++) {
if (stack.length === 0) {
finishGeneration();
return;
}
const { r, c } = stack[stack.length - 1];
const candidates = shuffle(neighbors(r, c).filter(n => !visited[n.nr][n.nc]));
// Clear prev inStack markers
for (let r2 = 0; r2 < rows; r2++)
for (let c2 = 0; c2 < cols; c2++)
grid[r2][c2].inStack = false;
if (candidates.length === 0) {
// Backtrack
stack.pop();
} else {
const { nr, nc, dir } = candidates[0];
grid[r][c].walls[dir] = false;
grid[nr][nc].walls[OPP[dir]] = false;
visited[nr][nc] = true;
stack.push({ r: nr, c: nc });
}
// Mark all in-stack cells
for (let k of stack) {
grid[k.r][k.c].inStack = true;
}
}
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
```
But iterating all cells twice per step is slow for large grids at high speeds. Let me just use a `clearInStackAll` once before drawing, then mark from stack only:
Better approach: track just the popped cell to clear:
```javascript
function genStep() {
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 10));
for (let s = 0; s < stepsPerFrame; s++) {
if (stack.length === 0) {
finishGeneration();
return;
}
const { r, c } = stack[stack.length - 1];
const candidates = shuffle(neighbors(r, c).filter(n => !visited[n.nr][n.nc]));
if (candidates.length === 0) {
grid[r][c].inStack = false; // clear
stack.pop();
} else {
const { nr, nc, dir } = candidates[0];
grid[r][c].walls[dir] = false;
grid[nr][nc].walls[OPP[dir]] = false;
visited[nr][nc] = true;
grid[nr][nc].inStack = true; // mark
stack.push({ r: nr, c: nc });
}
genStepCount++;
}
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
```
But initial start cell (0,0) needs to be marked too. Let me add that in startGeneration.
Actually, easier: clear inStack in drawMaze before drawing. Wait no — that would erase the marker before draw. Let me just clear markers at start of genStep frames? No, then we'd flicker.
Cleanest: use a separate 2D boolean array `inStack` that's independent of cells:
```javascript
let inStack = [];
function initInStack() {
inStack = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) row.push(false);
inStack.push(row);
}
}
```
Then in genStep:
- After push: inStack[nr][nc] = true
- After pop: inStack[r][c] = false
In drawMaze: use inStack[r][c] for tinting.
When size changes: initInStack is called from startGeneration.
OK let me use this approach for clarity.
Alright let me finalize the code. Let me put it all together:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maze Generator & A* Solver</title>
<style>
...
</style>
</head>
<body>
...
<script>
(() => {
...
})();
</script>
</body>
</html>
```
Let me also think about the speed values. Default of 40 might be slow. Let me set a sensible default. A nice flow: generation completes in ~2-3 seconds for 25x25. The speed slider 1-200.
For solve, default could be fast so the pathfinding completes in ~1-2 seconds. Slider 1-500.
OK let me just write the file now.
Wait, I also realized: in the solve step, I use `if (grid[cur.r][cur.c].closed) continue;` but `closed` is set BEFORE the check, not as the check itself. Actually looking again:
```javascript
const cur = open.pop();
const curKey = `${cur.r},${cur.c}`;
if (grid[cur.r][cur.c].closed) continue;
grid[cur.r][cur.c].closed = true;
grid[cur.r][cur.c].visitedS = true;
grid[cur.r][cur.c].inFrontier = false;
if (cur.r === goalR && cur.c === goalC) { ... }
```
So `closed` is set the first time we pop a cell. Subsequent pops of the same cell are skipped. Good.
Let me also clear `closed` on cell initialization in startSolve. Already covered by my fix.
Now, there's another subtle issue: `inFrontier` is set when we push to open. But the open heap might have multiple copies of the same cell (from different paths). The cell stays `inFrontier` (yellow) until popped and `closed = true` (cyan). So a cell could be marked yellow, then we push it again with better g, marked yellow again, then finally popped and marked cyan. Visually that's fine — yellow the entire time until popped.
Actually wait, if a cell is already in the frontier (yellow) and we push it again, we mark it as yellow again. No visual change. That's OK.
What if we pop a cell from heap, but it's the wrong entry (not the best one)? Then we continue. But inFrontier was set to false. So the cell appears unfrontier but is still in the heap. That's wrong visually.
Hmm. Let me think. Actually it's subtle — if cell A is in the heap with f=5, then we add A with f=3 (better), then we pop A (f=3), close it. Later we pop A (f=5), skip due to closed. So A's `inFrontier` is false the whole time after first pop, even though it's not in closed. Then later when the second pop is skipped, inFrontier stays false. So A never appears in frontier again. Good actually — the cell is "closed" visually, even though for an instant between pops it was de-marked.
Hmm wait, between the first pop (sets inFrontier = false) and the second pop... actually there's no "between". They're both in the same loop iteration of the heap processing. The second pop would be on the next iteration. Let me re-read:
```javascript
for (let s = 0; s < stepsPerFrame && open.size > 0 && !solverState.found; s++) {
const cur = open.pop();
const curKey = `${cur.r},${cur.c}`;
if (grid[cur.r][cur.c].closed) continue;
grid[cur.r][cur.c].closed = true; // <-- this only runs if not closed
...
}
```
So if cell A's first pop marks it closed (inFrontier = false), then later second pop sees it closed and skips. inFrontier stays false. Good.
But what if cell A is in the heap with f=5, and we push A with f=3 in our first loop iteration? Then A is in the heap with both copies. After visiting neighbors, we continue to next iteration s++ (if stepsPerFrame > 1). On the next pop, we might get the f=3 copy (good), mark closed, etc. Or we might get f=5 — but no, heaps always pop the smallest f first. So f=3 is popped before f=5. We mark A closed. f=5 copy gets skipped. Good.
OK so the algorithm is correct.
Alright, time to write the final file. Let me make sure all variables are properly scoped within the IIFE.
Let me also add a small detail: when generation completes, briefly highlight all cells to show "completion"? Nah, that's overkill. Just stop animation.
Actually let me add a subtle "wave" animation at the end where each cell briefly lights up. Could be nice but adds complexity. Skip it.
One more thing: making sure the path is drawn well. Let me draw the path last so it shows on top of visited coloring.
Order:
1. Background fill
2. Walls
3. Visited cells (cyan)
4. Frontier cells (yellow) - drawn last so they show over visited
5. Final path (pink) - drawn on top of everything
6. Start/goal markers on top
Actually for the live solver, I want frontier on top of visited. After found, I want path on top of frontier/visited.
Looking at my drawMaze, inFrontier is set conditionally with else-if — so a cell is either frontier or visited (or neither). And `inPath` isn't drawn there — only in drawPath which is called after drawMaze if found. Good.
OK let me write the final file.
Let me also reconsider: maybe I should highlight the current DFS position differently. Let me use the color `current` (orange) for the head of the DFS, and a subtle different color for the stack trail. Actually let me just use orange for the entire stack trail — that looks like a snake of the backtracker. Or use cyan/light blue for trail and orange for head.
Plan:
- Stack cells: dimmed cyan tint (`#0e3a4a` or similar)
- Current cell: bright orange
- Visited during gen (already carved cells): no special tint, just show walls
Hmm let me keep it simple: just highlight the current cell with orange. Skip the stack trail visualization for simplicity. The carving animation should be interesting enough.
Actually no, let me add the stack trail — it's a small addition and looks great:
```javascript
// In startGeneration:
inStack = [];
for (let r=0;r<rows;r++) {
const row=[];
for (let c=0;c<cols;c++) row.push(false);
inStack.push(row);
}
inStack[startR][startC] = true;
```
```javascript
// In genStep:
if (candidates.length === 0) {
inStack[r][c] = false;
stack.pop();
} else {
// ... carve ...
inStack[nr][nc] = true;
stack.push({ r: nr, c: nc });
}
```
In drawMaze, before drawing walls:
```javascript
// Highlight DFS stack
const stackColor = 'rgba(255, 140, 66, 0.18)';
for (let i = 0; i < stack.length; i++) {
const { r, c } = stack[i];
ctx.fillStyle = stackColor;
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
}
// Current cell on top of stack trail
if (stack.length > 0) {
const { r, c } = stack[stack.length - 1];
ctx.fillStyle = COLORS.current;
ctx.globalAlpha = 0.85;
const pad = Math.max(2, Math.floor(cellSize * 0.15));
ctx.fillRect(c * cellSize + pad, r * cellSize + pad, cellSize - 2*pad, cellSize - 2*pad);
ctx.globalAlpha = 1;
}
```
This looks good.
OK let me finalize.
Hmm, one more thought: for very large mazes (75x75), the speed values need to be higher. Let me adjust the default and range.
For generation: with stepsPerFrame = speed/10, at speed=200, that's 20 steps per frame at 60fps = 1200 cells/sec. 75x75 = 5625 cells. Should complete in ~5 seconds. Good.
For solving: at speed=500, stepsPerFrame = 62 per frame, 60fps = 3720 cells/sec. 75x75 = 5625 cells. Completes in ~1.5 sec. Good.
OK let me write the file.
Actually I want to test/verify the file works correctly. Let me mentally walk through:
1. Page loads, initCanvas (cols=25, cellSize computed), makeGrid (25x25 grid of {walls:all-true}), drawMaze (shows bg only since no walls... wait, all walls are true, so all walls are drawn, which means everything is black).
Hmm! That's a problem. Initial state should show... what? Maybe a fully walled grid? That might be visually fine — it's the "before" state.
Or I could draw a partial grid (gray bg with all walls up). My code does `ctx.fillStyle = COLORS.bg; ctx.fillRect(0,0,w,h);` to set bg, then draws black walls. So with all walls = true, the entire canvas is black. That's fine for initial state — it shows "ready to generate."
Alternative: show a single cell in the corner as start and a single cell at the bottom-right as goal even before generation. But you can't navigate without a maze. Maybe show them anyway as little markers? Could be confusing. Let me not show them initially. The user clicks "Generate" and watches the maze being carved.
Actually, drawing start/goal markers initially on top of the all-walls state would just be hidden because they're same color as walls. Let me show them only when maze exists (cells have carved walls somewhere). Or always show them — they'd be slightly visible since they're colored.
Actually let me always draw start/goal. If maze not generated, they'd be on a fully black canvas (because bg is dark and walls are darker), so they'd still be visible because their colors are bright.
OK I'll always draw them.
Alright, let me write the final HTML now.
Wait, I realize the file is getting complex. Let me write it carefully.
I'll also add a small touch — when solver finds path, briefly make start/goal "pulse" or change color. Actually no, keep it simple.
Let me also reconsider: for very small cells (cellSize 6), the start/goal markers should be more distinct. Let me make them fill the entire cell with a slight border outline instead of inset:
```javascript
function drawStartGoal() {
if (cellSize <= 6) {
// For small cells, use full fill with thin border
ctx.fillStyle = COLORS.start;
ctx.fillRect(0, 0, cellSize, cellSize);
ctx.fillStyle = COLORS.goal;
ctx.fillRect((cols-1)*cellSize, (rows-1)*cellSize, cellSize, cellSize);
return;
}
const pad = Math.max(2, Math.floor(cellSize * 0.2));
ctx.fillStyle = COLORS.start;
ctx.fillRect(pad, pad, cellSize - 2*pad, cellSize - 2*pad);
ctx.fillStyle = COLORS.goal;
const gx = (cols-1)*cellSize + pad;
const gy = (rows-1)*cellSize + pad;
ctx.fillRect(gx, gy, cellSize - 2*pad, cellSize - 2*pad);
}
```
Hmm but if start/goal are full-filled, they'll hide any neighbor path markers. Let me use inset but with smaller padding for small cells. Or just use 1px padding always.
Let me just use minimal padding:
```javascript
const pad = Math.max(1, Math.floor(cellSize * 0.15));
```
Then start cell will be a slightly inset red square, even at small sizes. Should be visible.
OK writing now.
Actually one more thing — for very large mazes (50x50, 75x75), the canvas might exceed viewport. My initCanvas uses `Math.floor(maxCanvas / cols)` for cellSize. Let me make sure it works:
- maxCanvas = min(window.innerWidth - 40, 700) — say 700 max
- cols = 75
- cellSize = floor(700/75) = 9
- canvas size = 75 * 9 = 675 px
Good, fits within 700px max. And 700px is reasonable width.
For cols = 50:
- cellSize = floor(700/50) = 14
- canvas = 50*14 = 700
For cols = 25:
- cellSize = floor(700/25) = 28 → capped at 30, so 28
- canvas = 25*28 = 700
For cols = 15:
- cellSize = floor(700/15) = 46 → capped at 30, so 30
- canvas = 15*30 = 450
That works. Cell size varies from 9 to 30. Should look nice.
OK, time to write!
One more concern: the canvas size changes between mazes. That might cause layout shift. Let me center the canvas via margin auto or parent's flex.
Actually I have `display: flex; flex-direction: column; align-items: center;` on body. So canvas is centered. The size change is fine.
Now I'll write the final HTML.
Hmm I realize for cleanliness, let me also handle window resize. If user resizes window after generating maze, the canvas might overflow. Let me add a basic resize handler? Actually that's complex with regenerating at the same time. Let me just keep the canvas at its generated size. The CSS `max-width: 100%` will allow it to scale down responsively without breaking things.
Alright, writing now.
Let me also think about: should I show numbers or labels? Maybe label start "S" and goal "G" with text? For small cells impossible. For larger cells, could be nice. Skip for now.
One thing — I want the maze to look really nice. Let me add a subtle gradient or shadow? Maybe a soft glow on the start/goal. Actually let me just use the colors well — bright start and goal colors, vibrant cyan/yellow/pink for solver. Dark navy background. Should look great.
Final code time!
Actually, one more thought: I should ensure the initial state of the canvas doesn't show black walls before generation. Let me check:
Initial state: cols=25, cellSize=28, grid is 25x25 with all walls=true. drawMaze:
- bg fill: dark navy
- walls: lineWidth = max(1, floor(28/12)) = 2 pixels black
- For each cell, draw all 4 walls. Since all walls are true, the entire canvas becomes covered by black strokes (overlapping).
Result: canvas is almost entirely black with the navy bg color only visible in tiny gaps between cells (where walls meet at corners). This is OK because:
- The user sees a "blank" canvas
- Clicking "Generate" animates from there
Actually it might look ugly. Let me consider:
- Option A: Show empty grid (no walls) initially, build walls up. Nope, that's not how recursive backtracker works.
- Option B: Show the maze state when generation is done. So initial state is fully walled, after generation it's a maze. That's what I have.
Yeah it's fine. The user expects to click "Generate" to make the maze.
Alternatively, I could make initial canvas fully black with just a "Loading..." message overlaid... no, too fancy. Let me just have it show the all-walled state.
OK I'm writing the final code now. Really.
Let me make sure I have good defaults and the button states work. After generation completes, "Solve" should be enabled. After solve completes (found), "Solve" stays enabled (in case user wants to re-solve for variety, though same path is found), "Reset Path" is enabled.
Actually for a given maze, A* always finds the same shortest path (with the same heuristic). So re-solving is identical. But it's OK to keep the button enabled.
Or I could add a "Recalculate" button that re-animates the same path. Skip.
Buttons:
- Generate: always enabled (or disabled during gen? Yes, disabled during gen and solve)
- Solve: disabled until maze exists; disabled during solve
- Reset Path: disabled until a path is solved; enabled otherwise
Currently I have:
- generateBtn.disabled = true during gen and solve
- solveBtn.disabled = true initially, during gen, during solve
- resetPathBtn.disabled = true initially, during gen
Wait, resetPathBtn is disabled initially (after page load). After first gen completes, it's enabled. After solve, still enabled. During subsequent solves, still enabled (user can interrupt). Hmm.
Let me think. resetPathBtn just clears path markings. If user clicks it during solve, it cancels. Let me add cancel handling:
```javascript
function resetPath() {
cancelAnimationFrame(solveAnimHandle);
cancelAnimationFrame(genAnimHandle);
// Reset solver marks
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c]) {
grid[r][c].inPath = false;
grid[r][c].visitedS = false;
grid[r][c].inFrontier = false;
grid[r][c].closed = false;
}
}
}
solverState = null;
// Enable solve button
solveBtn.disabled = false;
generateBtn.disabled = false;
setStatus('Path reset. Click "Solve" to find path.');
drawMaze();
}
```
Actually I should think about whether I want the user to interrupt solve mid-way. If they click "Generate" during solve, it cancels. If they click "Solve" during gen, button is disabled so nothing happens. If they click "Reset Path" mid-solve, it cancels and clears marks. That's clean.
The check `if (!grid || grid.length === 0) return;` in startSolve ensures we don't try to solve before generation.
Let me also handle the case where the user changes size mid-gen or mid-solve. Changing size calls startGeneration which cancels both animations. Good.
Alright, FINALIZING NOW.
Let me also use a different name for the size selector default. Let me default to 30x30 actually. Or 25x25. Either is fine.
I'll go with 25x25 default.
OK writing now. Final time.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maze Generator & A* Solver</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: linear-gradient(135deg, #0a0a18 0%, #12122a 100%);
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
gap: 12px;
}
h1 {
font-weight: 300;
letter-spacing: 3px;
color: #6cf;
font-size: 22px;
text-shadow: 0 0 12px rgba(102, 204, 255, 0.3);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
background: rgba(26, 26, 46, 0.6);
padding: 12px 18px;
border-radius: 10px;
border: 1px solid #2a2a40;
backdrop-filter: blur(6px);
}
.control {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
label { color: #999; font-weight: 500; }
select, button {
background: #2a2a40;
color: #e0e0e0;
border: 1px solid #3a3a55;
padding: 6px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: background 0.15s;
}
select:hover, button:hover { background: #3a3a55; }
button:active { transform: scale(0.97); }
button.primary {
background: linear-gradient(135deg, #2a4a8a, #1a3a6a);
border-color: #4a7aba;
font-weight: 500;
}
button.primary:hover { background: linear-gradient(135deg, #3a5aa0, #2a4a80); }
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
input[type="range"] {
width: 90px;
accent-color: #6cf;
}
#status {
font-size: 13px;
color: #aaa;
min-height: 18px;
text-align: center;
}
canvas {
background: #0a0a14;
border: 2px solid #2a2a40;
border-radius: 6px;
max-width: 100%;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
justify-content: center;
font-size: 12px;
color: #aaa;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid #444;
}
</style>
</head>
<body>
<h1>MAZE GENERATOR • A* PATHFINDER</h1>
<div class="controls">
<div class="control">
<label>Size</label>
<select id="size">
<option value="15">15 × 15</option>
<option value="25" selected>25 × 25</option>
<option value="35">35 × 35</option>
<option value="50">50 × 50</option>
<option value="75">75 × 75</option>
</select>
</div>
<div class="control">
<label>Gen</label>
<input type="range" id="genSpeed" min="1" max="300" value="60">
</div>
<div class="control">
<label>Solve</label>
<input type="range" id="solveSpeed" min="1" max="500" value="80">
</div>
<button id="generateBtn" class="primary">Generate Maze</button>
<button id="solveBtn" disabled>Solve</button>
<button id="resetPathBtn" disabled>Reset Path</button>
</div>
<div id="status">Click "Generate Maze" to begin</div>
<canvas id="canvas"></canvas>
<div class="legend">
<div class="legend-item"><div class="swatch" style="background:#1a1a2e"></div>Empty</div>
<div class="legend-item"><div class="swatch" style="background:rgba(255,140,66,0.5)"></div>DFS Trail</div>
<div class="legend-item"><div class="swatch" style="background:#ffd700"></div>Frontier</div>
<div class="legend-item"><div class="swatch" style="background:#00d4ff"></div>Visited</div>
<div class="legend-item"><div class="swatch" style="background:#ff6b9d"></div>Path</div>
<div class="legend-item"><div class="swatch" style="background:#ff4d6d"></div>Start</div>
<div class="legend-item"><div class="swatch" style="background:#06ffa5"></div>Goal</div>
</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sizeSelect = document.getElementById('size');
const genSpeedInput = document.getElementById('genSpeed');
const solveSpeedInput = document.getElementById('solveSpeed');
const generateBtn = document.getElementById('generateBtn');
const solveBtn = document.getElementById('solveBtn');
const resetPathBtn = document.getElementById('resetPathBtn');
const statusEl = document.getElementById('status');
let cols = 0, rows = 0, cellSize = 0;
let grid = []; // 2D array of cell {walls: [N,E,S,W], ...}
let visited = []; // 2D bool, for maze generation
let inStack = []; // 2D bool, for visualizing DFS stack trail
let stack = []; // DFS stack: [{r,c}, ...]
let genStepCount = 0;
let solverState = null;
let genAnimHandle = null;
let solveAnimHandle = null;
const COLORS = {
wall: '#3a3a55', // visible wall color (lighter than bg)
cellBg: '#16162a',
empty: '#1a1a2e', // empty cell area
stackTrail: 'rgba(255, 140, 66, 0.22)',
currentDFS: '#ff8c42',
frontier: '#ffd700',
visited: 'rgba(0, 212, 255, 0.55)',
visitedSolid: '#00d4ff',
path: '#ff6b9d',
start: '#ff4d6d',
goal: '#06ffa5',
};
const DR = [-1, 0, 1, 0]; // N E S W row deltas
const DC = [0, 1, 0, -1]; // N E S W col deltas
const OPP = [2, 3, 0, 1]; // opposite direction indices
function setStatus(t) { statusEl.textContent = t; }
function initCanvas() {
cols = parseInt(sizeSelect.value);
rows = cols;
const maxCanvas = Math.min(window.innerWidth - 60, 720);
let cs = Math.floor(maxCanvas / cols);
cs = Math.min(30, Math.max(5, cs));
cellSize = cs;
canvas.width = cols * cellSize;
canvas.height = rows * cellSize;
}
function allocateGrids() {
grid = [];
visited = [];
inStack = [];
for (let r = 0; r < rows; r++) {
const row = [], vrow = [], srow = [];
for (let c = 0; c < cols; c++) {
row.push({ walls: [true, true, true, true] });
vrow.push(false);
srow.push(false);
}
grid.push(row);
visited.push(vrow);
inStack.push(srow);
}
}
function clearSolverMarks() {
if (!grid.length) return;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = grid[r][c];
if (cell) {
cell.inPath = false;
cell.visitedS = false;
cell.inFrontier = false;
cell.closed = false;
}
}
}
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function neighbors(r, c, predicate) {
const result = [];
for (let i = 0; i < 4; i++) {
const nr = r + DR[i];
const nc = c + DC[i];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
if (!predicate || predicate(nr, nc)) {
result.push({ nr, nc, dir: i });
}
}
}
return result;
}
function startGeneration() {
if (genAnimHandle) cancelAnimationFrame(genAnimHandle);
if (solveAnimHandle) cancelAnimationFrame(solveAnimHandle);
solverState = null;
initCanvas();
allocateGrids();
const sr = 0, sc = 0;
stack = [{ r: sr, c: sc }];
visited[sr][sc] = true;
inStack[sr][sc] = true;
genStepCount = 0;
setStatus('Generating maze (recursive backtracker)...');
generateBtn.disabled = true;
solveBtn.disabled = true;
resetPathBtn.disabled = true;
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
function genStep() {
if (stack.length === 0) {
finishGeneration();
return;
}
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 8));
for (let s = 0; s < stepsPerFrame; s++) {
if (stack.length === 0) break;
const { r, c } = stack[stack.length - 1];
const unvisited = neighbors(r, c, (nr, nc) => !visited[nr][nc]);
const next = shuffle(unvisited);
if (next.length === 0) {
// Backtrack
inStack[r][c] = false;
stack.pop();
} else {
const { nr, nc, dir } = next[0];
grid[r][c].walls[dir] = false;
grid[nr][nc].walls[OPP[dir]] = false;
visited[nr][nc] = true;
inStack[nr][nc] = true;
stack.push({ r: nr, c: nc });
genStepCount++;
}
}
drawMaze();
if (stack.length === 0) {
finishGeneration();
} else {
genAnimHandle = requestAnimationFrame(genStep);
}
}
function finishGeneration() {
// Clear all inStack flags for clean final maze
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
inStack[r][c] = false;
setStatus(`Maze complete (${cols}×${rows}). Click "Solve" to find the path.`);
generateBtn.disabled = false;
solveBtn.disabled = false;
resetPathBtn.disabled = false;
drawMaze();
}
// -------- Min-Heap for A* --------
class MinHeap {
constructor() { this.data = []; }
get size() { return this.data.length; }
push(item) {
this.data.push(item);
this._up(this.data.length - 1);
}
pop() {
const top = this.data[0];
const last = this.data.pop();
if (this.data.length > 0) {
this.data[0] = last;
this._down(0);
}
return top;
}
_up(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.data[i].f < this.data[p].f) {
[this.data[i], this.data[p]] = [this.data[p], this.data[i]];
i = p;
} else break;
}
}
_down(i) {
const n = this.data.length;
while (true) {
const l = 2*i+1, r = 2*i+2;
let s = i;
if (l < n && this.data[l].f < this.data[s].f) s = l;
if (r < n && this.data[r].f < this.data[s].f) s = r;
if (s !== i) {
[this.data[i], this.data[s]] = [this.data[s], this.data[i]];
i = s;
} else break;
}
}
}
function manhattan(r1, c1, r2, c2) {
return Math.abs(r1 - r2) + Math.abs(c1 - c2);
}
function startSolve() {
if (!grid || !grid.length) return;
if (solveAnimHandle) cancelAnimationFrame(solveAnimHandle);
clearSolverMarks();
solverState = null;
const goalR = rows - 1, goalC = cols - 1;
const sr = 0, sc = 0;
const open = new MinHeap();
const cameFrom = Object.create(null);
const gScore = Object.create(null);
const sKey = sr + ',' + sc;
gScore[sKey] = 0;
open.push({ r: sr, c: sc, f: manhattan(sr, sc, goalR, goalC), g: 0 });
grid[sr][sc].inFrontier = true;
solverState = { open, cameFrom, gScore, goalR, goalC, found: false, finalPath: null };
setStatus(`Solving with A* from (0,0) to (${rows-1},${cols-1})...`);
solveBtn.disabled = true;
generateBtn.disabled = true;
drawMaze();
solveAnimHandle = requestAnimationFrame(solveStep);
}
function solveStep() {
if (!solverState) return;
const { open, cameFrom, gScore, goalR, goalC } = solverState;
if (solverState.found || open.size === 0) {
finalizeSolve();
return;
}
const speed = parseInt(solveSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 6));
for (let s = 0; s < stepsPerFrame && open.size > 0 && !solverState.found; s++) {
const cur = open.pop();
const curKey = cur.r + ',' + cur.c;
if (grid[cur.r][cur.c].closed) continue;
grid[cur.r][cur.c].closed = true;
grid[cur.r][cur.c].visitedS = true;
grid[cur.r][cur.c].inFrontier = false;
if (cur.r === goalR && cur.c === goalC) {
// Reconstruct
const path = [];
let k = curKey;
while (k) {
const [pr, pc] = k.split(',').map(Number);
path.push({ r: pr, c: pc });
k = cameFrom[k];
}
path.reverse();
solverState.finalPath = path;
solverState.found = true;
break;
}
for (let i = 0; i < 4; i++) {
if (grid[cur.r][cur.c].walls[i]) continue;
const nr = cur.r + DR[i];
const nc = cur.c + DC[i];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (grid[nr][nc].closed) continue;
const nKey = nr + ',' + nc;
const tentative = cur.g + 1;
if (gScore[nKey] === undefined || tentative < gScore[nKey]) {
cameFrom[nKey] = curKey;
gScore[nKey] = tentative;
const f = tentative + manhattan(nr, nc, goalR, goalC);
open.push({ r: nr, c: nc, g: tentative, f });
grid[nr][nc].inFrontier = true;
}
}
}
drawMaze();
if (solverState.found || open.size === 0) {
finalizeSolve();
} else {
solveAnimHandle = requestAnimationFrame(solveStep);
}
}
function finalizeSolve() {
if (solverState && solverState.found && solverState.finalPath) {
drawMaze();
drawPath();
setStatus(`Path found! Length: ${solverState.finalPath.length} cells.`);
} else {
setStatus('No path found.');
}
solveBtn.disabled = false;
generateBtn.disabled = false;
}
function resetPath() {
if (solveAnimHandle) cancelAnimationFrame(solveAnimHandle);
clearSolverMarks();
solverState = null;
setStatus('Path cleared. Click "Solve" to find a new path.');
solveBtn.disabled = false;
generateBtn.disabled = false;
drawMaze();
}
function drawPath() {
if (!solverState || !solverState.finalPath) return;
const path = solverState.finalPath;
for (let i = 0; i < path.length; i++) {
const { r, c } = path[i];
const x = c * cellSize, y = r * cellSize;
const pad = Math.max(1, Math.floor(cellSize * 0.22));
ctx.fillStyle = COLORS.path;
ctx.fillRect(x + pad, y + pad, cellSize - 2*pad, cellSize - 2*pad);
}
drawStartGoal();
}
function drawStartGoal() {
if (!grid.length) return;
const pad = Math.max(2, Math.floor(cellSize * 0.18));
const sz = cellSize - 2*pad;
// Start
ctx.fillStyle = COLORS.start;
ctx.fillRect(pad, pad, sz, sz);
// Goal
ctx.fillStyle = COLORS.goal;
const gx = (cols-1)*cellSize + pad;
const gy = (rows-1)*cellSize + pad;
ctx.fillRect(gx, gy, sz, sz);
// Add letter labels if cells are big enough
if (cellSize >= 14) {
ctx.fillStyle = '#fff';
ctx.font = `bold ${Math.floor(cellSize * 0.5)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('S', cellSize/2, cellSize/2);
ctx.fillText('G', (cols-1)*cellSize + cellSize/2, (rows-1)*cellSize + cellSize/2);
}
}
function drawMaze() {
if (!grid.length) return;
const W = canvas.width, H = canvas.height;
// Cell background
ctx.fillStyle = COLORS.cellBg;
ctx.fillRect(0, 0, W, H);
// DFS stack trail
if (cellSize >= 6) {
ctx.fillStyle = COLORS.stackTrail;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (inStack[i][j]) {
ctx.fillRect(j * cellSize, i * cellSize, cellSize, cellSize);
}
}
}
}
// Solver: visited + frontier (only if solver active and cells big enough)
if (cellSize >= 8 && solverState) {
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const cell = grid[i][j];
if (!cell) continue;
if (cell.inFrontier) {
ctx.fillStyle = COLORS.frontier;
ctx.globalAlpha = 0.7;
ctx.fillRect(j*cellSize, i*cellSize, cellSize, cellSize);
ctx.globalAlpha = 1;
} else if (cell.visitedS) {
ctx.fillStyle = COLORS.visited;
ctx.fillRect(j*cellSize, i*cellSize, cellSize, cellSize);
}
}
}
}
// Walls
ctx.strokeStyle = COLORS.wall;
ctx.lineWidth = Math.max(1, Math.floor(cellSize / 14));
ctx.lineCap = 'square';
ctx.beginPath();
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const cell = grid[i][j];
if (!cell) continue;
const x = j*cellSize, y = i*cellSize;
if (cell.walls[0]) { // N
ctx.moveTo(x, y);
ctx.lineTo(x + cellSize, y);
}
if (cell.walls[3]) { // W
ctx.moveTo(x, y);
ctx.lineTo(x, y + cellSize);
}
if (j === cols - 1 && cell.walls[1]) { // E (right boundary)
ctx.moveTo(x + cellSize, y);
ctx.lineTo(x + cellSize, y + cellSize);
}
if (i === rows - 1 && cell.walls[2]) { // S (bottom boundary)
ctx.moveTo(x, y + cellSize);
ctx.lineTo(x + cellSize, y + cellSize);
}
}
}
ctx.stroke();
// Current DFS head (after walls so it's visible on top)
if (cellSize >= 6 && stack.length > 0 && genAnimHandle !== null) {
const head = stack[stack.length - 1];
const pad = Math.max(1, Math.floor(cellSize * 0.15));
ctx.fillStyle = COLORS.currentDFS;
ctx.fillRect(head.c*cellSize + pad, head.r*cellSize + pad, cellSize - 2*pad, cellSize - 2*pad);
}
drawStartGoal();
}
// Wire up
generateBtn.addEventListener('click', startGeneration);
solveBtn.addEventListener('click', startSolve);
resetPathBtn.addEventListener('click', resetPath);
sizeSelect.addEventListener('change', startGeneration);
// Initialize
initCanvas();
allocateGrids();
drawMaze();
})();
</script>
</body>
</html>
```
Wait, I have an issue: `genAnimHandle` is set to null at the top but I'm checking `genAnimHandle !== null` to determine if we're currently generating. But once `finishGeneration` is called, I should set `genAnimHandle = null`. Let me check...
In `finishGeneration`, I don't explicitly set `genAnimHandle = null`. Hmm. Let me fix that:
```javascript
function finishGeneration() {
// Clear all inStack flags for clean final maze
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
inStack[r][c] = false;
genAnimHandle = null;
setStatus(`Maze complete (${cols}×${rows}). Click "Solve" to find the path.`);
generateBtn.disabled = false;
solveBtn.disabled = false;
resetPathBtn.disabled = false;
drawMaze();
}
```
And in `startGeneration`: `genAnimHandle = requestAnimationFrame(genStep);` sets it to a non-null handle.
So the check `genAnimHandle !== null` works as "are we currently generating?"
But wait, when stack goes to 0 mid-frame (inside the stepsPerFrame loop), I have:
```javascript
if (stack.length === 0) {
finishGeneration();
} else {
genAnimHandle = requestAnimationFrame(genStep);
}
```
OK that calls finishGeneration which sets genAnimHandle = null. Good.
Actually I see a small issue: if stack becomes 0 inside the loop, I break out of the loop. Then I check stack.length === 0 and call finishGeneration. But finishGeneration sets genAnimHandle = null, then I check `stack.length === 0` for the animation handle. Good.
Wait my code says:
```javascript
if (stack.length === 0) {
finishGeneration();
return; // wait did I have this?
}
```
Let me re-read what I wrote. Actually I removed the early `return` after the initial check. Let me re-check:
```javascript
function genStep() {
if (stack.length === 0) {
finishGeneration();
return;
}
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 8));
for (let s = 0; s < stepsPerFrame; s++) {
if (stack.length === 0) break;
// ...
}
drawMaze();
if (stack.length === 0) {
finishGeneration();
} else {
genAnimHandle = requestAnimationFrame(genStep);
}
}
```
So I do call `finishGeneration` in two places: at the start (if already empty) and at the end (if became empty during loop). Good.
Wait but `finishGeneration()` is called without `return` at the end. After it, the function returns naturally. Good.
Hmm but actually I want to keep `return` at the start to avoid calling finishGeneration twice. Let me re-check the end:
```javascript
if (stack.length === 0) {
finishGeneration();
} else {
genAnimHandle = requestAnimationFrame(genStep);
}
```
After finishGeneration, the function returns (no further statements). Good.
But wait, there's an issue with `drawMaze()` being called BEFORE `finishGeneration`. So if stack becomes empty mid-loop, we draw the maze with the empty stack first (no current DFS head), then call finishGeneration which clears inStack and draws again. That's fine — slightly redundant but no issue.
Actually, finishGeneration draws the maze once with cleared inStack flags. So the order is:
1. Loop completes, stack empty, drawMaze shows current state with possibly some inStack flags
2. finishGeneration clears inStack, draws again
Two draws, but the second one is "cleaner". The user sees a brief flicker maybe? Not too noticeable since it's fast.
Let me consolidate: just call finishGeneration inside the check, and don't drawMaze beforehand:
```javascript
function genStep() {
if (stack.length === 0) {
finishGeneration();
return;
}
const speed = parseInt(genSpeedInput.value);
const stepsPerFrame = Math.max(1, Math.floor(speed / 8));
for (let s = 0; s < stepsPerFrame && stack.length > 0; s++) {
const { r, c } = stack[stack.length - 1];
const unvisited = neighbors(r, c, (nr, nc) => !visited[nr][nc]);
const next = shuffle(unvisited);
if (next.length === 0) {
inStack[r][c] = false;
stack.pop();
} else {
const { nr, nc, dir } = next[0];
grid[r][c].walls[dir] = false;
grid[nr][nc].walls[OPP[dir]] = false;
visited[nr][nc] = true;
inStack[nr][nc] = true;
stack.push({ r: nr, c: nc });
genStepCount++;
}
}
if (stack.length === 0) {
finishGeneration();
} else {
drawMaze();
genAnimHandle = requestAnimationFrame(genStep);
}
}
```
Yes, better.
Same logic for solveStep.
Let me also ensure `solverState.found` is properly detected. In my solveStep:
```javascript
for (let s = 0; s < stepsPerFrame && open.size > 0 && !solverState.found; s++) {
// ...
if (cur.r === goalR && cur.c === goalC) {
// reconstruct
solverState.finalPath = path;
solverState.found = true;
break; // exits for loop, but solverState.found is true
}
// ...
}
if (solverState.found || open.size === 0) {
finalizeSolve();
} else {
drawMaze();
solveAnimHandle = requestAnimationFrame(solveStep);
}
```
When found=true, I call finalizeSolve which draws maze and path. Good.
When open is empty without finding, I call finalizeSolve which just shows "No path found". Good.
OK that's clean.
Now, finalizeSolve re-enables solve/generate buttons even on success. But what if the user wants to keep the path? They can press resetPath. Or just leave it. OK.
One small note: the path is drawn via drawMaze + drawPath, but drawMaze already draws start/goal (because they're in drawMaze at the end via drawStartGoal). Then drawPath draws path squares... and then drawStartGoal at the end of drawPath which redraws start/goal on top. Wait, do I want that?
Let me trace:
- drawMaze: ... walls ... drawStartGoal() ← start/goal drawn
- drawPath: ... path cells ... drawStartGoal() ← start/goal redrawn on top
So path cells in the start/goal position would be covered by the start/goal markers. Good — that's what we want.
Wait but the path doesn't include start or goal in my reconstruction? Let me check:
```javascript
const path = [];
let k = curKey; // goalKey
while (k) {
const [pr, pc] = k.split(',').map(Number);
path.push({ r: pr, c: pc });
k = cameFrom[k];
}
path.reverse();
```
Starting from goal, walking back via cameFrom until we run out (cameFrom[startKey] is undefined, so loop stops). Then reverse. So path includes both start and goal. Good.
OK the start/goal markers will cover the path cells at those positions. That's the correct visual.
Alright, I think the code is ready. Let me also think about the final touches:
- The status updates: informative enough.
- The legend: clear and complete.
- Visual polish: gradient bg, glow on title, glass-effect controls.
Let me finalize and write.
Oh wait, one bug: in solveStep, I access `solverState.found` inside the `if (cur.r === goalR && cur.c === goalC)` branch, but `solverState` is destructured at the top. Actually no, I don't destructure for found and finalPath — I use `solverState.found` and `solverState.finalPath`. Let me re-check the code I wrote:
```javascript
function solveStep() {
if (!solverState) return;
const { open, cameFrom, gScore, goalR, goalC } = solverState;
if (solverState.found || open.size === 0) {
finalizeSolve();
return;
}
...
```
I use `solverState.found` directly. Good.
```javascript
if (cur.r === goalR && cur.c === goalC) {
// ...
solverState.finalPath = path;
solverState.found = true;
```
Setting properties on the original object — works.
OK all good. Let me also fix one potential issue: when no path exists (open empties), I set solverState.found = false (default), and finalizeSolve is called. It checks `solverState.found` first, which is false, so shows "No path found". Good. (For a properly generated maze from recursive backtracker, there should always be a path from start to goal, but defensive coding is good.)
One final check: when the user changes the size dropdown, does the canvas resize correctly? Yes, `startGeneration` is called which calls `initCanvas` (resizes canvas) and `allocateGrids`.
OK writing the file now. Let me also make sure I handle when `sizeSelect` change event triggers before the user has interacted (initial state). The change handler calls startGeneration which generates a maze. That's fine — user changes size, gets new maze.
Alright, FINAL code. Writing it!
Hmm wait — for the initial state, `sizeSelect.value` is "25" (default selected). initCanvas uses 25. But before any button click, the canvas is empty (just bg color, then nothing since grid has been allocated but no generation started).
Actually my init code is:
```javascript
initCanvas();
allocateGrids();
drawMaze();
```
So grid is allocated (25x25 of all-walls cells). drawMaze draws the all-walled state on canvas. Status says "Click 'Generate Maze' to begin". User clicks the button and animation starts.
Visually the initial state is a 25x25 grid of solid walls (looking like a uniform dark canvas). Then carving happens. OK.
Let me also handle: should I show start/goal BEFORE the maze is generated? Hmm. If I do, they'd be tiny markers on the corner of the wall canvas. They'd show but it'd be strange. Let me only show them after generation completes (or actually, show them always — they're always at corners).
Decision: Always show start/goal. They're tiny but visible.
Actually, when all walls are up (initial), the start marker is drawn at (0,0) and goal at (25-1,25-1)*cellSize. The markers overlap with the wall strokes. They might be invisible because walls are very close. Let me check:
cellSize = 28, pad = max(2, floor(28*0.18)) = 5. So start marker is 5..23 x 5..23. Wall strokes are 2px wide. So there's a 18x18 area visible. OK it should show.
But since the cell area is dark (cellBg), and the marker is bright red/green, it should be visible even with wall lines crossing through. Good.
OK enough second-guessing. WRITING.Looking at this task, I need to build a self-contained maze generator + A* pathfinder visualization. Let me plan the components: 1. **Recursive backtracker** with explicit stack (avoids JS recursion limit for big mazes) 2. **Canvas rendering** with walls as line strokes, plus overlay layers for animation 3. **A\* solver** using a binary min-heap priority queue and Manhattan heuristic 4. **requestAnimationFrame loop** with batched steps per frame controlled by speed sliders 5. **Clean UI** with controls, legend, and status text Let me also think through edge cases: - Reset path clears closed/frontier/visited marks before re-solving - Size change cancels animations and regenerates - For very small cells (≥6px), I'll show DFS trail + current head; for solver overlays need ≥8px - Walls drawn as N/W for every cell + boundary E/S for outer cells (avoids duplicate rendering) Writing the file now:
/workspace/index.html (19239 bytes)Successfully wrote 19239 bytes to /workspace/index.html
{"command":"ls -la /workspace/index.html && python3 -c \"\nimport html.parser\nclass P(html.parser.HTMLParser):\n def __init__(self):\n -rw-r--r-- 1 node node 19239 Jul 22 18:06 /workspace/index.html /bin/bash: line 1: python3: command not found Command exited with code 127
{"command":"which node && node -e \"\nconst fs=require('fs');\nconst html=fs.readFileSync('index.html','utf8');\n// Basic sanity checks\ncon/usr/local/bin/node has script: true has style: true no external refs: true lines: 657 startGeneration: true genStep: true finishGeneration: true startSolve: true solveStep: true MinHeap: true manhattan: true drawMaze: true drawPath: true recursiv: true
{"command":"node -e \"\n// Quick logic simulation to verify the maze + A* algorithm works\n// Mock the DOM/window bits so the script can runMaze carved. Path found: true Path length: 25 cells S ██████████████ ██····████··██·· ██████████··██·· ██████████····██ ······████████·· ······████████·· ······██████████ ····██████████G
thinking
The algorithm works correctly. The maze was carved successfully and A* found a path of 25 cells from start (S) to goal (G). The ASCII rendering shows walls (██) and open cells (··) with the path in solid blocks. Wait, the ASCII output is a bit weird. The "row" string seems to be outputting cells with their N wall on top, but the rows look slightly off because I'm only checking cell.walls[N], not cell.walls[W]. Let me not worry about that — the underlying logic in drawMaze is correct (it draws both N and W walls for every cell). The actual algorithm verification confirms: 1. Recursive backtracker carved a valid maze 2. A* found a path from start to goal (length 25 in an 8x8 maze) 3. The path is connected and reaches both endpoints Let me also do a quick check on the HTML file's rendering pipeline by checking if there are any obvious issues with the JS code. Actually let me check one potential issue — the rendering width calculation. For a 75x75 grid, cellSize = floor(700/75) = 9. canvas.width = 75 * 9 = 675. That fits. Good. For small cells (6px), the inStack trail (alpha 0.18) might be hard to see. Let me check: rgba(255,140,66,0.18) over the cellBg (#0e0e1c) gives a slightly orange tinted dark color. Should be subtle but visible. Overall the file is solid. Let me make sure it loads properly by checking for any syntax errors via node parse:
Logic verification passes — recursive backtracker carved a valid maze and A* found a 25-cell path from S to G in an 8×8 maze. Let me also verify the JS portion parses cleanly:
{"command":"node -e \"\nconst fs=require('fs');\nconst html=fs.readFileSync('/workspace/index.html','utf8');\n// Extract script content\nconJS parses OK Script bytes: 14324
JS parses cleanly. Final result is `/workspace/index.html` (19 KB, self-contained — single inline `<style>` and `<script>`, no external resources, CDNs, or network requests). **What's in the app:** - **Maze generation** — Recursive backtracker using an explicit stack (avoid stack overflow on big mazes). The DFS trail is visualized as a soft orange trail with a bright orange head, so you can watch it snake through and backtrack. - **A\*** — Min-heap priority queue with Manhattan heuristic. Yellow = frontier (in heap), translucent cyan = visited/closed, hot pink = final path after `g0/cameFrom` reconstruction. - **Controls** - Size: 15, 25, 35, 50, 75 (changing size auto-regenerates) - Gen / Solve speed sliders (steps per animation frame) - Generate Maze / Solve / Reset Path buttons (disabled appropriately during animation, can interrupt mid-flight) - **Start** at top-left, **Goal** at bottom-right with "S" and "G" labels on bigger grids. - **Rendering** — Walls are stroked as line segments (N + W for every cell, plus E on the right edge and S on the bottom edge to avoid duplicate strokes). Animations use `requestAnimationFrame` with batched steps-per-frame for smooth speed scaling.
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
Empty grid never generates a maze; only a color legend appears after Enter+Space, no walls or solver animation visible.
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.