Skip to main content
kld.dev

Let's build a maze in SVG

February 16, 2023

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.