In the last article, we discussed building mazes using recursion and immutable data structures. But all we did there is create a graph. That is, we built a data structure in memory. We didn’t talk at all about how we render it. But the beauty of the web platform is that we have so many options. In this article, we’re going to cover three different ways to render a maze:

  1. Rendering a maze with Unicode box drawing characters;
  2. Rendering a maze with SVG; and
  3. Rendering an accessible maze with HTML and CSS.

Unicode rendering

With a Unicode renderer, we take our maze and convert it into a string. This is handy for debugging, as it works nicely with console.log(). I use this technique to include a maze in the source code of each blog post, for example. The output looks something like the following:1

┌──┬┬───────────┐
│╶┐╵│╶┬─┬─┬─┐╶─┐│
│╷├╴├╴│╷│╷╵╷└─┐└┤
││└─┤┌┘│││┌┴─┐└┐│
│└┬┐╵├╴││└┘┌╴├╴││
├┐│└─┘╶┴┴──┤╶┤┌┘│
││└─┬─┐╶┬╴╷├╴│└╴│
│└┬╴│╷└─┤╶┴┤╶┼─╴│
│╶┤╶┘├─┐│┌┐└┐├─┐│
│╷└──┘╷│││└┐│╵╷││
│├┬───┤╵│╵╷│└┬┘├┤
││╵╶┬┐└─┴─┼┴╴│╶┘│
│└┬╴╵├─╴┌╴│╶─┴──┤
├┐│┌─┤╶─┤╶┴┬──┐╷│
││└┘╷└─┐└┬╴│┌╴│└┤
│└──┴─╴└╴│╶┘│╶┴╴│
└────────┴──┴───┘

How does this work? Well, it uses 15 of the 128 box drawing characters from the Unicode Box Drawing block. These characters all represent intersections or vertices. That is, places where walls of the maze meet.

This is a shift in perspective from our maze generation. The generation code focussed on rooms. This rendering code will focus on vertices. To start, we’ll create a way to map from the directions that meet at a vertex to a box drawing character. Each key of the object represents the walls that meet at that vertex. So for a vertex where walls meet in all four directions the key is NESW (for north, east, south, west), and the value is .

const RENDER_MAP = {
  NESW: '┼',
  NES: '├',
  NEW: '┴',
  NSW: '┤',
  ESW: '┬',
  NE: '└',
  NS: '│',
  NW: '┘',
  ES: '┌',
  EW: '─',
  SW: '┐',
  N: '╵',
  E: '╶',
  S: '╷',
  W: '╴',
  '': '.',
};

Now, to make life easier for ourselves, we’re going to create a helper class that represents a vertex. It will do two main things:

  1. Help add walls to a vertex; and
  2. Convert from a vertex to a string.

Here’s how the code looks:

// vertex.js

// We'll continue using the ImmutableJS data structures
// that we used in the previous article.
import { Set } from 'immutable';

// Each of these is an immutable object representing
// a point.
import { EAST, NORTH, SOUTH, WEST } from './point';

// An array containing each of the four directions.
const DIRS = [NORTH, EAST, SOUTH, WEST];

/**
 * Converts a direction represented as a point into a
 * single character (N, E, S, or W).
 */
function dirToChar(dir: Dir) {
  switch (dir) {
    case NORTH:
      return 'N';
    case EAST:
      return 'E';
    case SOUTH:
      return 'S';
    case WEST:
      return 'W';
    default:
      throw new Error('Unknown direction encountered');
  }
}

/**
 * The Vertex class.
 */
export class Vertex {
  // The constructor takes a Set of directions and
  // stores it as a property.
  constructor(nesw) {
    this.nesw = nesw;
  }

  // Adding a direction to a vertex creates a new 
  // vertex. Using an Immutable Set here means we
  // don't have to worry about adding to the set
  // modifying other vertices.
  add(dir) {
    return new Vertex(this.nesw.add(dir));
  }

