I love the aesthetics of mazes. They have an organized chaos to them. The same goes for labyrinths, though they are technically different since a labyrinth is traditionally supposed to have a single path to the center with no dead ends or forks. They are less about getting lost and more about finding your way. Also minotaurs.

## The algorithm

After digging into various maze generation algorithms, the randomized depth-first search seemed like the simplest to implement in real time. The algorithm could be described as:

- Pick a cell (I’m using the top-right corner, but you could also pick a random cell)
- Pick a random neighbor of the current cell
- Connect the current cell to the neighbor
- Make the neighbor the current cell
- Repeat until there are no unvisited cells

Whenever a cell does not have any unvisited neighbors, you have to backtrack to a previous cell with unvisited neighbors. This requires building up a stack of cells that have been visited but not yet fully explored.

## A single SVG path

Let’s go ahead and dig into the code before I get too far ahead of myself. In the HTML, there is simply an inline `svg`

with a single `path`

for our maze drawing, as well as `rect`

for the outside walls.

```
<svg class="maze" xmlns="http://www.w3.org/2000/svg">
<path d="" />
<rect x="0" y="0" width="100%" height="100%" />
</svg>
<style>
svg.maze path,
svg.maze rect {
fill: none;
stroke: currentColor;
}
svg.maze path {
stroke-width: 0.15;
}
svg.maze rect {
stroke-width: 0.3;
}
</style>
```

I am setting the `stroke-width`

to `0.15`

. Technically, what looks like walls in the final maze are actually the corridors, but the aesthetic is the same. Please don’t tell anyone that my maze is a lie.

## The JavaScript

The `runMaze`

function starts by setting the `viewBox`

of the SVG to the size of the element, so I end up with a grid of 16x16 cells that can cover the entire SVG.

```
async function runMaze() {
const svg = document.querySelector('svg.maze');
const CELL_SIZE = 16;
const w = Math.ceil(svg.scrollWidth / CELL_SIZE);
const h = Math.ceil(svg.scrollHeight / CELL_SIZE);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
```

The next thing it does is instantiate the stack with the starting cell. I am representing a cell as an array like `[2,3]`

. There is also a `Set`

to keep track of which cells have been visited. Since arrays are passed by reference, I have to convert the cell to a string before adding it to the set. If I were to call `visited.has([2,3])`

, for example, it would always return `false`

for the same reason that `[2,3] === [2,3]`

is false.

```
const start = [w, 0];
const stack = [start];
const visited = new Set([start.join(',')]);
```

To begin drawing the maze, the `path`

is updated to begin drawing at the starting cell. For the sake of keeping the article concise, I am leaving out some code that resets the maze whenever the SVG is resized.

```
const path = svg.firstElementChild;
path.setAttribute('d', `M${start[0]},${start[1]}`);
```

Whenever we have to backtrack up the stack, we do not want to connect the last drawn cell to the next, so I have a `moveNext`

flag that will be set to `true`

whenever we backtrack.

```
let moveNext = false;
```

Now we can start the main loop. The loop will continue until the stack is empty. The last cell in the stack is the current cell.

```
while (stack.length > 0) {
const cell = stack[stack.length - 1];
const neighbor = getRandomNeighbor(cell, visited, w, h);
```

The `getRandomNeighbor`

function will return a random unvisited neighbor of the current cell. If there are no unvisited neighbors, it will return `null`

.

```
function getRandomNeighbor(cell, visited, w, h) {
const neighbors = getNeighbors(cell, visited, w, h);
if (neighbors.length === 0) return null;
return neighbors[Math.floor(Math.random() * neighbors.length)];
}
function getNeighbors(cell, visited, w, h) {
let neighbors = [];
const [x, y] = cell;
if (x > 0) neighbors.push([x - 1, y]);
if (x < w) neighbors.push([x + 1, y]);
if (y > 0) neighbors.push([x, y - 1]);
if (y < h) neighbors.push([x, y + 1]);
neighbors = neighbors.filter((n) => !visited.has(n.join(',')));
return neighbors;
}
```

Back in our loop, once we have a neighbor, we add it to the stack and mark it as visited. We also draw our SVG path to the cell and ensure `moveNext`

is false.

```
if (neighbor) {
stack.push(neighbor);
visited.add(neighbor.join(','));
appendToPath(path, neighbor, moveNext);
moveNext = false;
```

The `appendToPath`

function appends the cell to the path’s `d`

attribute. If `moveNext`

is true, it will append an `M`

command to move to the cell. Otherwise, it will append an `L`

command to draw a line to the cell.

```
function appendToPath(path, cell, isMove) {
const d = path.getAttribute('d');
path.setAttribute('d', d + ` ${isMove ? 'M' : 'L'}${cell[0]},${cell[1]}`);
}
```

If there are no neighbors, we have to backtrack. We pop the last cell off the stack and set `moveNext`

to true.

```
} else {
stack.pop();
moveNext = true;
}
}
}
```

If you were to put it all together, you would now have a function that instantly generates a maze!

## Final touches

However, I wanted to have the maze draw slowly over time. I did this by adding a `requestAnimationFrame`

promise to the loop. I also added a counter that would only call `requestAnimationFrame`

every 3 iterations. This way, the maze would be generated in real time, but it would not be too slow.

Here is the full `runMaze`

function:

```
async function runMaze() {
const svg = document.querySelector('svg.maze');
const CELL_SIZE = 16;
const w = Math.ceil(svg.scrollWidth / CELL_SIZE);
const h = Math.ceil(svg.scrollHeight / CELL_SIZE);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
const start = [w, 0];
const stack = [start];
const visited = new Set([start.join(',')]);
const path = svg.querySelector('path');
path.setAttribute('d', `M${start[0]},${start[1]}`);
let moveNext = false;
let i = 0;
while (stack.length > 0 && i / w < 60) {
const cell = stack[stack.length - 1];
const neighbor = getRandomNeighbor(cell, visited, w, h);
if (neighbor) {
stack.push(neighbor);
visited.add(neighbor.join(','));
appendToPath(path, neighbor, moveNext);
moveNext = false;
if (i % 3 === 0) await new Promise(requestAnimationFrame);
} else {
stack.pop();
moveNext = true;
}
i++;
}
}
```

## Webmentions

Kurau :tokyo: reposted this.