How to Make a Maze Game — Build a Browser Game in JavaScript

Play this game → Full source code →

What you'll learn in this tutorial

In a maze game, you travel from start to goal through a maze that the program builds all by itself. That auto-generation algorithm is the star of this project. Using a technique called "recursive backtracking", the game creates a brand-new maze every time you play. It's a project packed with learning — algorithm basics and grid-handling skills included.

Step 1: Auto-generate the maze

A maze is made of two kinds of cells: "walls" and "paths". We start by filling everything with walls, then dig out the paths. With a stack-based approach, whenever we hit a dead end we go back to the last fork and dig in a different direction. That is "recursive backtracking".

var MAZE_SIZE = 15;
var GRID_SIZE = MAZE_SIZE * 2 + 1; // including walls: 31x31
var WALL = 0;
var PATH = 1;

var DIRS = [
  { dx:  0, dy: -1 },  // up
  { dx:  0, dy:  1 },  // down
  { dx: -1, dy:  0 },  // left
  { dx:  1, dy:  0 }   // right
];

function generateMaze() {
  // fill the whole grid with walls
  grid = [];
  for (var y = 0; y < GRID_SIZE; y++) {
    grid[y] = [];
    for (var x = 0; x < GRID_SIZE; x++) {
      grid[y][x] = WALL;
    }
  }

  // stack-based recursive backtracking
  var stack = [];
  mazeVisited[0][0] = true;
  grid[1][1] = PATH;
  stack.push({ cx: 0, cy: 0 });

The logical maze is 15x15, but with walls in between, the real grid becomes 31x31. To convert a logical coordinate (cx, cy) to a grid coordinate, use cx * 2 + 1. The even coordinates in between are where the walls live.

The heart of the backtracking

  while (stack.length > 0) {
    var current = stack[stack.length - 1];
    var cx = current.cx;
    var cy = current.cy;

    // collect unvisited neighbor cells
    var neighbors = [];
    for (var d = 0; d < DIRS.length; d++) {
      var nx = cx + DIRS[d].dx;
      var ny = cy + DIRS[d].dy;
      if (nx >= 0 && nx < MAZE_SIZE && ny >= 0 && ny < MAZE_SIZE
          && !mazeVisited[ny][nx]) {
        neighbors.push({ nx: nx, ny: ny, dx: DIRS[d].dx, dy: DIRS[d].dy });
      }
    }

    if (neighbors.length === 0) {
      stack.pop(); // dead end: backtrack
    } else {
      var chosen = neighbors[Math.floor(Math.random() * neighbors.length)];

      // knock down the wall between the current cell and the chosen cell
      var wallGX = cx * 2 + 1 + chosen.dx;
      var wallGY = cy * 2 + 1 + chosen.dy;
      grid[wallGY][wallGX] = PATH;

      // turn the chosen cell into a path
      grid[chosen.ny * 2 + 1][chosen.nx * 2 + 1] = PATH;

      mazeVisited[chosen.ny][chosen.nx] = true;
      stack.push({ cx: chosen.nx, cy: chosen.ny });
    }
  }
}

