Maze generator & solver · kimi-k2.5
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 · 6 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
We need to generate a random maze using recursive backtracker on a canvas, then animate an A* or BFS solver finding the path. All in a single index.html, self-contained, no external resources. We'll write HTML with CSS in <style> and JS in <script>.
Plan:
- Maze generation: Use recursive backtracker on a grid of cells. We'll have width and height in cells. Each cell has walls: top, right, bottom, left. The algorithm starts at a random cell, marks visited, picks a random unvisited neighbor, removes walls between them, pushes current to stack, moves to neighbor. If no unvisited neighbors, backtrack (pop from stack). Continue until stack empty.
- For animation, we'll do the generation step-by-step, or generate then animate the solver? The prompt says "Generate a random maze ... then animate an A-star or BFS solver finding the path". Could generate instantly (or animate generation) and then animate solving. To make it more interesting, we can animate generation first, then automatically start solver animation. But the prompt doesn't explicitly require animating generation; it says generate then animate solver. To keep code simpler and robust, we can generate instantly (fast loop) and draw final maze, then animate the solver step by step. However, animating generation is also cool. Let's consider user expectations: "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." This implies two phases: first generate, then animate solver. We can animate generation so the user sees maze built, then animate solver. But the phrase "then animate" could also mean after generation, animate solver. Let's do both animated: animate maze generation (so we see walls being carved), then after generation completes, start A* solver animation.
Constraints: single file, no external resources.
Let's design the UI:
- Full screen canvas, maybe with some controls: cell size slider, generate button, solve button. But to keep it simple and polished, maybe just auto-generate and solve on load, with a header text. Or add a simple control bar at top with "Generate New Maze" and "Solve" buttons.
Style: dark theme, canvas centered, maybe with a backdrop. Use CSS for a nice layout.
Algorithm details:
Data structures:
```
const cols = Math.floor((window.innerWidth - margin) / cellSize);
const rows = Math.floor((window.innerHeight - margin) / cellSize);
```
But we need to define cellSize; maybe 20px. To make maze fit nicely, compute cols and rows based on canvas size.
Cell object: {x, y, walls: [top, right, bottom, left], visited}
Walls indices: 0: top, 1: right, 2: bottom, 3: left.
Recursive backtracker:
```
function generateMaze() {
// initialize cells
cells = [];
for (let y=0; y<rows; y++) {
for (let x=0; x<cols; x++) {
cells.push({x, y, walls: [true,true,true,true], visited: false});
}
}
let current = cells[0];
current.visited = true;
stack = [current];
// for animation, we can do one step per frame
}
```
Step function:
```
function generationStep() {
if (stack.length === 0) {
// done
startSolving();
return;
}
let current = stack[stack.length-1];
let neighbors = getUnvisitedNeighbors(current);
if (neighbors.length > 0) {
let next = neighbors[random];
removeWalls(current, next);
next.visited = true;
stack.push(next);
} else {
stack.pop();
}
}
```
But to animate nicely, we might want to run multiple steps per frame or one step per frame. One step per frame is okay but for large grids it will take a while. Let's run a few steps per frame, e.g., `stepsPerFrame = Math.max(1, Math.floor(cols*rows / 200))` or something. Or just one step per frame; for a 50x30 grid (1500 cells) that's about 25 seconds at 60fps, too slow. Let's do multiple steps per frame, maybe 5 or 10. Better: adjust dynamically: `let steps = Math.floor(cols * rows / 100);` but not too high. Alternatively, generate instantly (while loop) and only draw after each step? That would be same as instant. Let's just do one step per animation frame but use `requestAnimationFrame` and maybe do a batch of `batchSize = 5` steps per frame. This is a good compromise.
Solver: A* algorithm.
Grid graph: each cell is a node, edges exist between cells with no wall.
A*:
- openSet (priority queue or array). Since we want simplicity and animation, we can use an array and pick the node with lowest fScore each step.
- For animation, we maintain the algorithm state (openSet, closedSet, cameFrom, gScore, fScore). We do one step per frame (or a few per frame). We keep a `currentNode`. At each step, we remove node with lowest fScore from openSet. If it's the goal, reconstruct path and animate path. Otherwise, for each neighbor (only those reachable without walls), if not in closedSet, compute tentative gScore. If better, update.
- We need to animate both the "search" (explored cells) and the final path. So while searching, we color cells that are in openSet or closedSet. When goal reached, we reconstruct the path and then animate drawing the path (maybe one cell per frame or instant). Or we can draw the path immediately.
Given the maze is a tree (perfect maze), BFS or A* will find the unique shortest path. Since all edge weights are equal, A* with Manhattan heuristic is equivalent to BFS with heuristic. We'll implement A*.
Heuristic: Manhattan distance.
Wall removal: For a cell at (x,y) index `idx = y*cols + x`.
Neighbors in generation: up (y-1), right (x+1), down (y+1), left (x-1). Check bounds and visited.
Remove walls:
```
function removeWalls(a, b) {
let x = a.x - b.x;
if (x === 1) { a.walls[3] = false; b.walls[1] = false; }
else if (x === -1) { a.walls[1] = false; b.walls[3] = false; }
let y = a.y - b.y;
if (y === 1) { a.walls[0] = false; b.walls[2] = false; }
else if (y === -1) { a.walls[2] = false; b.walls[0] = false; }
}
```
Solver neighbor retrieval: For a cell, check its walls to see which directions are open. For openSet/closedSet, we can store indices.
```
function getReachableNeighbors(cellIdx) {
let c = cells[cellIdx];
let res = [];
if (!c.walls[0] && c.y > 0) res.push(idx(c.x, c.y-1));
if (!c.walls[1] && c.x < cols-1) res.push(idx(c.x+1, c.y));
if (!c.walls[2] && c.y < rows-1) res.push(idx(c.x, c.y+1));
if (!c.walls[3] && c.x > 0) res.push(idx(c.x-1, c.y));
return res;
}
```
A* state:
```
let openSet = [startIdx];
let closedSet = new Set();
let cameFrom = new Map(); // idx -> idx
let gScore = new Float64Array(cols*rows).fill(Infinity);
let fScore = new Float64Array(cols*rows).fill(Infinity);
gScore[startIdx] = 0;
fScore[startIdx] = heuristic(startIdx, goalIdx);
```
At each solver step:
```
if (openSet.length === 0) { // no path
stop;
}
// find node in openSet with lowest fScore
let currentIdx = openSet.reduce((best, idx) => fScore[idx] < fScore[best] ? idx : best);
openSet = openSet.filter(i => i !== currentIdx);
closedSet.add(currentIdx);
if (currentIdx === goalIdx) {
reconstructPath();
state = 'path_anim';
return;
}
for (let neighbor of getReachableNeighbors(currentIdx)) {
if (closedSet.has(neighbor)) continue;
let tentativeG = gScore[currentIdx] + 1;
let isOpen = openSet.includes(neighbor);
if (tentativeG < gScore[neighbor]) {
cameFrom.set(neighbor, currentIdx);
gScore[neighbor] = tentativeG;
fScore[neighbor] = tentativeG + heuristic(neighbor, goalIdx);
if (!isOpen) openSet.push(neighbor);
}
}
```
But `openSet.filter` and `includes` are O(n). Since grid size is moderate, it's fine. Could also use a priority queue but no need.
We can do multiple solver steps per frame for faster solving, but one per frame is fine and looks nice.
Animation styling:
- Background: dark (e.g., #121212)
- Walls: white or some bright color (e.g., #00e5ff or #ffffff). Let's use cyan for walls on dark bg.
- Unvisited cells during generation: maybe dark.
- Visited cells during generation: maybe slightly lighter.
- Solver search trail: openSet in green, closedSet in blue.
- Final path: bright yellow or white.
- Start cell: green.
- End cell: red.
Canvas drawing:
We need to draw cells and walls. Let's define `cellSize` in pixels. We'll draw walls as lines. For each cell, if wall exists, draw line.
Alternatively, draw rectangles for cells and lines for walls? Simpler: draw background for each cell, then walls as lines. But for performance, drawing lines for walls is okay.
Let's define a `drawMaze()` function that clears canvas, draws all cells (with colors based on state), and draws walls.
Cell colors per state:
- `searching`: if cell is in closedSet, color it e.g., `rgba(0, 100, 255, 0.3)`; if in openSet, color `rgba(0, 255, 100, 0.5)`.
- `path`: if part of final path, color e.g., `rgba(255, 255, 0, 0.8)` or `#FFD700`.
- `start` and `goal`: distinct colors.
But we need to manage drawing the generation animation too. During generation, we can highlight the current cell from stack top in a different color (e.g., `#FF5722`). Also visited cells can be slightly lit.
During generation, there is no openSet/closedSet. After generation is done, we start solving.
We'll have a game loop using `requestAnimationFrame` that calls the current phase's update and then `draw()`.
Phases: `'generating'`, `'solving'`, `'pathing'`, `'done'`.
During `'generating'`: call `generationSteps(batchSize)`; if done, set phase to `'solving'`.
During `'solving'`: call `solverStep()`; if goal reached, set phase to `'pathing'`, `path = reconstructedPath`, `pathIndex = 0` (or just set phase to done and draw full path). To animate the path drawing, we can do a small animation where we gradually reveal the path. Or just draw the full path instantly. Let's animate drawing the path: in `'pathing'` phase, each frame we increase `pathDrawIndex` up to path.length, and draw path cells up to that index. Then when done, phase = `'done'`.
Let's generate a maze on load and auto-start.
UI controls: Maybe just a "New Maze" button and a "Solve" button. But if we auto-solve after generation, the Solve button is not needed. However, user might want to just watch generation then solve? Let's add a simple top bar with:
- Title: "Maze Generator & Solver"
- Buttons: "Generate New Maze", "Solve (A*)" (disabled during generation, maybe becomes active after generation). But the prompt says "then animate an A-star or BFS solver finding the path", so auto-trigger after generation is fine.
Let's do auto-trigger after generation completes.
But to make it interactive, we can add a button to start a new maze, which resets everything.
Layout:
```
<div id="container">
<div id="controls">
<h1>Maze Generator & Solver</h1>
<button id="btn-new">New Maze</button>
<span id="status">Generating maze...</span>
</div>
<canvas id="canvas"></canvas>
</div>
```
CSS: body margin 0, background #0a0a0a, color #eee, font-family sans-serif. #controls is a flex bar at top. Canvas fills remaining space.
Canvas sizing: we'll set canvas width/height based on container size. Use `canvas.width = canvas.clientWidth`, `canvas.height = canvas.clientHeight` (or compute from offset). But we need to maintain crisp rendering on high DPI? Let's keep it simple: set canvas width/height to clientWidth/height.
Compute cols/rows: `let cols = Math.floor(canvas.width / cellSize); let rows = Math.floor(canvas.height / cellSize);` Use `cellSize = 25` maybe. But we should ensure a little margin so canvas isn't cramped. We can subtract some padding. Or just compute based on `Math.min(canvas.width, canvas.height) / desiredCellCount`. Let's choose a fixed cell size, e.g., `cellSize = 24`, and compute max cols/rows that fit. If canvas size changes, we can regenerate. For simplicity, we just generate once on load with initial window size. If resized, user can click New Maze.
Let's pick `cellSize = 20` for a dense maze. That should be fine.
Wall drawing: line width = 2 or 3? Let's use `wallThickness = 2`. Color `#00e5ff` (cyan) for walls. Or `#ffffff`. Let's go with `rgba(0, 230, 255, 0.9)` for a neon look.
Cell background: default `#0a0a0a`.
Visited during generation: `#1a1a2e`.
Current cell during generation: `#ff0055` (magenta).
Start: `#00ff88` (green). Goal: `#ff3333` (red).
OpenSet during solving: `#00ff88` (greenish). ClosedSet: `#0055ff` (blue). Path: `#ffdd00` (gold).
But we need to draw cell backgrounds efficiently: iterate over all cells, fillRect for each cell with its color. Then draw walls on top.
Helper `getCellColor(idx)`:
```
if (phase === 'generating') {
if (idx === currentIdx) return '#ff0055';
if (cells[idx].visited) return '#1a1a2e';
return '#0a0a0a';
}
if (phase === 'solving' || phase === 'pathing' || phase === 'done') {
if (idx === startIdx) return '#00ff88';
if (idx === goalIdx) return '#ff3333';
if (pathSet && pathSet.has(idx)) return '#ffdd00';
if (closedSet.has(idx)) return 'rgba(0, 85, 255, 0.4)';
if (openSet.includes(idx)) return 'rgba(0, 255, 136, 0.5)';
if (cells[idx].visited) return '#111122'; // visited from generation
return '#0a0a0a';
}
```
But for `'pathing'`, we might want to draw the path progressively: we have `path` array and `pathDrawIndex`. We'll create a Set of path indices up to `pathDrawIndex`. Or just iterate and fill.
Drawing function:
```
function draw() {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw cell backgrounds
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let color = getCellColor(i);
if (color) {
ctx.fillStyle = color;
ctx.fillRect(c.x * cellSize, c.y * cellSize, cellSize, cellSize);
}
}
// draw walls
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 2;
ctx.lineCap = 'square';
ctx.beginPath();
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let x = c.x * cellSize;
let y = c.y * cellSize;
if (c.walls[0]) { ctx.moveTo(x, y); ctx.lineTo(x + cellSize, y); }
if (c.walls[1]) { ctx.moveTo(x + cellSize, y); ctx.lineTo(x + cellSize, y + cellSize); }
if (c.walls[2]) { ctx.moveTo(x + cellSize, y + cellSize); ctx.lineTo(x, y + cellSize); }
if (c.walls[3]) { ctx.moveTo(x, y + cellSize); ctx.lineTo(x, y); }
}
ctx.stroke();
}
```
However, drawing walls as separate line segments at cell borders will double-draw each interior wall. That's okay. But to avoid overlapping lines causing darker appearance, we could optimize: only draw top and left walls for each cell, plus full outer border. Actually, each interior wall is shared by two cells, so drawing both is redundant but visually same if we use same color. Simpler to just draw all walls for all cells; it's fine for moderate grid sizes.
But note: `ctx.beginPath()` and `ctx.stroke()` for all segments is efficient.
Alternatively, we could draw only walls that exist, as above.
Now, generation batch size: Let's define `const genStepsPerFrame = Math.max(1, Math.floor(cols * rows / 80));` or something. Actually we want it to finish in a few seconds. Let's calculate: for a 60x40 grid (2400 cells), backtracker visits every cell and backtracks. Total steps ~ number of pushes + pops = 2*N -1 ~ 4800. At 60 fps, 4800 frames = 80 seconds if 1 per frame. That's too slow. So we need batch. `Math.max(2, Math.floor(totalCells / 20))` -> 120 steps per frame -> 40 frames = 0.66 seconds. Maybe too fast. Let's aim for total generation time ~2-3 seconds. So `genStepsPerFrame = Math.max(1, Math.floor(totalCells * 2 / 60));` -> for 2400 cells, 80 steps/frame -> 60 frames = 1 second. Still fast. Maybe we want `Math.max(1, Math.floor(totalCells / 60))` -> 40 steps/frame -> 2 seconds. That's good. Let's use `genStepsPerFrame = Math.max(1, Math.floor(totalCells / 60));`. We can also add a slider for speed, but keep it simple.
Solver speed: `solverStepsPerFrame = Math.max(1, Math.floor(totalCells / 120));` maybe 20 steps/frame. But for visual effect, one step per frame is better; however for large mazes it's slow. Let's do `solverStepsPerFrame = Math.max(1, Math.floor(totalCells / 200));` -> ~12 steps/frame for 2400 cells. That's about 4-5 seconds to explore. Maybe too fast? Let's do a fixed small batch, like 3 or 5 steps per frame. Or maybe `Math.max(1, Math.floor(totalCells / 300))`. Let's just set `solverStepsPerFrame = 3` for moderate speed. Actually, A* on a perfect maze explores a lot of cells. For 2400 cells, maybe 1200 explored. At 3 per frame, that's 400 frames ~ 6.7 seconds. Good.
Path animation: we can draw 2-3 cells per frame.
State transitions:
- `init()` called on load and on "New Maze" click.
- `init()` computes grid dimensions, initializes cells, sets phase='generating', stack=[start], etc.
- `loop()` called via requestAnimationFrame.
```
function loop() {
if (phase === 'generating') {
for (let i = 0; i < genStepsPerFrame; i++) {
if (phase !== 'generating') break; // changed inside
generationStep();
}
} else if (phase === 'solving') {
for (let i = 0; i < solverStepsPerFrame; i++) {
if (phase !== 'solving') break;
solverStep();
}
} else if (phase === 'pathing') {
// animate final path
pathDrawIndex = Math.min(path.length, pathDrawIndex + pathDrawSpeed);
if (pathDrawIndex >= path.length) {
phase = 'done';
statusText = 'Done!';
}
}
draw();
if (phase !== 'idle') requestAnimationFrame(loop);
}
```
Wait, we need to stop loop when idle or done? We can keep looping to draw. Actually, we can just always requestAnimationFrame, but if phase is 'done', we just draw and keep requesting? That would waste CPU. Better: if phase is not 'idle' or 'done', call requestAnimationFrame. But for pathing, we need animation. For done, we can draw final state and stop.
Let's structure:
```
let animating = false;
function startLoop() {
if (!animating) {
animating = true;
loop();
}
}
function loop() {
if (phase === 'generating') { ... }
else if (phase === 'solving') { ... }
else if (phase === 'pathing') { ... }
draw();
if (phase === 'done') {
animating = false;
return; // stop
}
requestAnimationFrame(loop);
}
```
But we need to continue drawing during pathing until done. So we requestAnimationFrame while phase != 'done'. After done, we draw one last time and stop.
Actually, pathing ends and then we might want to keep showing. So after done, just stop requesting frames. If user clicks New Maze, we restart.
Let's refine generationStep:
```
function generationStep() {
if (stack.length === 0) {
phase = 'solving';
initSolver();
return;
}
let current = stack[stack.length - 1];
let neighbors = getUnvisitedNeighbors(current);
if (neighbors.length > 0) {
let next = neighbors[Math.floor(Math.random() * neighbors.length)];
removeWalls(current, next);
next.visited = true;
stack.push(next);
} else {
stack.pop();
}
}
```
`getUnvisitedNeighbors`: returns cell objects.
`removeWalls`: as defined.
`initSolver`:
- startIdx = 0 (top-left)
- goalIdx = cells.length - 1 (bottom-right)
- openSet = [startIdx]
- closedSet = new Set()
- cameFrom = new Map()
- gScore = new Float64Array(totalCells).fill(Infinity)
- fScore = new Float64Array(totalCells).fill(Infinity)
- gScore[startIdx] = 0
- fScore[startIdx] = heuristic(startIdx, goalIdx)
- path = null
- pathDrawIndex = 0
- pathDrawSpeed = Math.max(2, Math.floor(path.length/100?)) We'll adjust later.
`solverStep`:
```
function solverStep() {
if (openSet.length === 0) {
phase = 'done';
statusText = 'No path found';
return;
}
// find lowest fScore
let lowestIdx = 0;
for (let i = 1; i < openSet.length; i++) {
if (fScore[openSet[i]] < fScore[openSet[lowestIdx]]) lowestIdx = i;
}
let currentIdx = openSet[lowestIdx];
openSet.splice(lowestIdx, 1);
closedSet.add(currentIdx);
if (currentIdx === goalIdx) {
reconstructPath();
phase = 'pathing';
return;
}
for (let neighbor of getReachableNeighbors(currentIdx)) {
if (closedSet.has(neighbor)) continue;
let tentativeG = gScore[currentIdx] + 1;
let inOpen = openSet.includes(neighbor);
if (tentativeG < gScore[neighbor]) {
cameFrom.set(neighbor, currentIdx);
gScore[neighbor] = tentativeG;
fScore[neighbor] = tentativeG + heuristic(neighbor, goalIdx);
if (!inOpen) openSet.push(neighbor);
}
}
}
```
`reconstructPath`:
```
function reconstructPath() {
path = [];
let current = goalIdx;
while (current !== startIdx) {
path.push(current);
current = cameFrom.get(current);
}
path.push(startIdx);
path.reverse();
pathDrawIndex = 0;
pathDrawSpeed = Math.max(2, Math.floor(path.length / 60));
}
```
`getReachableNeighbors`:
```
function getReachableNeighbors(idx) {
let c = cells[idx];
let res = [];
if (!c.walls[0] && c.y > 0) res.push(idx - cols);
if (!c.walls[1] && c.x < cols - 1) res.push(idx + 1);
if (!c.walls[2] && c.y < rows - 1) res.push(idx + cols);
if (!c.walls[3] && c.x > 0) res.push(idx - 1);
return res;
}
```
`heuristic`: Manhattan distance.
```
function heuristic(a, b) {
let ax = a % cols, ay = Math.floor(a / cols);
let bx = b % cols, by = Math.floor(b / cols);
return Math.abs(ax - bx) + Math.abs(ay - by);
}
```
Drawing logic:
```
function draw() {
// clear
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw cells background
for (let i = 0; i < cells.length; i++) {
let x = (i % cols) * cellSize;
let y = Math.floor(i / cols) * cellSize;
ctx.fillStyle = getCellColor(i);
ctx.fillRect(x, y, cellSize, cellSize);
}
// draw outer border (optional, walls cover it)
// draw walls
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 2;
ctx.lineJoin = 'miter';
ctx.beginPath();
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let x = c.x * cellSize;
let y = c.y * cellSize;
if (c.walls[0]) { ctx.moveTo(x, y); ctx.lineTo(x + cellSize, y); }
if (c.walls[1]) { ctx.moveTo(x + cellSize, y); ctx.lineTo(x + cellSize, y + cellSize); }
if (c.walls[2]) { ctx.moveTo(x + cellSize, y + cellSize); ctx.lineTo(x, y + cellSize); }
if (c.walls[3]) { ctx.moveTo(x, y + cellSize); ctx.lineTo(x, y); }
}
ctx.stroke();
// maybe draw start/goal markers as circles or text
}
```
`getCellColor(i)`:
```
function getCellColor(i) {
if (phase === 'generating') {
if (stack.length > 0 && i === (stack[stack.length-1].x + stack[stack.length-1].y * cols)) return '#ff0055';
if (cells[i].visited) return '#1a1a2e';
return null; // default background already cleared
}
// solving, pathing, done
if (i === startIdx) return '#00ff88';
if (i === goalIdx) return '#ff3333';
if ((phase === 'pathing' || phase === 'done') && path) {
// find if i is in path up to pathDrawIndex
// we can use a Set for path for O(1)
// But building a set each frame is expensive. Let's maintain a Set `pathSet` for currently drawn path.
}
if (closedSet && closedSet.has(i)) return 'rgba(0, 85, 255, 0.35)';
if (openSet && openSet.includes(i)) return 'rgba(0, 255, 136, 0.45)';
if (cells[i].visited) return '#11111f';
return null;
}
```
For path drawing, we can maintain a `pathSet` Set that we update during pathing:
In `loop()` for phase 'pathing':
```
for (let k = 0; k < pathDrawSpeed; k++) {
if (pathDrawIndex < path.length) {
pathSet.add(path[pathDrawIndex]);
pathDrawIndex++;
}
}
if (pathDrawIndex >= path.length) phase = 'done';
```
But `pathSet` is a Set of indices. We can define it globally.
During `reconstructPath()`, we create `pathSet = new Set();`.
In `getCellColor(i)`:
```
if (pathSet && pathSet.has(i)) return '#ffdd00';
```
Also we can highlight the current solving node: in `solverStep`, we could keep `currentSolverIdx` as the current node being processed. But since we process batch per frame, the last processed `currentIdx` is the one to highlight. Let's just not highlight current solver node; openSet/closedSet colors are enough.
But we could store `solverCurrentIdx` and color it white for emphasis.
Let's add `solverCurrentIdx` updated in `solverStep` before `openSet.splice`. Then in `getCellColor`:
```
if (solverCurrentIdx === i) return '#ffffff';
```
But careful: during batch, it updates multiple times per frame. The last one will be shown. That is fine.
For generation, we also want to show the current cell. `stackTopIdx` can be derived from `stack[stack.length-1]`.
One more thing: `cells[i].visited` is true for all cells after generation. During generation, visited cells are colored.
Double-check: In `removeWalls`, ensure we update both cells.
Let's write `removeWalls` carefully:
```
function removeWalls(a, b) {
let x = a.x - b.x;
if (x === 1) {
a.walls[3] = false;
b.walls[1] = false;
} else if (x === -1) {
a.walls[1] = false;
b.walls[3] = false;
}
let y = a.y - b.y;
if (y === 1) {
a.walls[0] = false;
b.walls[2] = false;
} else if (y === -1) {
a.walls[2] = false;
b.walls[0] = false;
}
}
```
Indices: top=0, right=1, bottom=2, left=3. Yes.
`getUnvisitedNeighbors(cell)`:
```
function getUnvisitedNeighbors(cell) {
let neighbors = [];
let {x, y} = cell;
if (y > 0) {
let top = cells[idx(x, y-1)];
if (!top.visited) neighbors.push(top);
}
if (x < cols - 1) {
let right = cells[idx(x+1, y)];
if (!right.visited) neighbors.push(right);
}
if (y < rows - 1) {
let bottom = cells[idx(x, y+1)];
if (!bottom.visited) neighbors.push(bottom);
}
if (x > 0) {
let left = cells[idx(x-1, y)];
if (!left.visited) neighbors.push(left);
}
return neighbors;
}
```
Now, the `init()` function:
```
function init() {
cancelAnimationFrame(animFrameId);
animating = false;
// set canvas size
const container = document.getElementById('canvas-container');
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
cellSize = 24; // maybe compute to fit? Let's compute to get at least some cells.
// ensure cellSize yields a reasonable grid
cols = Math.floor(canvas.width / cellSize);
rows = Math.floor(canvas.height / cellSize);
// center the maze?
// we can just use the full width/height.
cells = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
cells.push({x, y, walls: [true, true, true, true], visited: false});
}
}
let startCell = cells[0];
startCell.visited = true;
stack = [startCell];
openSet = null;
closedSet = null;
cameFrom = null;
gScore = null;
fScore = null;
path = null;
pathSet = null;
pathDrawIndex = 0;
solverCurrentIdx = -1;
startIdx = 0;
goalIdx = cells.length - 1;
phase = 'generating';
statusSpan.textContent = 'Generating maze...';
const totalCells = cols * rows;
genStepsPerFrame = Math.max(2, Math.floor(totalCells / 60));
solverStepsPerFrame = 2; // fixed small batch for solver
startLoop();
}
```
Wait, canvas container height. We need to define layout. Let's wrap canvas in a div that flex-grows.
HTML structure:
```
<!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>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #050505; color: #eee; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
#app { display: flex; flex-direction: column; width: 100%; height: 100%; }
#header { padding: 12px 20px; background: #111; border-bottom: 1px solid #222; display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.5px; color: #00e5ff; margin-right: auto; }
button { background: #00e5ff; color: #000; border: none; padding: 8px 16px; border-radius: 4px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
button:hover { background: #00b8cc; }
button:disabled { background: #333; color: #777; cursor: default; }
#status { font-size: 0.9rem; color: #aaa; min-width: 140px; }
#canvas-container { flex: 1; position: relative; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="app">
<div id="header">
<h1>Maze Generator & A* Solver</h1>
<button id="btn-new">New Maze</button>
<span id="status">Ready</span>
</div>
<div id="canvas-container">
<canvas id="canvas"></canvas>
</div>
</div>
<script>
// JS code
</script>
</body>
</html>
```
Now in JS, we need to get `canvas-container` dimensions after layout. Since container flexes, we can read `clientWidth/Height` after DOM is ready.
One issue: if cellSize is fixed at 24, but canvas is not perfectly divisible, there will be a small unused margin at right/bottom. That's okay. We can center the maze drawing by offsetting `(canvas.width - cols*cellSize)/2` and same for height. Let's add offsets.
```
let offsetX = Math.floor((canvas.width - cols * cellSize) / 2);
let offsetY = Math.floor((canvas.height - rows * cellSize) / 2);
```
But to keep drawing simple, we can apply `ctx.translate(offsetX, offsetY)` at the start of draw, and also ensure we clip or just clear full canvas. Since canvas might have extra space, we clear full canvas and then translate. That is fine.
But `getReachableNeighbors` uses cols and rows only; offsets are just visual.
Let's refine draw to use offsets.
Also for outer border: since each cell draws its own walls, the outer walls will be drawn naturally by border cells. So no need for extra border.
Let's incorporate all this into the script.
Potential edge cases:
- `openSet.includes` and `closedSet.has` performance. For up to ~5000 cells, fine.
- `cameFrom` is a Map of number->number. Fine.
- `genStepsPerFrame` might be too high if grid is huge. We'll cap it at maybe 20. Actually, if grid is 100x100 = 10000 cells, genStepsPerFrame = floor(10000/60) = 166. That's a lot per frame but still fine. The while loop will run fast. But drawing 10000 cells each frame might be heavier. Canvas drawing of 10k rectangles and 40k line segments is okay for modern browsers at 60fps? Maybe. Let's cap steps per frame at 50 or something. Actually, larger grid means more drawing cost, so we don't want to spend too much time in generation steps if drawing is already heavy. Let's just use a fixed small batch, like `5`, and let it run. But for a 20x15 grid, 5 steps per frame is fast enough. For a 100x100 grid, 5 steps per frame is 2000 frames ~ 33 seconds, slow. But 100x100 is huge for a 24px cell size (2400px). Not likely on a normal screen. For a 1920x1080 screen, with 24px cells, cols=80, rows=45 (3600 cells). 5 steps/frame -> 720 frames = 12 seconds. Might be okay. Let's compute `genStepsPerFrame = Math.max(3, Math.floor(totalCells / 100));` for 3600 cells -> 36 steps/frame -> 100 frames = 1.6 sec. That seems balanced. Let's use `Math.max(3, Math.floor(totalCells / 80))` for a bit faster. For 3600 -> 45 steps/frame. That's okay.
Solver: `solverStepsPerFrame = Math.max(1, Math.floor(totalCells / 400));` or fixed at 3. For 3600 cells -> 9 steps/frame. That gives a nice visualization. Let's use `Math.max(2, Math.floor(totalCells / 300))`.
Path speed: `Math.max(2, Math.floor(path.length / 80))`.
Let's write the code.
Potential issues with `cancelAnimationFrame`: need to keep track of `animFrameId`. `startLoop` sets it.
```
let animFrameId;
function startLoop() {
if (animFrameId) cancelAnimationFrame(animFrameId);
animating = true;
loop();
}
function loop() {
// ...
if (phase !== 'done') {
animFrameId = requestAnimationFrame(loop);
} else {
animating = false;
animFrameId = null;
statusSpan.textContent = 'Path found!';
draw(); // final draw
}
}
```
But in `pathing` phase, we need to request next frame until pathDrawIndex reaches path.length, then set phase='done'. In the loop, after pathing updates, if phase becomes 'done', we do final draw and stop.
Wait, we need to draw after updates. So:
```
function loop() {
if (phase === 'generating') {
for (...) generationStep();
} else if (phase === 'solving') {
for (...) solverStep();
} else if (phase === 'pathing') {
for (let k=0; k<pathDrawSpeed; k++) {
if (pathDrawIndex < path.length) {
pathSet.add(path[pathDrawIndex]);
pathDrawIndex++;
}
}
if (pathDrawIndex >= path.length) {
phase = 'done';
}
}
draw();
if (phase === 'done') {
statusSpan.textContent = 'Solved!';
animating = false;
animFrameId = null;
return;
}
animFrameId = requestAnimationFrame(loop);
}
```
Wait, if we return without requesting next frame, animation stops. That's fine. But if phase is 'done', we already drew. Good.
However, we might want to continue drawing in case of window resize? Not needed.
Now, generation to solving transition: In `generationStep`, when stack empty, set `phase = 'solving'` and call `initSolver()`. Because our loop runs batch, after the batch the loop will see phase is 'solving' and switch to solver steps.
`initSolver()`:
```
function initSolver() {
startIdx = 0;
goalIdx = cells.length - 1;
openSet = [startIdx];
closedSet = new Set();
cameFrom = new Map();
const totalCells = cols * rows;
gScore = new Float64Array(totalCells).fill(Infinity);
fScore = new Float64Array(totalCells).fill(Infinity);
gScore[startIdx] = 0;
fScore[startIdx] = heuristic(startIdx, goalIdx);
solverCurrentIdx = -1;
path = null;
pathSet = new Set();
pathDrawIndex = 0;
solverStepsPerFrame = Math.max(2, Math.floor(totalCells / 300));
statusSpan.textContent = 'Solving with A*...';
}
```
Note: `totalCells` is known globally. Also, we need to ensure `cells` have `.visited` true for all after generation. But that's okay.
Potential bug: `closedSet` and `openSet` used in `getCellColor`. We need to make sure they are initialized before draw is called. Since `initSolver` sets them before phase changes or immediately after, and the loop draws after updates, it should be fine. But what if `draw()` is called before `initSolver` finishes? In loop, generationStep changes phase to 'solving', then the loop proceeds to next iteration? Actually, the loop runs batch of generation steps in a for loop. If during the batch `phase` changes to 'solving', the for loop breaks, then after the batch, `draw()` is called. At this point, `openSet` etc. should be set. But `generationStep` sets phase and calls `initSolver`. So it's fine.
Similarly, `solverStep` sets phase to 'pathing' and calls `reconstructPath()`. After the batch, draw is called.
Now, reconstructPath must set pathSet.
```
function reconstructPath() {
path = [];
let curr = goalIdx;
while (curr !== startIdx) {
path.push(curr);
curr = cameFrom.get(curr);
// safety break
if (curr === undefined) break;
}
path.push(startIdx);
path.reverse();
pathSet = new Set();
pathDrawIndex = 0;
pathDrawSpeed = Math.max(2, Math.floor(path.length / 80));
}
```
Wait, if `cameFrom` doesn't have a value (shouldn't happen if goal found), but just in case.
In `solverStep`, we need to handle `cameFrom` correctly. Since `cameFrom` is a Map, when we find goal, we break.
Highlighting current during solving: let's track `solverCurrentIdx` as the index being processed in the last `solverStep` of the batch. Since batch runs multiple steps, only the last one matters for drawing. So in `solverStep`, after picking `currentIdx`, set `solverCurrentIdx = currentIdx`. This is fine.
Now, `getCellColor`:
```
function getCellColor(i) {
if (phase === 'generating') {
if (stack.length > 0) {
let top = stack[stack.length - 1];
if (top.x + top.y * cols === i) return '#ff0055';
}
if (cells[i].visited) return '#16162a';
return null;
}
// For solving/pathing/done
if (i === startIdx) return '#00ff88';
if (i === goalIdx) return '#ff3333';
if (pathSet && pathSet.has(i)) return '#ffdd00';
if (solverCurrentIdx === i) return '#ffffff';
if (closedSet && closedSet.has(i)) return 'rgba(0, 85, 255, 0.35)';
if (openSet && openSet.includes(i)) return 'rgba(0, 255, 136, 0.45)';
if (cells[i].visited) return '#0f0f1a';
return null;
}
```
But note: during solving, `cells[i].visited` is true for all, so the fallback color is `#0f0f1a`. That gives a subtle background.
One issue: `openSet` and `closedSet` might be null before initSolver. We check with `&&`.
Drawing: we need to offset by `offsetX, offsetY`. Let's compute offsets in `draw()` based on current canvas size (in case resized, though we won't handle live resize). Or compute in `init()`. We'll compute in `init()` and store in global variables `offsetX, offsetY`. But if we want to react to resize, we could ignore.
```
offsetX = Math.floor((canvas.width - cols * cellSize) / 2);
offsetY = Math.floor((canvas.height - rows * cellSize) / 2);
```
In `draw()`:
```
ctx.save();
ctx.translate(offsetX, offsetY);
// draw backgrounds
// draw walls
ctx.restore();
```
But clearing the canvas should be done before translate.
```
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(offsetX, offsetY);
...
ctx.restore();
```
Cell background drawing:
```
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let color = getCellColor(i);
if (color) {
ctx.fillStyle = color;
ctx.fillRect(c.x * cellSize, c.y * cellSize, cellSize, cellSize);
}
}
```
Walls drawing:
```
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let x = c.x * cellSize;
let y = c.y * cellSize;
if (c.walls[0]) { ctx.moveTo(x, y); ctx.lineTo(x + cellSize, y); }
if (c.walls[1]) { ctx.moveTo(x + cellSize, y); ctx.lineTo(x + cellSize, y + cellSize); }
if (c.walls[2]) { ctx.moveTo(x + cellSize, y + cellSize); ctx.lineTo(x, y + cellSize); }
if (c.walls[3]) { ctx.moveTo(x, y + cellSize); ctx.lineTo(x, y); }
}
ctx.stroke();
```
Maybe add a subtle glow effect for walls? Could use `ctx.shadowBlur` but that is performance heavy for many lines. Let's skip.
We also might want to draw a small circle or icon on start/goal to make them obvious. But colors are okay.
Status updates:
- `init()`: "Generating maze..."
- `initSolver()`: "Solving with A*..."
- `done`: "Path found!" or "Solved!"
Button: "New Maze" calls `init()`.
One thing: when clicking New Maze while animation is running, we need to cancel the old loop. Our `init()` calls `cancelAnimationFrame(animFrameId)` and sets `animating = false`. That's good.
Let's also handle `cellSize` adaptively. Instead of fixed 24, we can compute based on canvas size to aim for e.g., 40 columns. But fixed is okay. However, on mobile or small screens, 24px cells might be too large, making a very small maze. Let's set `cellSize = Math.max(10, Math.min(30, Math.floor(Math.min(canvas.width, canvas.height) / 25)));` This yields about 25 cells on the smaller dimension. For a 1920x1080 screen, smaller dimension is 1080, /25 = 43.2 -> min(30,43) = 30. So cellSize=30, cols=64, rows=36. That's okay. For a 800x600 screen, smaller=600/25=24 -> cellSize=24, cols=33, rows=25. Good.
Actually, we might want a slightly smaller cell size for denser maze. Let's do `Math.floor(Math.min(canvas.width, canvas.height) / 30)`. For 1080 -> 36. For 600 -> 20. That seems fine. We'll clamp between 12 and 30.
```
cellSize = Math.max(12, Math.min(30, Math.floor(Math.min(canvas.width, canvas.height) / 30)));
```
Then compute cols and rows: `cols = Math.floor(canvas.width / cellSize); rows = Math.floor(canvas.height / cellSize);`.
Now, let's write the code carefully.
Potential issues:
- `openSet.includes(neighbor)` inside solverStep is O(n). With thousands of cells, it might be okay for a few steps per frame. Let's keep it.
- `getReachableNeighbors` uses `idx - cols` or `idx + cols`, but we need to ensure we don't wrap around horizontally. But walls ensure that left/right movement only exists if no wall. However, if a cell is on the leftmost column but its left wall is false (shouldn't happen with proper generation), `idx-1` would be wrong. But since walls are correct, and we check `c.x > 0` for left and `c.x < cols-1` for right, it's safe. Similarly for top/bottom with `c.y`. But for `idx - cols`, we should also check `c.y > 0`. The wall check `!c.walls[0]` combined with `c.y > 0` is fine. But if we had a bug where `idx-cols` wraps? No, because `c.y > 0`.
- `heuristic` for A*: Manhattan distance is admissible and works with equal weights.
- `cameFrom` for start is undefined. In reconstructPath, loop until `curr !== startIdx`. Safe.
- In `generationStep`, when picking neighbors, we need to ensure they exist. `getUnvisitedNeighbors` checks bounds.
- Random: `Math.random()`.
- `stack` holds references to cell objects. We can use objects.
Now, we need to ensure everything is inside one file `index.html` with no external resources.
Let's draft the full JS.
```
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusSpan = document.getElementById('status');
const btnNew = document.getElementById('btn-new');
let cols, rows;
let cellSize;
let offsetX, offsetY;
let cells;
let stack;
let phase = 'idle';
let animFrameId;
let genStepsPerFrame, solverStepsPerFrame;
let startIdx, goalIdx;
let openSet, closedSet, cameFrom, gScore, fScore;
let solverCurrentIdx;
let path, pathSet, pathDrawIndex, pathDrawSpeed;
function idx(x, y) { return y * cols + x; }
function heuristic(a, b) {
let ax = a % cols, ay = Math.floor(a / cols);
let bx = b % cols, by = Math.floor(b / cols);
return Math.abs(ax - bx) + Math.abs(ay - by);
}
function removeWalls(a, b) {
let x = a.x - b.x;
if (x === 1) { a.walls[3] = false; b.walls[1] = false; }
else if (x === -1) { a.walls[1] = false; b.walls[3] = false; }
let y = a.y - b.y;
if (y === 1) { a.walls[0] = false; b.walls[2] = false; }
else if (y === -1) { a.walls[2] = false; b.walls[0] = false; }
}
function getUnvisitedNeighbors(cell) {
let neighbors = [];
let {x, y} = cell;
if (y > 0) {
let c = cells[idx(x, y-1)];
if (!c.visited) neighbors.push(c);
}
if (x < cols - 1) {
let c = cells[idx(x+1, y)];
if (!c.visited) neighbors.push(c);
}
if (y < rows - 1) {
let c = cells[idx(x, y+1)];
if (!c.visited) neighbors.push(c);
}
if (x > 0) {
let c = cells[idx(x-1, y)];
if (!c.visited) neighbors.push(c);
}
return neighbors;
}
function getReachableNeighbors(cellIdx) {
let c = cells[cellIdx];
let res = [];
if (!c.walls[0] && c.y > 0) res.push(idx(c.x, c.y-1));
if (!c.walls[1] && c.x < cols - 1) res.push(idx(c.x+1, c.y));
if (!c.walls[2] && c.y < rows - 1) res.push(idx(c.x, c.y+1));
if (!c.walls[3] && c.x > 0) res.push(idx(c.x-1, c.y));
return res;
}
function generationStep() {
if (stack.length === 0) {
initSolver();
phase = 'solving';
return;
}
let current = stack[stack.length - 1];
let neighbors = getUnvisitedNeighbors(current);
if (neighbors.length > 0) {
let next = neighbors[Math.floor(Math.random() * neighbors.length)];
removeWalls(current, next);
next.visited = true;
stack.push(next);
} else {
stack.pop();
}
}
function solverStep() {
if (openSet.length === 0) {
phase = 'done';
statusSpan.textContent = 'No path found';
return;
}
let lowest = 0;
for (let i = 1; i < openSet.length; i++) {
if (fScore[openSet[i]] < fScore[openSet[lowest]]) lowest = i;
}
let currentIdx = openSet[lowest];
solverCurrentIdx = currentIdx;
openSet.splice(lowest, 1);
closedSet.add(currentIdx);
if (currentIdx === goalIdx) {
reconstructPath();
phase = 'pathing';
return;
}
for (let neighbor of getReachableNeighbors(currentIdx)) {
if (closedSet.has(neighbor)) continue;
let tentativeG = gScore[currentIdx] + 1;
let inOpen = openSet.includes(neighbor);
if (tentativeG < gScore[neighbor]) {
cameFrom.set(neighbor, currentIdx);
gScore[neighbor] = tentativeG;
fScore[neighbor] = tentativeG + heuristic(neighbor, goalIdx);
if (!inOpen) openSet.push(neighbor);
}
}
}
function reconstructPath() {
path = [];
let curr = goalIdx;
while (curr !== startIdx) {
path.push(curr);
curr = cameFrom.get(curr);
if (curr === undefined) { path = []; break; }
}
if (path.length > 0) path.push(startIdx);
path.reverse();
pathSet = new Set();
pathDrawIndex = 0;
pathDrawSpeed = Math.max(2, Math.floor(path.length / 80));
}
function getCellColor(i) {
if (phase === 'generating') {
if (stack.length > 0) {
let top = stack[stack.length - 1];
if (top.x + top.y * cols === i) return '#ff0055';
}
if (cells[i].visited) return '#16162a';
return null;
}
if (i === startIdx) return '#00ff88';
if (i === goalIdx) return '#ff3333';
if (pathSet && pathSet.has(i)) return '#ffdd00';
if (solverCurrentIdx === i) return '#ffffff';
if (closedSet && closedSet.has(i)) return 'rgba(0, 85, 255, 0.35)';
if (openSet && openSet.includes(i)) return 'rgba(0, 255, 136, 0.45)';
if (cells[i].visited) return '#0f0f1a';
return null;
}
function draw() {
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(offsetX, offsetY);
// cell backgrounds
for (let i = 0; i < cells.length; i++) {
let color = getCellColor(i);
if (color) {
ctx.fillStyle = color;
let c = cells[i];
ctx.fillRect(c.x * cellSize, c.y * cellSize, cellSize, cellSize);
}
}
// walls
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = Math.max(1, Math.floor(cellSize / 12));
ctx.lineCap = 'square';
ctx.beginPath();
for (let i = 0; i < cells.length; i++) {
let c = cells[i];
let x = c.x * cellSize;
let y = c.y * cellSize;
if (c.walls[0]) { ctx.moveTo(x, y); ctx.lineTo(x + cellSize, y); }
if (c.walls[1]) { ctx.moveTo(x + cellSize, y); ctx.lineTo(x + cellSize, y + cellSize); }
if (c.walls[2]) { ctx.moveTo(x + cellSize, y + cellSize); ctx.lineTo(x, y + cellSize); }
if (c.walls[3]) { ctx.moveTo(x, y + cellSize); ctx.lineTo(x, y); }
}
ctx.stroke();
ctx.restore();
}
function initSolver() {
startIdx = 0;
goalIdx = cells.length - 1;
openSet = [startIdx];
closedSet = new Set();
cameFrom = new Map();
const total = cols * rows;
gScore = new Float64Array(total).fill(Infinity);
fScore = new Float64Array(total).fill(Infinity);
gScore[startIdx] = 0;
fScore[startIdx] = heuristic(startIdx, goalIdx);
solverCurrentIdx = -1;
path = null;
pathSet = new Set();
pathDrawIndex = 0;
solverStepsPerFrame = Math.max(2, Math.floor(total / 300));
statusSpan.textContent = 'Solving with A*...';
}
function init() {
if (animFrameId) cancelAnimationFrame(animFrameId);
animFrameId = null;
const container = document.getElementById('canvas-container');
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
cellSize = Math.max(12, Math.min(30, Math.floor(Math.min(canvas.width, canvas.height) / 30)));
cols = Math.floor(canvas.width / cellSize);
rows = Math.floor(canvas.height / cellSize);
offsetX = Math.floor((canvas.width - cols * cellSize) / 2);
offsetY = Math.floor((canvas.height - rows * cellSize) / 2);
cells = [];
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
cells.push({x, y, walls: [true, true, true, true], visited: false});
}
}
let startCell = cells[0];
startCell.visited = true;
stack = [startCell];
phase = 'generating';
statusSpan.textContent = 'Generating maze...';
const total = cols * rows;
genStepsPerFrame = Math.max(3, Math.floor(total / 80));
// reset solver state
openSet = null;
closedSet = null;
cameFrom = null;
gScore = null;
fScore = null;
solverCurrentIdx = -1;
path = null;
pathSet = null;
pathDrawIndex = 0;
startLoop();
}
function loop() {
if (phase === 'generating') {
for (let i = 0; i < genStepsPerFrame; i++) {
if (phase !== 'generating') break;
generationStep();
}
} else if (phase === 'solving') {
for (let i = 0; i < solverStepsPerFrame; i++) {
if (phase !== 'solving') break;
solverStep();
}
} else if (phase === 'pathing') {
for (let k = 0; k < pathDrawSpeed; k++) {
if (pathDrawIndex < path.length) {
pathSet.add(path[pathDrawIndex]);
pathDrawIndex++;
}
}
if (pathDrawIndex >= path.length) {
phase = 'done';
}
}
draw();
if (phase === 'done') {
statusSpan.textContent = 'Solved!';
animFrameId = null;
return;
}
animFrameId = requestAnimationFrame(loop);
}
function startLoop() {
if (animFrameId) cancelAnimationFrame(animFrameId);
animFrameId = requestAnimationFrame(loop);
}
btnNew.addEventListener('click', init);
window.addEventListener('load', init);
```
Wait, `window.addEventListener('load', init);` is fine. Alternatively `document.addEventListener('DOMContentLoaded', init)`. Since script is at end of body, we can just call `init()` directly. Let's call `init()` at end.
Let's check for any missing variable declarations:
- `cells` is declared in `init`.
- `stack` declared in `init`.
- `phase` declared.
- `genStepsPerFrame`, `solverStepsPerFrame` declared.
- `startIdx`, `goalIdx` declared.
- `openSet`, `closedSet`, `cameFrom`, `gScore`, `fScore` declared.
- `solverCurrentIdx` declared.
- `path`, `pathSet`, `pathDrawIndex`, `pathDrawSpeed` declared.
All good.
One issue: In `draw()`, `closedSet` and `openSet` are checked in `getCellColor`. When `phase` is `'generating'`, these are null, but `getCellColor` only checks them in later phases.
Another issue: `pathDrawSpeed` is set in `reconstructPath()`. If `path.length` is 0 (no path), pathDrawSpeed might be 0. But if goal is unreachable (shouldn't happen in perfect maze) or cameFrom missing, `path` might be empty. In that case, `phase` is set to 'pathing' but `path` empty, and `pathDrawIndex` (0) >= path.length (0), so next frame it will set phase to 'done'. But `statusSpan` would say 'Solved!' incorrectly. However, in a perfect maze there is always a path. So fine.
But there is a subtle bug: during generation, `stack` contains cell references. `getCellColor` uses `top.x + top.y * cols`. This works. However, after `stack.pop()`, the cell is still visited. Fine.
During `generationStep`, we call `removeWalls(current, next)`. This updates walls. Good.
During `init()`, we set `startCell.visited = true`. Good.
Potential issue with `removeWalls`: The order of arguments matters for `x` and `y` difference. If `a` is current, `b` is next. `a.x - b.x` is 1 if a is to the right of b. Then a's left wall and b's right wall should be removed. That's what we did. Let's verify: if a.x=5, b.x=4, then x=1. a is to the right of b. So a's left wall (3) and b's right wall (1). Correct.
If a.x=4, b.x=5, x=-1. a is to the left of b. a's right wall (1) and b's left wall (3). Correct.
Similarly for y: a.y=5, b.y=4, y=1. a is below b. a's top wall (0) and b's bottom wall (2). Wait: if a is below b, then a is at (x,5), b at (x,4). The wall between them is a's top wall and b's bottom wall. So a.walls[0] = false, b.walls[2] = false. In code: `if (y === 1) { a.walls[0] = false; b.walls[2] = false; }`. Yes.
If a.y=4, b.y=5, y=-1. a is above b. a's bottom wall (2) and b's top wall (0). Code: `else if (y === -1) { a.walls[2] = false; b.walls[0] = false; }`. Yes.
Great.
Potential performance issue: In `draw()`, we loop over all cells to draw backgrounds. Then loop again over all cells to draw walls. That's fine.
Wall line width: `Math.max(1, Math.floor(cellSize / 12))`. For cellSize=12 -> 1. For 30 -> 2. Good.
One more subtlety: During generation, some cells may not be visited yet, but their walls are still drawn. That's fine.
We need to ensure that after maze generation finishes, all cells are visited. In recursive backtracker, yes.
What about the `canvas-container` div? It takes remaining height due to flex column. That should work.
Let's also add a subtle note in the UI: maybe show grid size. But not necessary.
We should make sure to set `statusSpan` in all cases. In `initSolver()`, we set status. In `done`, we set to 'Solved!'. In init, 'Generating maze...'. Good.
One more thing: `btnNew` is used before it's defined in the DOM? Since script is at end of body, it's fine.
Let's do a final check of the entire file.
I will now write the complete `index.html`.
Wait, `getReachableNeighbors` uses `idx(c.x, c.y+1)`. But if `c.y < rows -1`, then `idx(c.x, c.y+1)` is valid. Good.
In `heuristic`, we use `a % cols`. That is fine.
Potential off-by-one: `goalIdx = cells.length - 1`. That is bottom-right.
Alright.
One thing to improve: The `draw` function currently draws cell backgrounds as rectangles. For cells with no special color, we skip drawing, leaving the cleared background. That is fine.
But during generation, unvisited cells remain black (`#050505`), visited are `#16162a`, current is `#ff0055`. That is a nice look.
During solving, visited cells (all visited from generation) get `#0f0f1a`, which is slightly different from unvisited black but all are visited anyway. So all cells get a slight tint.
The solver might paint over closedSet and openSet. The openSet cells are greenish, closedSet blueish, current white, path gold. Start and goal are distinct.
One potential issue: In `getCellColor`, `if (solverCurrentIdx === i) return '#ffffff';` This will override path coloring if the current solver node happens to also be in the path. But during solving, path hasn't been drawn yet (`pathSet` empty). Once `phase` is `pathing`, `solverCurrentIdx` is -1 (never reset). Wait, in `initSolver`, `solverCurrentIdx = -1`. In `reconstructPath`, we don't reset it. So during `pathing` and `done`, `solverCurrentIdx` remains whatever it was in the last solverStep. We should reset it in `reconstructPath` or `initSolver`. Let's set `solverCurrentIdx = -1;` in `reconstructPath()` or in `initSolver` which is already done. But after `solverStep` sets phase to `'pathing'`, `solverCurrentIdx` is still the goal index (or some other). That might cause the goal cell to be colored white instead of red, or a cell on the path to be white. To avoid this, we can reset `solverCurrentIdx = -1` when entering `pathing` phase. Or in `reconstructPath()`. Let's do the latter.
```
function reconstructPath() {
...
pathDrawSpeed = ...;
solverCurrentIdx = -1;
}
```
Also, in `solverStep`, after setting `phase='done'` for no path, we could reset.
But let's not overcomplicate.
Another thing: `pathSet` is a Set. In `getCellColor`, we check `pathSet && pathSet.has(i)`. During `solving`, `pathSet` exists but is empty. Fine.
One more issue: In `loop()`, after phase becomes `'done'`, we draw one last time and return. But we don't set `animFrameId = null` before returning? Actually we do: in the `if (phase === 'done')` block, we set `animFrameId = null; return;`. But note that `requestAnimationFrame` hasn't been called for the next frame, so `animFrameId` refers to the current frame's ID. Setting it to null is fine.
Wait, `requestAnimationFrame` returns an ID and schedules the callback. Inside `loop()`, we call `draw()`, then if `phase === 'done'`, we set `animFrameId = null` and return, not requesting a new frame. That's correct.
But if `phase` is `'done'` because solverStep set it (e.g., no path), we should still draw. Yes, because `loop()` continues to `draw()`.
What about if `init()` is called while `loop` is running? `init()` cancels the scheduled frame (if any) and starts a new one. But if `loop` is currently executing (e.g., in the middle of a batch), `init()` runs and resets state, but the current `loop` continues until it finishes and then calls `requestAnimationFrame(loop)` at the end, unless `phase` is done. Wait, `init()` sets `animFrameId = null` but doesn't stop the currently executing `loop` function. If `init()` is called while `loop()` is in the middle of processing (e.g., during a batch), after `init()` returns, the rest of the current `loop()` executes using the new state? That could be problematic, but it's a race condition in single-threaded JS: user click triggers `init()` during execution of a scheduled `loop`. Since JS is single-threaded, the click event will only be processed after the current `loop` finishes (assuming `loop` is synchronous). Actually, `requestAnimationFrame` callbacks run before repaints, and events are processed between tasks. But `loop` runs as a synchronous function. If the user clicks during animation, the click event is queued. After `loop` finishes, either it requests another frame or returns (if done). Then the click event is processed, calling `init()` which starts a new loop. So there is no concurrency issue. Good.
One more thought: `gScore` and `fScore` are `Float64Array(total)`. `Infinity` is fine. `fScore[openSet[lowest]]` comparison works.
Let's also add a small aesthetic touch: a glow on the walls. Since `shadowBlur` is expensive, maybe only apply it to the final path? Or skip. Let's keep it simple.
The prompt: "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." So the generation should be animated? It says "Generate a random maze ... then animate an A-star or BFS solver". This could be interpreted as: generate the maze (maybe instantly or animated), and then animate the solver. Our approach animates generation (batched steps) and then solver. This should satisfy.
Let's produce the file.
One small improvement: The `statusSpan` should reflect "Generating..." and then "Solving..." and then "Solved!". Good.
Also, we might want to disable the "New Maze" button during generation/solving? It's okay to allow clicking to restart. But to prevent accidental clicks, we can leave it enabled.
Edge cases:
- If the container has 0 size (unlikely), `cols` or `rows` could be 0. We can guard: if cols<2 or rows<2, just don't init and show a message. But for normal screens it's fine.
- `cellSize` computed as min/30. If container is very small (<360px), cellSize = floor(min/30) could be <12, so we clamp to 12. Then cols/rows = container.width / 12. For a 200px container, cols ~16, rows maybe 10. Fine.
Let's do one more pass on the code for syntax errors.
Potential issues:
- `let {x, y} = cell;` in `getUnvisitedNeighbors`. `cell` is an object with x,y. Good.
- `cells[idx(x, y-1)]` - idx defined.
- In `removeWalls`, the `x` and `y` variables are separate. If both x and y differences exist, only one should be non-zero (since neighbors are orthogonal). The code handles x first, then y. It's fine.
- In `getReachableNeighbors`, we check `!c.walls[0] && c.y > 0`. This is correct.
- In `draw()`, `ctx.fillStyle` is set inside loop for each cell. This is okay.
- `ctx.lineWidth = Math.max(1, Math.floor(cellSize / 12));` Good.
- `loop()` function references `phase`, `genStepsPerFrame`, etc.
- `startLoop()` does `animFrameId = requestAnimationFrame(loop);`. `init()` does `startLoop()`. Good.
- At bottom, call `init();`.
One more thing: in `draw()`, after restoring context, we might want to draw a border around the maze area? Not necessary.
Let's consider adding a slight margin so the maze doesn't touch the edges. Our `offsetX` and `offsetY` already center it, creating equal margins. Good.
Now, what about the path animation? It draws cells as gold squares. The path might be hard to see against walls. But since walls are cyan lines and path fills the cell, it will look like a thick golden trail inside the corridors. That should be clear.
Maybe we want to draw the path as a line connecting cell centers. That could look nicer. But filling the cell is simpler and clear. Let's stick with filling.
However, because walls are drawn on top of cell fill? In our draw function, we draw cell backgrounds first, then walls on top. So the path fill will be partially covered by walls on the cell edges. That's okay because the path is inside the cell.
But if the path goes through a cell, the gold fill will be behind the walls, so the walls remain visible. That's fine.
Let's write the file.
I think it's solid. I'll produce the complete `index.html`./workspace/index.html (9840 bytes)Successfully wrote 9840 bytes to /workspace/index.html
thinking
The user wants a single-page web app that generates a random maze using the recursive backtracker algorithm on a canvas, then animates an A* or BFS solver finding the path from start to finish. I need to verify that the file I created works correctly. Let me read the file to make sure it's correct.
{"path":"/workspace/index.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> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: #050505; color: #eee; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } #app { display: flex; flex-direction: column; width: 100%; height: 100%; } #header { padding: 12px 20px; background: #111; border-bottom: 1px solid #222; display: flex; align-items: center; gap: 16px; flex-shrink: 0; } h1 { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.5px; color: #00e5ff; margin-right: auto; } button { background: #00e5ff; color: #000; border: none; padding: 8px 16px; border-radius: 4px; font-weight: 600; cursor: pointer; transition: background 0.2s; } button:hover { background: #00b8cc; } #status { font-size: 0.9rem; color: #aaa; min-width: 140px; } #canvas-container { flex: 1; position: relative; overflow: hidden; } canvas { display: block; } </style> </head> <body> <div id="app"> <div id="header"> <h1>Maze Generator & A* Solver</h1> <button id="btn-new">New Maze</button> <span id="status">Ready</span> </div> <div id="canvas-container"> <canvas id="canvas"></canvas> </div> </div> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const statusSpan = document.getElementById('status'); const btnNew = document.getElementById('btn-new'); let cols, rows; let cellSize; let offsetX, offsetY; let cells; let stack; let phase = 'idle'; let animFrameId; let genStepsPerFrame, solverStepsPerFrame; let startIdx, goalIdx; let openSet, closedSet, cameFrom, gScore, fScore; let solverCurrentIdx; let path, pathSet, pathDrawIndex, pathDrawSpeed; function idx(x, y) { return y * cols + x; } function heuristic(a, b) { const ax = a % cols, ay = Math.floor(a / cols); const bx = b % cols, by = Math.floor(b / cols); return Math.abs(ax - bx) + Math.abs(ay - by); } function removeWalls(a, b) { const x = a.x - b.x; if (x === 1) { a.walls[3] = false; b.walls[1] = false; } else if (x === -1) { a.walls[1] = false; b.walls[3] = false; } const y = a.y - b.y; if (y === 1) { a.walls[0] = false; b.walls[2] = false; } else if (y === -1) { a.walls[2] = false; b.walls[0] = false; } } function getUnvisitedNeighbors(cell) { const neighbors = []; const {x, y} = cell; if (y > 0) { const c = cells[idx(x, y - 1)]; if (!c.visited) neighbors.push(c); } if (x < cols - 1) { const c = cells[idx(x + 1, y)]; if (!c.visited) neighbors.push(c); } if (y < rows - 1) { const c = cells[idx(x, y + 1)]; if (!c.visited) neighbors.push(c); } if (x > 0) { const c = cells[idx(x - 1, y)]; if (!c.visited) neighbors.push(c); } return neighbors; } function getReachableNeighbors(cellIdx) { const c = cells[cellIdx]; const res = []; if (!c.walls[0] && c.y > 0) res.push(idx(c.x, c.y - 1)); if (!c.walls[1] && c.x < cols - 1) res.push(idx(c.x + 1, c.y)); if (!c.walls[2] && c.y < rows - 1) res.push(idx(c.x, c.y + 1)); if (!c.walls[3] && c.x > 0) res.push(idx(c.x - 1, c.y)); return res; } function generationStep() { if (stack.length === 0) { initSolver(); phase = 'solving'; return; } const current = stack[stack.length - 1]; const neighbors = getUnvisitedNeighbors(current); if (neighbors.length > 0) { const next = neighbors[Math.floor(Math.random() * neighbors.length)]; removeWalls(current, next); next.visited = true; stack.push(next); } else { stack.pop(); } } function solverStep() { if (openSet.length === 0) { phase = 'done'; statusSpan.textContent = 'No path found'; return; } let lowest = 0; for (let i = 1; i < openSet.length; i++) { if (fScore[openSet[i]] < fScore[openSet[lowest]]) lowest = i; } const currentIdx = openSet[lowest]; solverCurrentIdx = currentIdx; openSet.splice(lowest, 1); closedSet.add(currentIdx); if (currentIdx === goalIdx) { reconstructPath(); phase = 'pathing'; return; } for (const neighbor of getReachableNeighbors(currentIdx)) { if (closedSet.has(neighbor)) continue; const tentativeG = gScore[currentIdx] + 1; const inOpen = openSet.includes(neighbor); if (tentativeG < gScore[neighbor]) { cameFrom.set(neighbor, currentIdx); gScore[neighbor] = tentativeG; fScore[neighbor] = tentativeG + heuristic(neighbor, goalIdx); if (!inOpen) openSet.push(neighbor); } } } function reconstructPath() { path = []; let curr = goalIdx; while (curr !== startIdx) { path.push(curr); curr = cameFrom.get(curr); if (curr === undefined) { path = []; break; } } if (path.length > 0) path.push(startIdx); path.reverse(); pathSet = new Set(); pathDrawIndex = 0; pathDrawSpeed = Math.max(2, Math.floor(path.length / 80)); solverCurrentIdx = -1; } function getCellColor(i) { if (phase === 'generating') { if (stack.length > 0) { const top = stack[stack.length - 1]; if (top.x + top.y * cols === i) return '#ff0055'; } if (cells[i].visited) return '#16162a'; return null; } if (i === startIdx) return '#00ff88'; if (i === goalIdx) return '#ff3333'; if (pathSet && pathSet.has(i)) return '#ffdd00'; if (solverCurrentIdx === i) return '#ffffff'; if (closedSet && closedSet.has(i)) return 'rgba(0, 85, 255, 0.35)'; if (openSet && openSet.includes(i)) return 'rgba(0, 255, 136, 0.45)'; if (cells[i].visited) return '#0f0f1a'; return null; } function draw() { ctx.fillStyle = '#050505'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(offsetX, offsetY); // Cell backgrounds for (let i = 0; i < cells.length; i++) { const color = getCellColor(i); if (color) { ctx.fillStyle = color; const c = cells[i]; ctx.fillRect(c.x * cellSize, c.y * cellSize, cellSize, cellSize); } } // Walls ctx.strokeStyle = '#00e5ff'; ctx.lineWidth = Math.max(1, Math.floor(cellSize / 12)); ctx.lineCap = 'square'; ctx.beginPath(); for (let i = 0; i < cells.length; i++) { const c = cells[i]; const x = c.x * cellSize; const y = c.y * cellSize; if (c.walls[0]) { ctx.moveTo(x, y); ctx.lineTo(x + cellSize, y); } if (c.walls[1]) { ctx.moveTo(x + cellSize, y); ctx.lineTo(x + cellSize, y + cellSize); } if (c.walls[2]) { ctx.moveTo(x + cellSize, y + cellSize); ctx.lineTo(x, y + cellSize); } if (c.walls[3]) { ctx.moveTo(x, y + cellSize); ctx.lineTo(x, y); } } ctx.stroke(); ctx.restore(); } function initSolver() { startIdx = 0; goalIdx = cells.length - 1; openSet = [startIdx]; closedSet = new Set(); cameFrom = new Map(); const total = cols * rows; gScore = new Float64Array(total).fill(Infinity); fScore = new Float64Array(total).fill(Infinity); gScore[startIdx] = 0; fScore[startIdx] = heuristic(startIdx, goalIdx); solverCurrentIdx = -1; path = null; pathSet = new Set(); pathDrawIndex = 0; solverStepsPerFrame = Math.max(2, Math.floor(total / 300)); statusSpan.textContent = 'Solving with A*...'; } function init() { if (animFrameId) cancelAnimationFrame(animFrameId); animFrameId = null; const container = document.getElementById('canvas-container'); canvas.width = container.clientWidth; canvas.height = container.clientHeight; cellSize = Math.max(12, Math.min(30, Math.floor(Math.min(canvas.width, canvas.height) / 30))); cols = Math.floor(canvas.width / cellSize); rows = Math.floor(canvas.height / cellSize); offsetX = Math.floor((canvas.width - cols * cellSize) / 2); offsetY = Math.floor((canvas.height - rows * cellSize) / 2); cells = []; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { cells.push({x, y, walls: [true, true, true, true], visited: false}); } } const startCell = cells[0]; startCell.visited = true; stack = [startCell]; phase = 'generating'; statusSpan.textContent = 'Generating maze...'; const total = cols * rows; genStepsPerFrame = Math.max(3, Math.floor(total / 80)); openSet = null; closedSet = null; cameFrom = null; gScore = null; fScore = null; solverCurrentIdx = -1; path = null; pathSet = null; pathDrawIndex = 0; startLoop(); } function loop() { if (phase === 'generating') { for (let i = 0; i < genStepsPerFrame; i++) { if (phase !== 'generating') break; generationStep(); } } else if (phase === 'solving') { for (let i = 0; i < solverStepsPerFrame; i++) { if (phase !== 'solving') break; solverStep(); } } else if (phase === 'pathing') { for (let k = 0; k < pathDrawSpeed; k++) { if (pathDrawIndex < path.length) { pathSet.add(path[pathDrawIndex]); pathDrawIndex++; } } if (pathDrawIndex >= path.length) { phase = 'done'; } } draw(); if (phase === 'done') { statusSpan.textContent = 'Solved!'; animFrameId = null; return; } animFrameId = requestAnimationFrame(loop); } function startLoop() { if (animFrameId) cancelAnimationFrame(animFrameId); animFrameId = requestAnimationFrame(loop); } btnNew.addEventListener('click', init); init(); </script> </body> </html>
thinking
The file looks good. It generates a random maze using recursive backtracker with animation, then uses A* to solve it with animation. The code is self-contained in a single HTML file with inline CSS and JS. It meets all requirements: 1. Recursive backtracker algorithm for maze generation 2. Canvas-based rendering 3. A* solver (which is optimal for unweighted grids and equivalent to BFS with heuristic) 4. Animation of both generation and solving 5. No external resources 6. Single file index.html The code is complete and ready.
The single-file solution is ready. Open `index.html` to see: 1. **Maze Generation** – The recursive‑backtracker algorithm carves the maze cell‑by‑cell (animated). 2. **A\* Solver** – After generation completes, the A\* search runs (open nodes in green, closed nodes in blue, current node white). 3. **Path Reveal** – The final shortest path is drawn in gold from start (green) to finish (red). The canvas auto‑sizes to the window, and you can click **“New Maze”** to generate another one at any time.
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 and A* solves it with animated exploration and yellow solution path; click triggered new maze/solve, no keyboard response.
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.