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.
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.
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.
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.
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).
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.
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.
A: Yes, you can. This tutorial writes the code step by step, so even first-timers can finish it by following along in order.
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.