  // Convert this vertex to a string by mapping its
  // directions to a box drawing character.
  toString() {
    const key = DIRS
      .filter((d) => this.nesw.has(d))
      .map(dirToChar)
      .join('');
    return RENDER_MAP[key];
  }
}

// We export the empty vertex. Since this is immutable,
// we should only ever need one of these and we can
// consider it a constant.
export const EMPTY_VERTEX = new Vertex(Set());

With that in place, we need one more helper function before we write the rendering code. It’s called repeat(). You give it a value, v, and a number n. And it will return an array filled with the value v, repeated n times:

export function repeat(value, n) {
  return new Array(n).fill(value);
}

With that in place, we can code our maze rendering algorithm. We start by creating a 2D array of empty vertices. Then we consider each vertex in turn.

A diagram showing four squares arranged in a grid. The center of the grid is marked '(x, y) in vertex coordinates'. The top left room is labelled 'North-west room (x-1, y-1). The top right room is labelled 'North-east room (x, y-1)'. The bottom right room is labelled 'South-east room (x, y)'. The bottom left room is labelled 'South-west room (x-1, y). The lines between each square are labelled 'Potential north wall', 'Potential east wall', 'Potential south wall', and 'Potential west wall'.
Each vertex can have four adjoining rooms. That is, rooms to the north-west, north-east, south-east, and south-west. And there are potentially four walls for each vertex.

For each vertex, we construct four Point objects to represent the possible adjoining rooms. Then we pair these points together, creating an array of possible walls. Then we work out which walls to add. We do this by checking the maze graph. If two adjoining rooms have a connection, there is no wall. Conversely, if we can’t find a connection, we add a wall.

We end up with another 2D array of vertices. Once we have that, we take advantage of the .toString() override we created, and call .join('') on each row, then .join('\n') to create a single string.

The code looks like so:

/**
 * Render Maze Text.
 *
 * Renders the maze using Unicode box
 * drawing characters.
 *
 * @param {number} n  The size of the maze. The maze is
 *   always a square and n represents the number of
 *   rooms along one side of the square.
 * @param {Map<Point, List<Point>>} rooms A graph 
 *   representation of the maze, as a map of rooms
 *   (x,y coordinates) to adjacent rooms (a list of
 *   x,y coordinates).
 * @returns A Unicode representation of the maze.
 */
export function renderMazeText(n, rooms): string {
  // Construct a 2D array with n + 1 rows and
  // n + 1 columns.
  const emptyVertices = repeat(undefined, n + 1)
    .map(() => repeat(emptyVertex, n + 1));

  // Map over each vertex and consider its possible
  // adjoining rooms.
  const vertices = emptyVertices.map((row, y) =>
    row.map((vertex, x) => {
      // We are looking at the vertex at x,y. There are
      // potentially rooms to the NW, NE, SE, and SW.
      const nwRoom = p(x - 1, y - 1);
      const neRoom = p(x, y - 1);
      const seRoom = p(x, y);
      const swRoom = p(x - 1, y);

      // Pair the possible adjacent rooms with the
      // direction of the wall between them.
      return (
        [
          [nwRoom, neRoom, NORTH],
          [neRoom, seRoom, EAST],
          [seRoom, swRoom, SOUTH],
          [swRoom, nwRoom, WEST],
        ]
      ).reduce((v, [a, b, dir]) => {
        // If at least one of the rooms is inside the
        // maze and there is no connection between them,
        // add a half-wall.
        return (rooms.has(a) || rooms.has(b))
          && !rooms.get(a)?.includes(b)
            ? v.add(dir)
            : v;
      }, vertex);
    }),
  );

  // Convert the whole thing to a string, taking
  // advantage of the Vertex .toString() override.
  return vertices.map((row) => row.join('')).join('\n');
}

When we run this code, we get back a string. And we can use strings almost anywhere. Here’s another example:

┌──┬─────┬─┬─┬──┐
│╷╷└─╴┌┬╴│╷╵╷│┌╴│
││└┬─┬┘│╶┤├─┘└┤╶┤
│└┐│╶┘╷└╴╵│┌─┐└╴│
│┌┘├──┤┌─┬┴┘╷├──┤
││╷│┌╴│╵╷│╶┬┘│╶┐│
├┘│││┌┴─┘└┐│╷│┌┘│
│┌┘││└─┐┌─┘│└┘│┌┤
│├─┘└┬╴├┘┌┬┴─┬┘││
│╵╶┬─┘╷│┌┘│╶┐│╶┤│
│┌┬┘┌─┤│└┐╵┌┘├╴││
│╵│╶┤╶┘└┐└┬┘╷└┬┘│
├─┴╴├───┴┐╵┌┼╴│╶┤
│╶─┐│╶┬╴┌┴─┘│╶┤╷│
│┌┐│└┐│╶┤╶┐┌┴┐└┤│
│╵│└─┘└┐└╴│╵╷└╴╵│
└─┴────┴──┴─┴───┘

It’s great to have the portability of a string. But it’s not without its problems. In most scenarios, the lines don’t join up vertically. And most fonts render characters as oblongs rather than squares. The rendering is functional, but not pretty. Working with a web browser, we have other options.

SVG rendering

One option for rendering our maze is using SVG. The result looks something like the following:

An SVG rendering of a 16 by 16 room maze. The maze is a perfect square and is rendered using single pixel black lines on a light-coloured background. Apologies that it isn’t accessible. We’ll deal with this later in the article.
When we render with SVG, we can have perfectly square rooms and walls that line up precisely.

The code to generate SVG output is even simpler than our Unicode renderer. This is because we don’t need to muck around with vertices. Instead, we start by drawing two long lines for the north and west sides of the maze.

A diagram showing just the north and west walls of a maze.

Next, we consider each room in turn. For each room, we check to see if there is an adjoining room to the south or east. If not, then we need to draw a line to represent the wall between those two rooms.

A diagram showing a square representing a room at (x, y) labelled ‘Current room’. To its right is another square labelled ‘East room (x+1, y)’. Below the original square is yet another square labelled ‘South room (y, x+1)’. The line between current room and east room is lablled ’Potential east wall’. The line between current room and south room is labelled ‘Potential south wall’.

We draw the wall by creating a string to represent an SVG path element. Once we’ve repeated this process for all the rooms, we end up with a List of strings. We then .join() the list into a single string and insert these into an SVG group element. And we place all that into an outer SVG element. The code looks something like the following:

Here’s the code:

import { List } from 'immutable';

/**
 * Render maze as SVG.
 *
 * @param {number} n The size of the maze. The maze is
 *   always a square and n represents the number of
 *   rooms along one side of the square.
 * @param {number} squareSize The size in pixels to draw
 *   each room.
 * @param {Map<Point, List<Point>>} rooms A graph
 *   representation of the maze. That is, a map of rooms
 *   (Point objects) to adjacent rooms (a List of
 *   Point objects).
 * @returns A string that will draw an SVG
 *   representation of the maze if converted to
 *   DOM elements.
 */
