skip to content
A Codework Orange

Drawing Maze Corridors

/ 6 min read

Table of Contents

There is nothing much to say about Maze itself, apart from drawing the walls. This is non-projective 3D made on a vector graphics computer, so how did they make a 3D-looking, first-person view game?

The Basics

Before we draw anything, we need a few definitions. This will be quick.

A maze is a grid. Each cell is either a wall or not a wall. No middle ground. Walls are simple like that.

struct coords { int col; int row; };

This structure locates anything in our maze. A player. A wall. That feeling you’ve been here before.

To check if a cell is blocked:

bool is_wall(struct coords position);

It returns true if there’s a wall. It returns false if there isn’t. Simple.

Now, direction. The player can face four ways. This is a maze, not a flight simulator:

enum direction { NORTH, WEST, SOUTH, EAST };

To find a neighboring cell, we have next():

struct coords next(struct coords position, enum direction dir);

Give it a position and a direction. It gives you the neighbor.

With just these pieces, we can draw a top-down map:

Top-down view of the maze

But we need one more concept: orientation. Direction is absolute — NORTH is always NORTH, no matter where the player looks. Orientation is relative to the player:

enum orientation { FORWARDS, LEFT, BACKWARDS, RIGHT };

The function reldir(direction, orientation) converts between them. If the player faces EAST, their LEFT is NORTH. If they face SOUTH, their LEFT is EAST. Think about it for a moment. Then stop thinking about it.

The Constraints That Help Us

MazeWars has strict rules. They seem limiting, but they actually make everything easier:

  • Each cell is wall or corridor. No half-walls, no diagonals.
  • Movement is axis-aligned. You face NORTH, SOUTH, EAST, or WEST. Never northeast.
  • The player is always in the center of a cell.
  • Movement is exactly one cell at a time.
  • Corridors are exactly one cell wide.

This means something useful: a wall two cells ahead always looks the same on screen. It doesn’t matter where you are in the maze. It doesn’t matter which way you face. The geometry is predictable.

So we can precompute it.

The 1970s hardware was not powerful. Real-time perspective math was not an option. Instead, we define a list of rectangles. Each rectangle is “what a wall looks like at distance N”:

struct rect { int left; int top; int right; int bottom; };
static const struct rect frames[] = {
{80, 60, 720, 540}, // Outer frame
{160, 120, 640, 480},
{256, 192, 544, 408},
{313, 235, 485, 364},
{348, 261, 451, 338},
{369, 276, 430, 322},
{381, 286, 417, 313},
{389, 291, 410, 307},
{393, 295, 405, 304},
{396, 297, 403, 302},
{397, 298, 401, 301},
{400, 300, 401, 301}, // Vanishing point
};

Look at these numbers. They form rectangles that shrink toward the center of the screen. This is perspective. Or rather, this is cheating at perspective. The best kind.

The first rectangle is the outer frame — our window into the corridor. Each next rectangle is where a wall appears if it’s one step further. The last entry is a single pixel. The vanishing point. Perhaps also a metaphor.

The maze structure

Walking Forward Without Moving

Here’s the key idea: we don’t simulate a camera in 3D space. We just walk through the maze data, cell by cell, and ask questions about walls.

struct coords pos = player.pos;
int dir = player.dir;
for (int depth = 0; depth < n_frames - 1; depth++) {
// Check walls at this depth
// Draw what we need
// Move to next cell (in our mind)
pos = next(pos, dir);
}

At each depth, we stand at a position and look in a direction. We check: is there a wall in front? To the left? To the right? We draw the right lines. Then we take an imaginary step forward and repeat.

The loop also keeps two variables: left_was_wall and right_was_wall. These remember if the previous depth had walls on each side. This matters. We’ll see why soon.

The Wall In Front of You

Let’s start simple. You walk down a corridor. There’s a wall in front of you. What do you do?

You draw four lines and stop.

if (is_wall(pos)) {
// Top edge
draw_line(back.left, back.top, back.right, back.top);
// Bottom edge
draw_line(back.left, back.bottom, back.right, back.bottom);
// Left edge (usually)
draw_line(back.left, back.top, back.left, back.bottom);
// Right edge (usually)
draw_line(back.right, back.top, back.right, back.bottom);
break;
}

Here, back is frames[depth] — the rectangle at our current depth. We draw its edges and we’re done. No need to look further. The wall ends our journey. Walls do that.

Drawing the end wall

I say “usually” for the vertical edges because of a small detail: if a side wall connects to this front wall, we don’t want to draw the corner line twice. But you can ignore this for now. It’s just polish.

The Walls Beside You

This part is more complex. Stay with me.

When there’s no wall in front, you check the sides. If there’s a wall to your left, you draw it going into the distance. This means diagonal lines from the current frame’s corners to the next frame’s corners:

if (is_wall(next(pos, reldir(dir, LEFT)))) {
// Top edge of left wall, going away
draw_line(back.left, back.top, front.left, front.top);
// Bottom edge of left wall, going away
draw_line(back.left, back.bottom, front.left, front.bottom);
}

Here, back is frames[depth] and front is frames[depth + 1]. We connect corners of two rectangles with lines. Do this for both sides, and you get the classic corridor look: walls on each side going toward a point far away. Or at least toward a very small rectangle.

Drawing side walls

The same logic works for the right side. Walls are fair like that.

Remembering Where You’ve Been

Now, about those was_wall variables. Imagine this: you walk down a corridor with a wall on your left. Then suddenly, there’s an opening — a side passage. The wall has ended.

At the exact depth where the wall stops, we need to draw the end of that wall:

  1. A vertical line at the corner (the edge of the wall, now visible)
  2. Horizontal lines for the top and bottom of that wall’s end
if (left_was_wall && !is_wall(next(pos, reldir(dir, LEFT)))) {
// The wall has ended! Draw its edge.
draw_line(back.left, back.top, back.left, back.bottom);
}

The opposite is also true. If there was no wall before but there is one now, we see the start of a new wall. This also needs a vertical edge line — we’re looking at the corner of a wall that just appeared.

Drawing side wall ends

Without this tracking, walls would float in space with no connection. Maybe interesting for art. Not what we want here.

Putting It All Together

The full maze drawn

The complete algorithm, without the details, looks like this:

  1. Start at the player’s position, facing their direction
  2. For each depth from 0 to the maximum:
    • Wall directly ahead? Draw its face, stop
    • Wall to the left? Draw its diagonal edges
    • Wall to the right? Draw its diagonal edges
    • Side wall just started? Draw the corner
    • Side wall just ended? Draw the corner
    • Remember what walls we saw
    • Move forward one cell (in our mind)

That’s it. No matrix math. No ray casting. No trigonometry. Just rectangles getting smaller, and careful notes about which lines to draw.

The original MazeWars ran on an Imlac PDS-1. This machine had, let’s say, the power of a modern toaster. This algorithm is why the game worked. Sometimes the clever solution is not the elegant one — it’s the one that ships.