Maze Generation: How to Create Stunning Algorithmic Mazes With Code
A maze is one of the oldest puzzles in human culture — from the mythical Labyrinth of Crete to corn mazes and escape rooms. But for programmers, mazes are something even more fascinating: they are spanning trees of grid graphs, and the different algorithms that generate them produce wildly different visual textures, difficulty levels, and aesthetic qualities. A maze generator is also one of the most satisfying things to animate — watching walls dissolve and paths carve themselves is genuinely mesmerizing.
In this guide, we'll implement 8 different maze generation algorithms from scratch in JavaScript and Canvas. Each one produces a perfect maze (exactly one path between any two cells), but the character of each maze is completely different. Some create long, winding corridors. Others produce short dead ends. Some are biased toward certain directions. All of them are beautiful.
How maze generation works
Every maze algorithm works on a grid of cells. Each cell has four walls (north, south, east, west). A perfect maze is a spanning tree of the grid — meaning every cell is reachable from every other cell, and there are no loops. The different algorithms are really just different methods for building this spanning tree.
The shared setup for all our examples:
// Grid cell: tracks which walls are open
// walls: [north, east, south, west] — true = wall present
function createGrid(cols, rows) {
var grid = [];
for (var y = 0; y < rows; y++) {
for (var x = 0; x < cols; x++) {
grid.push({ x: x, y: y, walls: [true, true, true, true], visited: false });
}
}
return grid;
}
function getCell(grid, cols, x, y) {
if (x < 0 || x >= cols || y < 0 || y >= grid.length / cols) return null;
return grid[y * cols + x];
}
// Remove wall between two adjacent cells
function removeWall(a, b) {
var dx = b.x - a.x;
var dy = b.y - a.y;
if (dx === 1) { a.walls[1] = false; b.walls[3] = false; }
if (dx === -1) { a.walls[3] = false; b.walls[1] = false; }
if (dy === 1) { a.walls[2] = false; b.walls[0] = false; }
if (dy === -1) { a.walls[0] = false; b.walls[2] = false; }
}
1. Recursive Backtracker (depth-first search)
The recursive backtracker is the most popular maze algorithm and produces the most visually appealing results. It works like a random depth-first search: pick a starting cell, randomly walk to unvisited neighbors, and when you hit a dead end, backtrack until you find a cell with unvisited neighbors. The result is long, winding corridors with relatively few dead ends — mazes that feel organic and challenging.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cellSize = 12;
var cols = Math.floor(c.width / cellSize);
var rows = Math.floor(c.height / cellSize);
var grid = [];
var stack = [];
var current;
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++)
grid.push({ x: x, y: y, walls: [true, true, true, true], visited: false });
function getCell(gx, gy) {
if (gx < 0 || gx >= cols || gy < 0 || gy >= rows) return null;
return grid[gy * cols + gx];
}
function removeWall(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
if (dx === 1) { a.walls[1] = false; b.walls[3] = false; }
if (dx === -1) { a.walls[3] = false; b.walls[1] = false; }
if (dy === 1) { a.walls[2] = false; b.walls[0] = false; }
if (dy === -1) { a.walls[0] = false; b.walls[2] = false; }
}
current = grid[0];
current.visited = true;
stack.push(current);
function step() {
if (stack.length === 0) return false;
var neighbors = [];
var dirs = [[0,-1],[1,0],[0,1],[-1,0]];
for (var i = 0; i < 4; i++) {
var n = getCell(current.x + dirs[i][0], current.y + dirs[i][1]);
if (n && !n.visited) neighbors.push(n);
}
if (neighbors.length > 0) {
var next = neighbors[Math.floor(Math.random() * neighbors.length)];
stack.push(current);
removeWall(current, next);
next.visited = true;
current = next;
} else {
current = stack.pop();
}
return true;
}
function drawMaze() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cellSize;
var py = cell.y * cellSize;
if (cell.visited) {
var hue = (cell.x + cell.y) * 3;
ctx.fillStyle = 'hsla(' + hue + ',60%,15%,1)';
ctx.fillRect(px, py, cellSize, cellSize);
}
ctx.strokeStyle = 'hsla(200,70%,50%,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cellSize, py); }
if (cell.walls[1]) { ctx.moveTo(px + cellSize, py); ctx.lineTo(px + cellSize, py + cellSize); }
if (cell.walls[2]) { ctx.moveTo(px + cellSize, py + cellSize); ctx.lineTo(px, py + cellSize); }
if (cell.walls[3]) { ctx.moveTo(px, py + cellSize); ctx.lineTo(px, py); }
ctx.stroke();
}
// Highlight current cell
ctx.fillStyle = 'hsla(50,100%,60%,0.9)';
ctx.fillRect(current.x * cellSize + 2, current.y * cellSize + 2, cellSize - 4, cellSize - 4);
// Draw stack as trail
for (var s = 0; s < stack.length; s++) {
ctx.fillStyle = 'hsla(120,60%,40%,' + (0.1 + 0.4 * s / stack.length) + ')';
ctx.fillRect(stack[s].x * cellSize + 3, stack[s].y * cellSize + 3, cellSize - 6, cellSize - 6);
}
}
var stepsPerFrame = 4;
function animate() {
var done = false;
for (var i = 0; i < stepsPerFrame; i++) {
if (!step()) { done = true; break; }
}
drawMaze();
if (!done) requestAnimationFrame(animate);
else {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText('Recursive Backtracker — ' + cols + '×' + rows + ' maze', 8, c.height - 8);
}
}
animate();
The recursive backtracker's signature is its long passages. Because it dives as deep as possible before backtracking, it naturally creates serpentine corridors that wind across the entire grid. The stack depth can reach nearly every cell in the grid, making this algorithm use O(n) memory. The animation reveals the algorithm's nature: the yellow cursor races forward, and the green trail shows the backtrack stack growing and shrinking.
2. Randomized Prim's Algorithm
Prim's algorithm grows the maze outward from a seed, like crystal formation. It maintains a frontier of walls adjacent to the visited region. At each step, it picks a random frontier wall and, if the cell on the other side hasn't been visited, removes the wall and adds the new cell's walls to the frontier. The result is a maze with many short dead ends that radiate outward — rounder and more symmetrical than the backtracker.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 12;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
var frontier = [];
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++)
grid.push({ x: x, y: y, walls: [true, true, true, true], visited: false });
function gc(gx, gy) {
if (gx < 0 || gx >= cols || gy < 0 || gy >= rows) return null;
return grid[gy * cols + gx];
}
function rw(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
if (dx === 1) { a.walls[1] = false; b.walls[3] = false; }
if (dx === -1) { a.walls[3] = false; b.walls[1] = false; }
if (dy === 1) { a.walls[2] = false; b.walls[0] = false; }
if (dy === -1) { a.walls[0] = false; b.walls[2] = false; }
}
var dirs = [[0,-1],[1,0],[0,1],[-1,0]];
function addFrontier(cell) {
for (var i = 0; i < 4; i++) {
var n = gc(cell.x + dirs[i][0], cell.y + dirs[i][1]);
if (n && !n.visited && !n.frontier) {
n.frontier = true;
n.from = cell;
frontier.push(n);
}
}
}
var start = grid[Math.floor(rows / 2) * cols + Math.floor(cols / 2)];
start.visited = true;
addFrontier(start);
function step() {
if (frontier.length === 0) return false;
var idx = Math.floor(Math.random() * frontier.length);
var cell = frontier[idx];
frontier.splice(idx, 1);
// Find visited neighbors
var visited = [];
for (var i = 0; i < 4; i++) {
var n = gc(cell.x + dirs[i][0], cell.y + dirs[i][1]);
if (n && n.visited) visited.push(n);
}
if (visited.length > 0) {
var neighbor = visited[Math.floor(Math.random() * visited.length)];
rw(cell, neighbor);
cell.visited = true;
addFrontier(cell);
}
return true;
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
if (cell.visited) {
var dist = Math.sqrt(Math.pow(cell.x - cols/2, 2) + Math.pow(cell.y - rows/2, 2));
var hue = dist * 8;
ctx.fillStyle = 'hsla(' + hue + ',50%,18%,1)';
ctx.fillRect(px, py, cs, cs);
}
if (cell.frontier) {
ctx.fillStyle = 'hsla(50,80%,50%,0.3)';
ctx.fillRect(px, py, cs, cs);
}
ctx.strokeStyle = 'hsla(280,60%,45%,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
}
var spf = 5;
function animate() {
var done = false;
for (var i = 0; i < spf; i++) { if (!step()) { done = true; break; } }
draw();
if (!done) requestAnimationFrame(animate);
else {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText("Prim's Algorithm — grows outward like crystal", 8, c.height - 8);
}
}
animate();
Notice how Prim's maze grows outward in a roughly circular shape from the center. The frontier (highlighted in yellow) forms an expanding ring. This produces shorter dead ends and a more "bushy" structure compared to the backtracker's long corridors. Prim's mazes are generally easier to solve because the solution path tends to be more direct.
3. Randomized Kruskal's Algorithm
Kruskal's algorithm takes a completely different approach: it starts with every cell as its own set, randomly picks walls, and removes them if the two cells on either side belong to different sets (using union-find). This means the maze grows simultaneously from many points across the grid — like islands merging. The result has a very uniform, unbiased texture with no directional preference.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 12;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
var wallList = [];
var parent = [];
var rank = [];
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++) {
var idx = y * cols + x;
grid.push({ x: x, y: y, walls: [true, true, true, true], set: idx });
parent.push(idx);
rank.push(0);
if (x < cols - 1) wallList.push([idx, idx + 1, 'h']);
if (y < rows - 1) wallList.push([idx, idx + cols, 'v']);
}
// Shuffle walls
for (var i = wallList.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = wallList[i]; wallList[i] = wallList[j]; wallList[j] = tmp;
}
function find(x) {
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x;
}
function union(a, b) {
var ra = find(a), rb = find(b);
if (ra === rb) return false;
if (rank[ra] < rank[rb]) { var t = ra; ra = rb; rb = t; }
parent[rb] = ra;
if (rank[ra] === rank[rb]) rank[ra]++;
return true;
}
var wallIdx = 0;
function step() {
while (wallIdx < wallList.length) {
var w = wallList[wallIdx++];
var a = w[0], b = w[1];
if (union(a, b)) {
var ca = grid[a], cb = grid[b];
if (w[2] === 'h') { ca.walls[1] = false; cb.walls[3] = false; }
else { ca.walls[2] = false; cb.walls[0] = false; }
return true;
}
}
return false;
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
var root = find(i);
var hue = (root * 137.508) % 360;
ctx.fillStyle = 'hsla(' + hue + ',40%,20%,1)';
ctx.fillRect(px, py, cs, cs);
ctx.strokeStyle = 'hsla(160,50%,40%,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
}
var spf = 6;
function animate() {
var done = false;
for (var i = 0; i < spf; i++) { if (!step()) { done = true; break; } }
draw();
if (!done) requestAnimationFrame(animate);
else {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText("Kruskal's Algorithm — union-find merging regions", 8, c.height - 8);
}
}
animate();
During generation, each connected region gets a unique color (based on its root in the union-find structure). You can watch islands of color merge as walls are removed. The final maze is statistically the most "random" — no directional bias, no preference for long or short passages. Kruskal's is the gold standard for unbiased maze generation.
4. Eller's Algorithm (row-by-row)
Eller's algorithm is a marvel of efficiency: it generates a perfect maze one row at a time, using only O(n) memory for the current row. It assigns cells to sets, randomly merges adjacent cells in the same row, then creates vertical connections to ensure each set survives to the next row. It's the only algorithm here that can generate an infinitely tall maze with constant memory.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 14;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++)
grid.push({ x: x, y: y, walls: [true, true, true, true], set: -1 });
function gc(gx, gy) { return grid[gy * cols + gx]; }
var nextSet = 0;
var currentRow = 0;
function processRow(row) {
var isLast = row === rows - 1;
// Assign sets to unassigned cells
for (var x = 0; x < cols; x++) {
var cell = gc(x, row);
if (cell.set === -1) cell.set = nextSet++;
}
// Randomly merge adjacent cells in same row
for (var x = 0; x < cols - 1; x++) {
var cell = gc(x, row);
var right = gc(x + 1, row);
if (cell.set !== right.set && (isLast || Math.random() < 0.5)) {
cell.walls[1] = false;
right.walls[3] = false;
var oldSet = right.set;
for (var xx = 0; xx < cols; xx++) {
if (gc(xx, row).set === oldSet) gc(xx, row).set = cell.set;
}
}
}
if (isLast) return;
// Create vertical connections — at least one per set
var sets = {};
for (var x = 0; x < cols; x++) {
var s = gc(x, row).set;
if (!sets[s]) sets[s] = [];
sets[s].push(x);
}
for (var s in sets) {
var members = sets[s];
var connected = 0;
for (var i = 0; i < members.length; i++) {
if (Math.random() < 0.4 || (connected === 0 && i === members.length - 1)) {
var x = members[i];
var cell = gc(x, row);
var below = gc(x, row + 1);
cell.walls[2] = false;
below.walls[0] = false;
below.set = cell.set;
connected++;
}
}
}
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
if (cell.set >= 0) {
var hue = (cell.set * 47) % 360;
ctx.fillStyle = 'hsla(' + hue + ',45%,20%,1)';
ctx.fillRect(px, py, cs, cs);
}
ctx.strokeStyle = 'hsla(30,60%,50%,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
// Highlight current row
if (currentRow < rows) {
ctx.fillStyle = 'hsla(50,100%,60%,0.15)';
ctx.fillRect(0, currentRow * cs, c.width, cs);
}
}
function animate() {
if (currentRow < rows) {
processRow(currentRow);
currentRow++;
draw();
requestAnimationFrame(animate);
} else {
draw();
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText("Eller's Algorithm — row-by-row, O(n) memory", 8, c.height - 8);
}
}
animate();
The yellow highlight sweeps downward, showing Eller's row-by-row progression. Each row's cells are colored by their set membership — watch how sets merge horizontally and propagate downward. The final row forces all remaining sets to merge, guaranteeing a perfect maze. Eller's algorithm was published in 1982 and remains the most memory-efficient perfect maze generator known.
5. Recursive Division
Recursive division works top-down instead of bottom-up: start with an empty room and recursively divide it with walls, leaving a single passage in each wall. It's the only "wall-adding" algorithm here (all others remove walls). The result has a distinctive look: long, straight walls with obvious recursive structure — almost architectural.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 10;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
// Use a 2D wall grid: each cell tracks right and bottom walls
var hWalls = []; // horizontal walls: hWalls[y][x] = wall above row y
var vWalls = []; // vertical walls: vWalls[y][x] = wall left of col x
for (var y = 0; y <= rows; y++) {
hWalls[y] = [];
for (var x = 0; x < cols; x++) hWalls[y][x] = (y === 0 || y === rows);
}
for (var y = 0; y < rows; y++) {
vWalls[y] = [];
for (var x = 0; x <= cols; x++) vWalls[y][x] = (x === 0 || x === cols);
}
var queue = [];
queue.push([0, 0, cols, rows]);
function divide() {
if (queue.length === 0) return false;
var r = queue.shift();
var x1 = r[0], y1 = r[1], w = r[2], h = r[3];
if (w < 2 || h < 2) return true;
if (w > h) {
// Vertical wall
var wx = x1 + 1 + Math.floor(Math.random() * (w - 1));
var passage = y1 + Math.floor(Math.random() * h);
for (var y = y1; y < y1 + h; y++) {
vWalls[y][wx] = (y !== passage);
}
queue.push([x1, y1, wx - x1, h]);
queue.push([wx, y1, x1 + w - wx, h]);
} else {
// Horizontal wall
var wy = y1 + 1 + Math.floor(Math.random() * (h - 1));
var passage = x1 + Math.floor(Math.random() * w);
for (var x = x1; x < x1 + w; x++) {
hWalls[wy][x] = (x !== passage);
}
queue.push([x1, y1, w, wy - y1]);
queue.push([x1, wy, w, y1 + h - wy]);
}
return true;
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
ctx.strokeStyle = 'hsla(340,60%,50%,0.7)';
ctx.lineWidth = 2;
// Draw horizontal walls
for (var y = 0; y <= rows; y++) {
for (var x = 0; x < cols; x++) {
if (hWalls[y] && hWalls[y][x]) {
ctx.beginPath();
ctx.moveTo(x * cs, y * cs);
ctx.lineTo((x + 1) * cs, y * cs);
ctx.stroke();
}
}
}
// Draw vertical walls
for (var y = 0; y < rows; y++) {
for (var x = 0; x <= cols; x++) {
if (vWalls[y] && vWalls[y][x]) {
ctx.beginPath();
ctx.moveTo(x * cs, y * cs);
ctx.lineTo(x * cs, (y + 1) * cs);
ctx.stroke();
}
}
}
}
function animate() {
var active = false;
for (var i = 0; i < 2; i++) { if (divide()) active = true; }
draw();
if (queue.length > 0 || active) requestAnimationFrame(animate);
else {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText('Recursive Division — walls added top-down', 8, c.height - 8);
}
}
animate();
Recursive division mazes have a distinctive "room-and-corridor" feel. The long, straight walls make the recursive structure visible — you can often see the major division lines that split the maze into quadrants. This makes them easier to solve visually (the solver can see the structure), but they're fast to generate and produce aesthetically distinctive results. Recursive division is popular in game level generation because its room-like subdivisions naturally suggest dungeon layouts.
6. Sidewinder Algorithm
The sidewinder is a simple, elegant algorithm with a unique bias: it processes the grid row by row, left to right. In each row, it creates "runs" of connected horizontal cells, then randomly decides to either extend the run or close it by carving a passage upward from a random cell in the run. The top row is always a single corridor. The result has a strong directional bias — easy to traverse horizontally, harder vertically.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 13;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++)
grid.push({ x: x, y: y, walls: [true, true, true, true], done: false });
function gc(gx, gy) { return grid[gy * cols + gx]; }
var currentRow = 0;
function processRow(row) {
var run = [];
for (var x = 0; x < cols; x++) {
var cell = gc(x, row);
cell.done = true;
run.push(cell);
var atEast = (x === cols - 1);
var atTop = (row === 0);
var closeRun = atEast || (!atTop && Math.random() < 0.5);
if (!closeRun) {
// Extend run east
var right = gc(x + 1, row);
cell.walls[1] = false;
right.walls[3] = false;
} else {
if (!atTop) {
// Close run: carve north from random cell in run
var chosen = run[Math.floor(Math.random() * run.length)];
var above = gc(chosen.x, row - 1);
chosen.walls[0] = false;
above.walls[2] = false;
} else if (!atEast) {
// Top row: always extend east
var right = gc(x + 1, row);
cell.walls[1] = false;
right.walls[3] = false;
closeRun = false;
}
if (closeRun) run = [];
}
}
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
if (cell.done) {
var hue = cell.y * 5 + 180;
ctx.fillStyle = 'hsla(' + hue + ',40%,18%,1)';
ctx.fillRect(px, py, cs, cs);
}
ctx.strokeStyle = 'hsla(180,50%,45%,0.55)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
if (currentRow < rows) {
ctx.fillStyle = 'hsla(50,100%,60%,0.15)';
ctx.fillRect(0, currentRow * cs, c.width, cs);
}
}
function animate() {
if (currentRow < rows) {
processRow(currentRow);
currentRow++;
draw();
requestAnimationFrame(animate);
} else {
draw();
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText('Sidewinder — horizontal runs with upward exits', 8, c.height - 8);
}
}
animate();
The sidewinder's directional bias is clearly visible: horizontal passages tend to be longer than vertical ones, and the top row is always completely open. This makes sidewinder mazes trivial to solve if you know the algorithm (just go up and then across), but visually they have an appealing layered quality. The sidewinder is one of the fastest maze algorithms — O(n) time with O(1) memory per row.
7. Hunt-and-Kill
Hunt-and-kill is similar to the recursive backtracker but uses a different strategy when it hits a dead end: instead of backtracking through a stack, it scans the grid ("hunts") for an unvisited cell adjacent to a visited one. This eliminates the stack entirely, trading memory for time. The mazes look similar to backtracker mazes but with slightly more dead ends and shorter corridors on average.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 12;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
var current;
var hunting = false;
var huntRow = 0;
var done = false;
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++)
grid.push({ x: x, y: y, walls: [true, true, true, true], visited: false });
function gc(gx, gy) {
if (gx < 0 || gx >= cols || gy < 0 || gy >= rows) return null;
return grid[gy * cols + gx];
}
function rw(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
if (dx === 1) { a.walls[1] = false; b.walls[3] = false; }
if (dx === -1) { a.walls[3] = false; b.walls[1] = false; }
if (dy === 1) { a.walls[2] = false; b.walls[0] = false; }
if (dy === -1) { a.walls[0] = false; b.walls[2] = false; }
}
var dirs = [[0,-1],[1,0],[0,1],[-1,0]];
current = grid[0];
current.visited = true;
function walkStep() {
var neighbors = [];
for (var i = 0; i < 4; i++) {
var n = gc(current.x + dirs[i][0], current.y + dirs[i][1]);
if (n && !n.visited) neighbors.push(n);
}
if (neighbors.length > 0) {
var next = neighbors[Math.floor(Math.random() * neighbors.length)];
rw(current, next);
next.visited = true;
current = next;
return true;
}
return false; // dead end — need to hunt
}
function huntStep() {
for (var y = huntRow; y < rows; y++) {
for (var x = 0; x < cols; x++) {
var cell = gc(x, y);
if (!cell.visited) {
var visitedNeighbors = [];
for (var i = 0; i < 4; i++) {
var n = gc(x + dirs[i][0], y + dirs[i][1]);
if (n && n.visited) visitedNeighbors.push(n);
}
if (visitedNeighbors.length > 0) {
var neighbor = visitedNeighbors[Math.floor(Math.random() * visitedNeighbors.length)];
rw(cell, neighbor);
cell.visited = true;
current = cell;
huntRow = y;
return true;
}
}
}
}
return false; // all cells visited
}
function step() {
if (done) return false;
if (!hunting) {
if (!walkStep()) {
hunting = true;
}
} else {
if (!huntStep()) {
done = true;
return false;
}
hunting = false;
}
return true;
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
if (cell.visited) {
ctx.fillStyle = 'hsla(' + (cell.x * 2 + cell.y * 3) + ',40%,16%,1)';
ctx.fillRect(px, py, cs, cs);
}
ctx.strokeStyle = 'hsla(90,50%,40%,0.55)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
ctx.fillStyle = hunting ? 'hsla(0,80%,60%,0.9)' : 'hsla(50,100%,60%,0.9)';
ctx.fillRect(current.x * cs + 2, current.y * cs + 2, cs - 4, cs - 4);
if (hunting) {
ctx.fillStyle = 'hsla(0,60%,50%,0.1)';
ctx.fillRect(0, huntRow * cs, c.width, cs);
}
}
var spf = 5;
function animate() {
var active = false;
for (var i = 0; i < spf; i++) { if (step()) active = true; }
draw();
if (active) requestAnimationFrame(animate);
else {
draw();
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText('Hunt-and-Kill — random walk + linear scan', 8, c.height - 8);
}
}
animate();
Watch the cursor change color: yellow during the random walk phase, red during the hunt phase. The hunt scan line sweeps across the grid looking for the next starting point. Hunt-and-kill uses O(1) extra memory (no stack), making it ideal for very large mazes. The trade-off is that the hunt phase can be slow for sparse grids, giving O(n²) worst-case time — though in practice the constant is small.
8. Wilson's Algorithm (loop-erased random walk)
Wilson's algorithm is mathematically the most elegant: it generates a uniform spanning tree, meaning every possible maze is equally likely. It works by performing random walks from unvisited cells until the walk hits a visited cell, then adding the loop-erased path to the maze. "Loop-erased" means if the walk crosses itself, the loop is removed. Early walks can take a very long time (the first walk wanders aimlessly through an empty grid), but later walks quickly connect to the growing maze.
var c = document.createElement('canvas');
document.body.appendChild(c);
c.width = c.height = Math.min(window.innerWidth, window.innerHeight, 500);
c.style.background = '#0a0a1a';
var ctx = c.getContext('2d');
var cs = 14;
var cols = Math.floor(c.width / cs);
var rows = Math.floor(c.height / cs);
var grid = [];
var inMaze = [];
var remaining = [];
var walkPath = [];
var walkFrom = [];
var walking = false;
var done = false;
for (var y = 0; y < rows; y++)
for (var x = 0; x < cols; x++) {
var idx = y * cols + x;
grid.push({ x: x, y: y, walls: [true, true, true, true] });
inMaze.push(false);
remaining.push(idx);
}
function gc(gx, gy) {
if (gx < 0 || gx >= cols || gy < 0 || gy >= rows) return -1;
return gy * cols + gx;
}
function rw(ai, bi) {
var a = grid[ai], b = grid[bi];
var dx = b.x - a.x, dy = b.y - a.y;
if (dx === 1) { a.walls[1] = false; b.walls[3] = false; }
if (dx === -1) { a.walls[3] = false; b.walls[1] = false; }
if (dy === 1) { a.walls[2] = false; b.walls[0] = false; }
if (dy === -1) { a.walls[0] = false; b.walls[2] = false; }
}
var dirs = [[0,-1],[1,0],[0,1],[-1,0]];
// Start: add one random cell to the maze
var startIdx = Math.floor(Math.random() * grid.length);
inMaze[startIdx] = true;
remaining.splice(remaining.indexOf(startIdx), 1);
function startWalk() {
if (remaining.length === 0) { done = true; return; }
var start = remaining[Math.floor(Math.random() * remaining.length)];
walkPath = [start];
walkFrom = {};
walkFrom[start] = true;
walking = true;
}
function walkStep() {
var current = walkPath[walkPath.length - 1];
var cell = grid[current];
// Random neighbor
var shuffled = dirs.slice();
for (var i = shuffled.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = shuffled[i]; shuffled[i] = shuffled[j]; shuffled[j] = t;
}
for (var i = 0; i < 4; i++) {
var ni = gc(cell.x + shuffled[i][0], cell.y + shuffled[i][1]);
if (ni >= 0) {
if (inMaze[ni]) {
// Hit the maze — add loop-erased path
walkPath.push(ni);
for (var p = 0; p < walkPath.length - 1; p++) {
rw(walkPath[p], walkPath[p + 1]);
inMaze[walkPath[p]] = true;
var ri = remaining.indexOf(walkPath[p]);
if (ri >= 0) remaining.splice(ri, 1);
}
walking = false;
return;
}
if (walkFrom[ni]) {
// Loop detected — erase it
while (walkPath[walkPath.length - 1] !== ni) {
var removed = walkPath.pop();
delete walkFrom[removed];
}
} else {
walkPath.push(ni);
walkFrom[ni] = true;
}
return;
}
}
}
function draw() {
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, c.width, c.height);
for (var i = 0; i < grid.length; i++) {
var cell = grid[i];
var px = cell.x * cs, py = cell.y * cs;
if (inMaze[i]) {
ctx.fillStyle = 'hsla(260,40%,20%,1)';
ctx.fillRect(px, py, cs, cs);
}
ctx.strokeStyle = 'hsla(260,50%,45%,0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
if (cell.walls[0]) { ctx.moveTo(px, py); ctx.lineTo(px + cs, py); }
if (cell.walls[1]) { ctx.moveTo(px + cs, py); ctx.lineTo(px + cs, py + cs); }
if (cell.walls[2]) { ctx.moveTo(px + cs, py + cs); ctx.lineTo(px, py + cs); }
if (cell.walls[3]) { ctx.moveTo(px, py + cs); ctx.lineTo(px, py); }
ctx.stroke();
}
// Draw walk path
if (walking && walkPath.length > 0) {
for (var p = 0; p < walkPath.length; p++) {
var wc = grid[walkPath[p]];
var alpha = 0.3 + 0.5 * (p / walkPath.length);
ctx.fillStyle = 'hsla(50,90%,60%,' + alpha + ')';
ctx.fillRect(wc.x * cs + 1, wc.y * cs + 1, cs - 2, cs - 2);
}
// Draw walk connections
ctx.strokeStyle = 'hsla(50,90%,60%,0.6)';
ctx.lineWidth = 2;
ctx.beginPath();
var fc = grid[walkPath[0]];
ctx.moveTo(fc.x * cs + cs/2, fc.y * cs + cs/2);
for (var p = 1; p < walkPath.length; p++) {
var wc = grid[walkPath[p]];
ctx.lineTo(wc.x * cs + cs/2, wc.y * cs + cs/2);
}
ctx.stroke();
}
}
startWalk();
var spf = 8;
function animate() {
for (var i = 0; i < spf; i++) {
if (done) break;
if (walking) walkStep();
else startWalk();
}
draw();
if (!done) requestAnimationFrame(animate);
else {
draw();
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px monospace';
ctx.fillText("Wilson's — loop-erased random walk (uniform spanning tree)", 8, c.height - 8);
}
}
animate();
Wilson's algorithm is the most visually dramatic to watch. The yellow random walk wanders erratically, frequently looping back on itself. When it finally connects to the purple maze region, the entire loop-erased path is instantly absorbed. Early walks are long and chaotic; later walks are short and direct. The mathematical guarantee of uniformity makes Wilson's unique: unlike every other algorithm here, it has zero bias. Every possible perfect maze of the given dimensions is equally likely to be generated.
Comparing maze algorithms
Each algorithm has a distinct character:
| Algorithm | Corridors | Dead ends | Bias | Memory | Speed |
|---|---|---|---|---|---|
| Recursive Backtracker | Long, winding | Few | None | O(n) | Fast |
| Prim's | Short, branching | Many | Radial | O(n) | Fast |
| Kruskal's | Medium | Medium | None | O(n) | Fast |
| Eller's | Medium | Medium | Slight horizontal | O(cols) | Very fast |
| Recursive Division | Long, straight | Few | Axis-aligned | O(log n) | Very fast |
| Sidewinder | Horizontal runs | Medium | Strong horizontal | O(1) | Very fast |
| Hunt-and-Kill | Medium-long | Some | None | O(1) | Medium |
| Wilson's | Uniform random | Uniform | None (uniform) | O(n) | Slow start |
For game levels, the recursive backtracker is usually the best choice: its long corridors create suspense and exploration. For puzzle games, Kruskal's or Wilson's provide the fairest challenge. For infinite/streaming generation, Eller's is unbeatable. For architectural layouts, recursive division naturally creates rooms.
Beyond perfect mazes
Perfect mazes are just the beginning. Here are directions to explore:
- Add loops by randomly removing extra walls after generation — this creates multiple paths and more natural-feeling spaces
- Use cellular automata for cave-like mazes — the Game of Life and similar rules produce organic cavern structures
- Apply procedural generation techniques to create dungeon layouts — BSP trees combined with maze corridors create classic roguelike levels
- Generate mazes on non-rectangular grids — hexagonal, triangular, or circular mazes have completely different solving characteristics
- Solve mazes with A* pathfinding and visualize the solution — the contrast between explored cells and the shortest path is beautiful
- Create 3D mazes (multiple floors connected by staircases) or 4D mazes projected into 2D
- Use maze generation for texture synthesis — the wall patterns from different algorithms make excellent procedural textures for games and generative art
- Combine with mathematical art to warp mazes into non-Euclidean spaces — Möbius strip mazes, torus mazes, or hyperbolic mazes
Maze generation is where graph theory meets visual art. Every algorithm encodes a different philosophy about how to partition space — greedily, randomly, efficiently, or uniformly. The fact that such simple rules produce such varied and beautiful structures is one of the joys of algorithmic art. On Lumitree, several micro-worlds use maze generation to create branching corridors of light and color — every visitor's seed grows into a unique labyrinth that connects to the ever-expanding tree.