export function renderMazeSVG(n, squareSize, rooms) {
  // Calculate the total size of the SVG image we're
  // creating. Since it's a square, it will be the same
  // in each dimension.
  const totalSize = (n + 2) * squareSize;

  // Create two long 'walls' for the north and west
  // sides. We do this by making two SVG path elements.
  const wStart = squareSize;
  const wEnd = (n + 1) * squareSize;
  const northWall = `<path d="M ${wStart} ${wStart} L ${wEnd} ${wStart}" />`;
  const westWall = `<path d="M ${wStart} ${wStart} L ${wStart} ${wEnd}" />`;

  // Construct the rest of the maze by examining each
  // room in turn.
  const wallLines = rooms
    .reduce((allWalls, doors, room) => {
      // For the given room, check to see if it
      // has an adjoining room to the south or east.
      const walls = [SOUTH, EAST]
        .map(addPoint(room))
        .filter((adj) => !doors.includes(adj))

        // Calculate the start and end point for the wall
        .map((adj) => [adj.x, adj.y, room.x + 1, room.y + 1])
        .map((pts) => pts.map((pt) => (pt + 1) * squareSize))

        // Convert the start and end points into an SVG
        // Path element.
        .map(([ax, ay, bx, by]) => `<path d="M ${ax} ${ay} L ${bx} ${by}" />`);

      // Add the paths we've just created (if any) to
      // the list of maze lines we're creating.
      return allWalls.push(...walls);
    }, List())

    // Join all these path strings together into a
    // single string.
    .join('\n');

  // Construct the SVG element with all the walls as
  // as children.
  return `<svg width="${totalSize}" height="${totalSize}" viewBox="0 0 ${totalSize} ${totalSize}">
     <g class="mazebg" stroke="currentColor" stroke-width="1">
      ${northWall}
      ${westWall}
      ${wallLines}
     </g>
    </svg>`;
}

When we run the code, we get back a string. But we can insert this string into the DOM and the browser will render it for us. Or, we can write the string to a file and render it as an image.

Here’s another example, just for fun:

An SVG rendering of a 16 by 16 room maze. The maze is a perfect square and is rendered using single pixel black lines on a light-coloured background. Apologies that it isn’t accessible. We’ll deal with this in the next section.

Could we render an accessible maze?

The trouble with both text and SVG is that they’re not terribly accessible. It’s not apparent to assistive technologies that we’re dealing with a maze. We can improve things slightly by adding alt text to an img element showing the SVG. But that still doesn’t provide the same amount of information that the visual rendering does. So, is there a way we could do better?

One simple thing we could try is creating a list of all the rooms as HTML. It’s not pretty, but it does contain all the information in the maze. So it would tick the box for being accessible.

The code to generate an HTML version of the maze is even simpler than our SVG renderer. We’ll start by writing a helper functions to describe the ‘doors’ leading out of a given room. That is, a function to create a textual description designed for a human to read. It takes a list of doors, and a Point representing the current room. And it returns a string that we can insert into a sentence written in English.

import { subtractPoint } from './point';

const directionToString = new Map([
  [NORTH, 'north'],
  [EAST, 'east'],
  [SOUTH, 'south'],
  [WEST, 'west'],
]);

function doorsDescription(doors, room) {
  const dirs = doors.map((door) => {
    const direction = directionToString.get(subtractPoint(door)(room));
    return direction;
  });
  return dirs.set(-1, (doors.size > 1 ? 'and ' : '') + dirs.get(-1)).join(', ');
};

Then we can write a function that generates the full HTML as follows:

/**
 * Rooms to List.
 *
 * Takes a maze graph representation and renders it as
 * an HTML list.
 *
 * @param {Map<Point, List<Point>>} rooms  graph
 *   representation of the maze. That is, a map of rooms
 *   (Point objects) to adjacent rooms (a List of
 *   Point objects).
 * @returns An HTML string that represents the maze as
 *   an unordered list.
 */
export function renderMazeAsList(rooms) {
  return (
    '<ul class="room-list">' +
    rooms
      .sortBy((_, { x, y }) => Math.sqrt(x ** 2 + y ** 2))
      .map(
        (doors, room) =>
          `<li class="maze-room">
          <p>Room ${room.x},${room.y}</p>
          <p>${doors.size === 1 ? 'There is a door' : 'There are doors'} to the
          ${doorsDescription(doors, room)}.</p>
         </li>`,
      )
      .join('\n') +
    '</ul>'
  );
}

This will generate an HTML string that looks something like the following:

<div class="accessibleMaze">
  <ul class="room-list">
    <li class="maze-room">
      <p>Room 0,0</p>
      <p>There are doors to the south, and east.</p>
    </li>
    <li class="maze-room">
      <p>Room 1,0</p>
      <p>There are doors to the west, and east.</p>
    </li>
    <li class="maze-room">
      <p>Room 0,1</p>
      <p>There are doors to the east, north, and south.</p>
    </li>

    <!-- … You get the idea … -->
    
    <li class="maze-room">
      <p>Room 15,15</p>
      <p>There are doors to the west, and north.</p>
    </li>
  </ul>
</div>

And if we render it, it looks like what you see below. It might check the accessibility box, technically. But let’s face it—it’s rather dull.

But perhaps we could enhance this a little What if we made each list item focus-able, and then added links to adjacent rooms. That way, you could navigate through the list using your keyboard.

We’ll start by adding a new helper function that will generate the list of links for us:

function doorsToList(doors, room: Point) {
  return (
    '<ul class="door-list">' +
    doors
      .map((door) => {
        const direction = directionToString.get(subtractPoint(door)(room));
        return `<li class="door door-${direction}">
          <a class="doorLink" href="#room-${door.x}-${door.y}" title="Take the ${direction} door">${direction}</a>
        </li>`;
      })
      .join('\n') +
    '</ul>'
  );
}

And then we update the HTML generating code:

/**
 * Rooms to List.
 *
 * Takes a maze graph representation and renders it as
 * an HTML list.
 *
 * @param {Map<Point, List<Point>>} rooms  graph
 *   representation of the maze. That is, a map of rooms
 *   (Point objects) to adjacent rooms (a List of
 *   Point objects).
 * @returns An HTML string that represents the maze as
 *   an unordered list.
 */
export function renderMazeAsList(rooms) {
  return (
    '<ul class="room-list">' +
    rooms
      .sortBy((_, { x, y }) => Math.sqrt(x ** 2 + y ** 2))
      .map(
        (doors, room) =>
          `<li tabindex="0" class="maze-room" id="room-${room.x}-${room.y}">
          <p>Room ${room.x},${room.y}</p>
          <p>${doors.size === 1 ? 'There is a door' : 'There are doors'} to the
          ${doorsDescription(doors, room)}.</p>
          ${doorsToList(doors, room)}
         </li>`,
      )
      .join('\n') +
    '</ul>'
  );
}

Note how we’ve added tabindex attributes to each list item to make them focusable. And we’ve added id attributes that match the links generated by doorsToList().

Running this code, we get lengthier HTML:

<div class="accessibleMaze">
  <ul class="room-list">
    <li tabindex="0" class="maze-room" id="room-0-0">
      <p>Room 0,0</p>
      <p>There are doors to the south, and east.</p>
      <ul class="door-list">
        <li class="door door-south">
          <a class="doorLink" href="#room-0-1" title="Take the south door">south</a>
        </li>
        <li class="door door-east">
          <a class="doorLink" href="#room-1-0" title="Take the east door">east</a>
        </li>
      </ul>
    </li>
    <li tabindex="0" class="maze-room" id="room-1-0">
      <p>Room 1,0</p>
      <p>There are doors to the west, and east.</p>
      <ul class="door-list">
        <li class="door door-west">
          <a class="doorLink" href="#room-0-0" title="Take the west door">west</a>
        </li>
        <li class="door door-east">
          <a class="doorLink" href="#room-2-0" title="Take the east door">east</a>
        </li>
      </ul>
    </li>
    
    <!-- … You get the idea … -->

    <li tabindex="0" class="maze-room" id="room-15-15">
      <p>Room 15,15</p>
      <p>There are doors to the west, and north.</p>
      <ul class="door-list">
        <li class="door door-west">
          <a class="doorLink" href="#room-14-15" title="Take the west door">west</a>
        </li>
        <li class="door door-north">
          <a class="doorLink" href="#room-15-14" title="Take the north door">north</a>
        </li>
      </ul>
    </li>
  </ul>