Because the next neighbor is picked at random, a different maze appears every time. When we reach a dead end, we pop the stack to return to the previous fork (that's the backtracking) and look for another way. To knock down a wall, we change the cell sitting between the current cell and the next cell to PATH.

Step 2: Draw the maze with the DOM

Now we show the generated maze data on screen using CSS Grid. We create the 31x31 cells as div elements and color-code walls, paths, the player, and the goal with CSS classes.

function createBoard() {
  board.innerHTML = '';
  board.style.gridTemplateColumns = 'repeat(' + GRID_SIZE + ', 1fr)';
  board.style.gridTemplateRows = 'repeat(' + GRID_SIZE + ', 1fr)';
  cells = [];

  for (var y = 0; y < GRID_SIZE; y++) {
    cells[y] = [];
    for (var x = 0; x < GRID_SIZE; x++) {
      var cell = document.createElement('div');
      cell.className = 'maze-cell';
      board.appendChild(cell);
      cells[y][x] = cell;
    }
  }
}

function render() {
  for (var y = 0; y < GRID_SIZE; y++) {
    for (var x = 0; x < GRID_SIZE; x++) {
      var cell = cells[y][x];

      if (x === playerX && y === playerY) {
        cell.className = 'maze-cell player';
      } else if (x === goalX && y === goalY) {
        cell.className = 'maze-cell goal';
      } else if (grid[y][x] === WALL) {
        cell.className = 'maze-cell wall';
      } else if (visited[x + ',' + y]) {
        cell.className = 'maze-cell visited';
      } else {
        cell.className = 'maze-cell path';
      }
    }
  }
}

gridTemplateColumns and gridTemplateRows define a grid of 31 columns and 31 rows. Just by switching CSS classes, walls show up dark, paths show up light, and the player stands out in a bright color. Tinting the squares you've already walked through with visited is a nice touch too. Keeping the DOM elements in the cells array means we never have to search the DOM again — that keeps things fast.

Step 3: Add player movement and goal detection

The player moves up, down, left, and right with the arrow keys or WASD. Walls block the way, and reaching the goal means you win. Let's support swipes too.

var DIR = {
  UP:    { x:  0, y: -1 },
  DOWN:  { x:  0, y:  1 },
  LEFT:  { x: -1, y:  0 },
  RIGHT: { x:  1, y:  0 }
};

function movePlayer(dir) {
  if (!gameRunning || gameCleared) return;

  var newX = playerX + dir.x;
  var newY = playerY + dir.y;

  // bounds check
  if (newX < 0 || newX >= GRID_SIZE || newY < 0 || newY >= GRID_SIZE) return;

  // wall check
  if (grid[newY][newX] === WALL) return;

  // mark the current spot as visited
  visited[playerX + ',' + playerY] = true;

  // move the player
  playerX = newX;
  playerY = newY;

  // redraw
  render();

  // goal check
  if (playerX === goalX && playerY === goalY) {
    gameClear();
  }
}

We only update the player's position after checking that the destination is not a wall (WALL). Recording the old position in visited makes the trail you've walked show up in color. The goal is fixed at the bottom-right corner of the maze, (GRID_SIZE - 2, GRID_SIZE - 2).

Swipe controls (for mobile)

boardWrapper.addEventListener('touchend', function (e) {
  if (e.changedTouches.length === 0) return;

  var dx = e.changedTouches[0].clientX - touchStartX;
  var dy = e.changedTouches[0].clientY - touchStartY;
  var dt = Date.now() - touchStartTime;

  var minDist = 20;
  var maxTime = 400;

  if (dt > maxTime) return;
  if (Math.abs(dx) < minDist && Math.abs(dy) < minDist) return;

  var dir;
  if (Math.abs(dx) > Math.abs(dy)) {
    dir = dx > 0 ? DIR.RIGHT : DIR.LEFT;
  } else {
    dir = dy > 0 ? DIR.DOWN : DIR.UP;
  }

  movePlayer(dir);
});

We work out the direction from how far the touch moved between start and end, and how long the swipe took. minDist filters out accidental taps, maxTime filters out long presses, and comparing the X and Y distances picks whichever axis moved more.

Wrap-up — next steps

Great job! You now have the core of a maze game. You learned how to auto-generate mazes with recursive backtracking, draw with CSS Grid, and handle player movement and goal detection. To take it further, let players change the maze size, add a hint feature that shows the shortest route, or try a different generation algorithm (like the wall-growing method). A time-attack leaderboard is fun too.

Frequently asked questions

Q: Can I build this as a programming beginner?

A: Yes, you can. This tutorial writes the code step by step, so even first-timers can finish it by following along in order.

Q: How long does it take to build?

A: You can build the basic game in about 30 minutes to an hour. Polishing the looks or adding features makes it even more fun to grow.