</div>

So, we’ve added some ids and tabindex attributes, and we’ve given each room a list of links that point to the adjacent rooms—kind of like doorways. And we’ve made this bit easier to navigate with the keyboard.

If we render that out, it looks like the following. Still pretty dull.

But what if we added some CSS so that we only show the first room, or whichever list item is focussed. And, while we’re playing with CSS, perhaps we could position the links around the text. And maybe we could add some background images and border images…

/* Accessible Maze Rendering
 * ------------------------------------------------------------------------------ */

.maze-room {
  box-sizing: border-box;
  list-style: none;
  margin: 0;
  width: 28em;
  height: 28em;
  background-image: url('./img/floor.png');
  background-size: 64px 64px;
  border-image: url('./img/walls.png');
  border-image-slice: 16;
  border-image-repeat: round;
  border-width: 64px;
  border-image-width: 64px;
  padding: 5em;
  position: absolute;
  left: -64em;
  top: 0;
}

.room-list:not(:has(:focus)) .maze-room:first-child,
.maze-room:focus,
.maze-room:has(:focus) {
  outline: none;
  left: 0;
}

.door {
  list-style: none;
  margin: 0;
  padding: 0;
  position: absolute;
  background: url('./img/dungeon-doors.png') transparent;
  background-size: 224px 224px;
}

.doorLink {
  display: block;
  width: 100%;
  height: 100%;
  text-align: center;
  background-repeat: no-repeat;
  overflow: hidden;
  text-indent: -99em;
}

.door-south {
  background-position: top center;
  height: 4em;
  width: calc(100% - 10em);
  bottom: 0;
  left: 5em;
}

.door-north {
  background-position: bottom center;
  height: 4em;
  width: calc(100% - 10em);
  top: 0;
  left: 5em;
}

.door-west {
  background-position: center right;
  width: 4em;
  height: calc(100% - 10em);
  top: 5em;
  left: 0;
}

.door-east {
  background-position: center left;
  width: 4em;
  height: calc(100% - 10em);
  top: 5em;
  right: 0;
}

#room-0-0::after {
  content: ' ';
  display: block;
  position: absolute;
  top: 5em;
  left: 0;
  height: calc(100% - 10em);
  width: 4em;
  background: url('./img/dungeon-exits.png') center right no-repeat;
  background-size: 128px 88px;
}

.maze-room:last-child::after {
  content: ' ';
  display: block;
  position: absolute;
  top: 5em;
  right: 0;
  height: calc(100% - 10em);
  width: 4em;
  background: url('./img/dungeon-exits.png') center left no-repeat;
  background-size: 128px 88px;
}

Perhaps we could throw in some pixel art… and a random object or two. And suddenly, you’ve got the beginnings of a game. No JS required.

Sprites by Scott Matott. Used under the Open Game Art license (OGA-BY-3.0).

Try clicking the doors and see where it takes you. Can you find the south-east corner with the exit door?

So what?

What have we done here?

We’ve looked at three different methods for rendering a maze graph. And, strangely, the method with the simplest output (the Unicode renderer) involved the most complex code. Yet, arguably the most interesting output was the HTML renderer. And that involved the most straightforward code.

What’s more intriguing, though, is that thinking through how to make our maze accessible lead us on an adventure. By adding some simple CSS, we created something visually appealing and exciting. And it makes me wonder. People tend to think of accessibility as admirable, but perhaps not essential. That is, something we fully intend to consider, after we’ve done the ‘real’ work. But what if we’re missing out by having this attitude? Does accessibility have to be a tedious chore? What if we thought about it differently? What if we considered it a constraint that leads to more creative output? And possibly an opportunity to inject more fun and delight into our products? Maybe that’s worth pondering some more.

Finally, I’ve created a Github repository and npm package for this code. Just in case you want to muck around with mazes but don’t want to write this all out by hand.