AI Maze Generator - Recursive Backtracking Puzzle Navigate procedurally generated mazes! Adjust maz

This sketch procedurally builds a perfect maze using the recursive backtracking algorithm, animating the carving process cell by cell, then lets you navigate it with the arrow keys or watch a BFS pathfinder solve it for you. A slider controls the maze's grid resolution, and buttons let you regenerate or auto-solve the current maze.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up maze generation — Running several carving steps per frame makes large mazes finish building almost instantly instead of animating slowly.
  2. Recolor the solution path — The blue BFS trail is drawn with a single stroke() call, so changing its RGB values instantly recolors it.
  3. Make the player bigger — The player's circle diameter is a fraction of cellWidth, so raising the multiplier makes the marker fill more of its cell.
  4. Change the wall color — All maze walls are drawn with a single stroke() call inside Cell.show(), so this one line controls every wall in the maze.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch generates a brand-new maze every time you press a button, using the classic recursive backtracking algorithm to carve a perfect path network out of a grid of walled cells. You watch the maze build itself frame by frame as a cyan highlight hops between cells knocking down walls, and once it's done you can steer a green dot through the corridors to a red goal using the arrow keys, or click 'Solve Maze' to see a blue line trace the shortest route found by a breadth-first search. It combines a 2D grid of custom Cell objects, a stack-based generation algorithm, a queue-based search algorithm, and p5.js DOM elements (slider and buttons) into one interactive puzzle generator.

The code is organized around a Cell class that stores each cell's four walls and visited state, a generation loop driven from draw() that pops and pushes cells onto a stack, and a separate solveMaze() function that runs a full BFS pass whenever you ask for the solution. Studying it will teach you how to represent a grid as a 2D array of objects, how a stack turns into a depth-first maze carver, how a queue turns into a shortest-path solver, and how to wire up p5.dom UI controls to sketch logic.

⚙️ How It Works

  1. When the page loads, setup() creates a full-window canvas, builds the slider and button UI with createUI(), and calls generateMaze() to set up a fresh grid, player, and goal.
  2. Every frame, draw() clears the background, draws every cell's walls, highlights the cell currently being carved, and then calls recursiveBacktrackingStep() once - so the maze is built up gradually over many frames rather than instantly.
  3. recursiveBacktrackingStep() looks at the cell on top of a stack, picks a random unvisited neighbor, knocks down the wall between them with removeWalls(), and pushes the neighbor onto the stack; when a cell has no unvisited neighbors it gets popped off, which is the 'backtracking' part of the algorithm.
  4. Once the stack empties, mazeGenerated becomes true and the arrow keys start working: keyPressed() checks the current cell's walls before letting the player move into a neighboring cell.
  5. Clicking 'Solve Maze' runs solveMaze(), a breadth-first search that explores the grid outward from the player's cell, remembers each cell's parent, and once it reaches the goal walks the parent chain backwards to build solutionPath, which drawSolution() then renders as a connected blue line.
  6. Resizing the browser window triggers windowResized(), which resizes the canvas and recalculates cellWidth so the maze keeps filling the screen.

🎓 Concepts You'll Learn

Recursive backtracking (stack-based maze generation)Breadth-first search (queue-based pathfinding)2D grid of class instancesObject-oriented design with a Cell classp5.dom UI elements (slider, buttons)Parent-pointer path reconstructionIncremental animation of an algorithm over multiple frames

📝 Code Breakdown

setup()

setup() runs once when the sketch starts, and is the right place to configure the canvas and call one-time initialization functions like building UI or generating the first maze.

function setup() {
  createCanvas(windowWidth, windowHeight);
  pixelDensity(1); // Ensure consistent rendering across screens

  // Create UI elements
  createUI();

  // Initial maze generation
  generateMaze();
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window so the maze can use all available space.
pixelDensity(1);
Forces 1 canvas pixel per screen pixel, avoiding blurry or oversized rendering on high-DPI (Retina) screens.
createUI();
Calls a helper function that builds the slider, buttons, and info text using p5.dom.
generateMaze();
Builds the first maze grid immediately so there's something to look at as soon as the sketch loads.

createUI()

This function shows how p5.dom lets you build regular HTML controls (div, p, slider, button) and attach JavaScript callbacks to them, bridging normal web UI with your p5.js sketch logic.

function createUI() {
  let uiDiv = createDiv();
  uiDiv.style('position', 'absolute');
  uiDiv.style('top', '10px');
  uiDiv.style('left', '10px');
  uiDiv.style('background', 'rgba(255, 255, 255, 0.8)');
  uiDiv.style('padding', '10px');
  uiDiv.style('border-radius', '5px');
  uiDiv.style('font-family', 'sans-serif');
  uiDiv.style('color', '#333');

  infoText = createP('Maze Size: 20x20');
  infoText.parent(uiDiv);
  infoText.style('margin', '0 0 10px 0');

  sizeSlider = createSlider(10, 50, 20, 1); // Min, Max, Default, Step
  sizeSlider.parent(uiDiv);
  sizeSlider.style('width', '200px');
  sizeSlider.input(() => {
    infoText.html(`Maze Size: ${sizeSlider.value()}x${sizeSlider.value()}`);
  });

  generateButton = createButton('Generate New Maze');
  generateButton.parent(uiDiv);
  generateButton.style('margin-top', '10px');
  generateButton.mousePressed(generateMaze);

  solveButton = createButton('Solve Maze');
  solveButton.parent(uiDiv);
  solveButton.style('margin-left', '10px');
  solveButton.style('margin-top', '10px');
  solveButton.mousePressed(solveMaze);
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

calculation Slider Input Handler sizeSlider.input(() => { infoText.html(`Maze Size: ${sizeSlider.value()}x${sizeSlider.value()}`); });

Updates the on-screen text label live as the user drags the slider, before a new maze is even generated.

let uiDiv = createDiv();
Creates a plain HTML <div> container to group all the UI controls together visually.
uiDiv.style('position', 'absolute');
Positions the panel independently of normal page flow so it floats over the canvas.
infoText = createP('Maze Size: 20x20');
Creates a paragraph element showing the current maze dimensions, stored globally so other functions can update its text.
sizeSlider = createSlider(10, 50, 20, 1);
Creates a draggable slider ranging from 10 to 50 with a starting value of 20 and step size of 1 - this controls how many columns/rows the maze has.
sizeSlider.input(() => { infoText.html(...) });
Registers a callback that fires every time the slider moves, updating the label text to match the live slider value.
generateButton.mousePressed(generateMaze);
Wires the 'Generate New Maze' button so clicking it calls generateMaze() and builds a fresh maze.
solveButton.mousePressed(solveMaze);
Wires the 'Solve Maze' button so clicking it calls solveMaze() and triggers the BFS pathfinder.

draw()

draw() runs continuously, roughly 60 times per second. Because recursiveBacktrackingStep() is called once per frame instead of all at once, the maze generation itself becomes an animation - a common trick for visualizing algorithms.

🔬 This line runs one carving step per frame, so a 20x20 maze finishes almost instantly. What happens if you call recursiveBacktrackingStep() multiple times in a row (or in a small loop) here - does the maze appear to build faster or does it just skip the animation?

  if (!mazeGenerated) {
    recursiveBacktrackingStep();
  }
function draw() {
  background(0); // Dark background

  // Draw all cells
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      grid[i][j].show();
    }
  }

  // Draw current cell during generation
  if (!mazeGenerated && current) {
    current.highlight();
  }

  // Draw solution path if solving
  if (solvingMaze && solutionPath.length > 0) {
    drawSolution();
  }

  // Draw player
  drawPlayer();

  // Draw goal
  drawGoal();

  // Continue maze generation step by step if not yet complete
  if (!mazeGenerated) {
    recursiveBacktrackingStep();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Draw Every Cell for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { grid[i][j].show(); } }

Iterates over every cell in the 2D grid array and calls its show() method to draw its walls.

conditional Highlight Current Carving Cell if (!mazeGenerated && current) { current.highlight(); }

While the maze is still being carved, highlights the cell currently being processed in cyan.

conditional Show Solution Path if (solvingMaze && solutionPath.length > 0) { drawSolution(); }

Only draws the blue solution line once BFS has actually found a path.

conditional Advance Maze Generation if (!mazeGenerated) { recursiveBacktrackingStep(); }

Runs one more step of the carving algorithm each frame until the whole maze is finished, which is what animates the build process.

background(0);
Repaints the whole canvas black every frame so nothing from the previous frame lingers.
grid[i][j].show();
Calls each individual Cell's show() method, which draws that cell's four possible walls.
current.highlight();
Draws a translucent cyan square over the cell the generation algorithm is currently sitting on, so you can watch it work.
drawSolution();
Draws the BFS-found path as a connected blue line, but only when solvingMaze is true and a path exists.
drawPlayer();
Draws the green circle representing the player's current grid cell.
drawGoal();
Draws the red circle representing the goal cell.
recursiveBacktrackingStep();
Performs exactly one step of the maze-carving algorithm this frame - calling it once per frame (instead of running the whole algorithm at once) is what makes the maze appear to build itself gradually.

generateMaze()

generateMaze() is the 'reset' function - it wipes all state (grid, stack, player, goal, solution) and rebuilds everything fresh, which is a useful pattern whenever you need a restartable simulation.

function generateMaze() {
  cols = sizeSlider.value();
  rows = sizeSlider.value();
  cellWidth = min(width, height) / cols; // Make it square based on smaller dimension

  grid = [];
  stack = [];
  mazeGenerated = false;
  solvingMaze = false;
  solutionPath = [];

  // Initialize grid
  for (let i = 0; i < cols; i++) {
    grid[i] = [];
    for (let j = 0; j < rows; j++) {
      grid[i][j] = new Cell(i, j);
    }
  }

  // Set starting cell for generation
  current = grid[0][0];
  current.visited = true;
  stack.push(current);

  // Set player and goal
  player = { i: 0, j: 0 }; // Start at top-left
  goal = grid[cols - 1][rows - 1]; // Goal at bottom-right
  
  // Set goal cell's parent to null for BFS
  goal.parent = null;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Build the Grid for (let i = 0; i < cols; i++) { grid[i] = []; for (let j = 0; j < rows; j++) { grid[i][j] = new Cell(i, j); } }

Creates a brand-new 2D array of Cell objects, one for every column/row combination, each starting with all four walls up.

cols = sizeSlider.value();
Reads the current slider value to decide how many columns the maze grid will have.
cellWidth = min(width, height) / cols;
Divides the smaller of the canvas's width/height by the column count so cells are square and the maze fits on screen.
grid = []; stack = [];
Resets the grid array and the generation stack so a previous maze doesn't leak into the new one.
grid[i][j] = new Cell(i, j);
Instantiates a new Cell object for position (i, j) and stores it in the 2D grid array.
current = grid[0][0];
Chooses the top-left cell as the starting point for the recursive backtracking algorithm.
current.visited = true;
Marks the starting cell as visited so the algorithm won't try to revisit it.
stack.push(current);
Pushes the starting cell onto the stack - this stack is the backbone of the recursive backtracking algorithm.
player = { i: 0, j: 0 };
Creates a simple object to track the player's grid coordinates, starting at the top-left cell.
goal = grid[cols - 1][rows - 1];
Sets the goal to the bottom-right cell of the grid.

recursiveBacktrackingStep()

This function IS the recursive backtracking algorithm, implemented iteratively with an explicit stack instead of actual recursive function calls - a common technique to avoid stack overflow errors on large mazes.

🔬 random(neighbors) picks the next cell completely at random. What happens if you always pick the FIRST neighbor instead (neighbors[0]) - do the corridors become longer and straighter, or shorter and more twisty?

  if (neighbors.length > 0) {
    let next = random(neighbors);

    // Remove walls
    removeWalls(current, next);

    next.visited = true;
    stack.push(next); // Push the new cell onto the stack
  } else {
    stack.pop(); // Backtrack
  }
function recursiveBacktrackingStep() {
  if (stack.length === 0) {
    mazeGenerated = true;
    console.log("Maze Generated!");
    return;
  }

  current = stack[stack.length - 1]; // Look at the top of the stack (don't pop yet)

  let neighbors = current.checkNeighbors();

  if (neighbors.length > 0) {
    let next = random(neighbors);

    // Remove walls
    removeWalls(current, next);

    next.visited = true;
    stack.push(next); // Push the new cell onto the stack
  } else {
    stack.pop(); // Backtrack
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Check If Generation Is Done if (stack.length === 0) { mazeGenerated = true; console.log("Maze Generated!"); return; }

Once the stack is completely empty, every reachable cell has been visited, so the maze is finished.

conditional Carve Forward or Backtrack if (neighbors.length > 0) { let next = random(neighbors); // Remove walls removeWalls(current, next); next.visited = true; stack.push(next); // Push the new cell onto the stack } else { stack.pop(); // Backtrack }

If the current cell has unvisited neighbors, move forward into a random one; otherwise pop the stack to backtrack to an earlier cell.

if (stack.length === 0) {
Checks whether there are no more cells left to backtrack to, meaning generation is complete.
current = stack[stack.length - 1];
Peeks at (without removing) the most recently pushed cell - this is the 'current' cell the algorithm is working from.
let neighbors = current.checkNeighbors();
Asks the current cell for a list of its unvisited neighboring cells.
let next = random(neighbors);
Picks one of those unvisited neighbors at random - this randomness is what makes every generated maze different.
removeWalls(current, next);
Knocks down the wall between the current cell and the chosen neighbor, carving a passage.
next.visited = true;
Marks the neighbor as visited so it won't be chosen again.
stack.push(next);
Pushes the neighbor onto the stack, making it the new 'current' cell on the next step.
stack.pop();
When there are no unvisited neighbors, removes the top cell from the stack, effectively stepping back to the previous cell - this is the 'backtracking' in recursive backtracking.

removeWalls()

Because every wall is shared between two adjacent cells, removing a passage always means flipping two wall flags to false - one on each cell. This function centralizes that bookkeeping so removeWalls() is the only place that logic lives.

function removeWalls(a, b) {
  let x = a.i - b.i;
  if (x === 1) { // b is to the left of a
    a.walls[3] = false; // a's left wall
    b.walls[1] = false; // b's right wall
  } else if (x === -1) { // b is to the right of a
    a.walls[1] = false; // a's right wall
    b.walls[3] = false; // b's left wall
  }

  let y = a.j - b.j;
  if (y === 1) { // b is above a
    a.walls[0] = false; // a's top wall
    b.walls[2] = false; // b's bottom wall
  } else if (y === -1) { // b is below a
    a.walls[2] = false; // a's bottom wall
    b.walls[0] = false; // b's top wall
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Horizontal Wall Removal let x = a.i - b.i; if (x === 1) { // b is to the left of a a.walls[3] = false; // a's left wall b.walls[1] = false; // b's right wall } else if (x === -1) { // b is to the right of a a.walls[1] = false; // a's right wall b.walls[3] = false; // b's left wall }

Determines whether the neighbor is left or right of the current cell and removes the matching pair of walls between them.

conditional Vertical Wall Removal let y = a.j - b.j; if (y === 1) { // b is above a a.walls[0] = false; // a's top wall b.walls[2] = false; // b's bottom wall } else if (y === -1) { // b is below a a.walls[2] = false; // a's bottom wall b.walls[0] = false; // b's top wall }

Determines whether the neighbor is above or below the current cell and removes the matching pair of walls.

let x = a.i - b.i;
Computes the column difference between the two cells to figure out their left/right relationship.
if (x === 1) {
A difference of 1 means cell b sits one column to the left of cell a.
a.walls[3] = false;
Removes cell a's left wall (walls array is [top, right, bottom, left], so index 3 is left).
b.walls[1] = false;
Removes cell b's right wall, which is the matching wall on the other side of the same shared boundary.
let y = a.j - b.j;
Computes the row difference between the two cells to figure out their above/below relationship.

solveMaze()

solveMaze() implements breadth-first search (BFS), which explores the maze in expanding 'rings' outward from the start. Because it explores level by level, the very first time it reaches the goal, that path is guaranteed to be the shortest possible route.

🔬 Using queue.shift() (removing from the front) makes this breadth-first search. What do you think happens to the found path if you change queue.shift() to queue.pop() (removing from the end) instead - is the path still guaranteed to be the shortest one?

  while (queue.length > 0) {
    let currentCell = queue.shift();
function solveMaze() {
  if (!mazeGenerated) {
    console.log("Maze not yet generated. Please wait.");
    return;
  }
  if (solvingMaze) {
    console.log("Maze already being solved or solution shown.");
    return;
  }

  solvingMaze = true;
  solutionPath = [];

  // Reset visited status for pathfinding and parent references
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      grid[i][j].pathVisited = false;
      grid[i][j].parent = null;
    }
  }

  let startCell = grid[player.i][player.j];
  let queue = [];

  queue.push(startCell);
  startCell.pathVisited = true;

  while (queue.length > 0) {
    let currentCell = queue.shift();

    if (currentCell === goal) {
      // Goal reached, reconstruct path
      let pathCell = goal;
      while (pathCell !== null) {
        solutionPath.unshift(pathCell); // Add to the beginning of the array
        pathCell = pathCell.parent;
      }
      console.log("Solution found!");
      return;
    }

    // Check neighbors (top, right, bottom, left)
    let neighbors = [];
    if (!currentCell.walls[0] && currentCell.j > 0) neighbors.push(grid[currentCell.i][currentCell.j - 1]); // Top
    if (!currentCell.walls[1] && currentCell.i < cols - 1) neighbors.push(grid[currentCell.i + 1][currentCell.j]); // Right
    if (!currentCell.walls[2] && currentCell.j < rows - 1) neighbors.push(grid[currentCell.i][currentCell.j + 1]); // Bottom
    if (!currentCell.walls[3] && currentCell.i > 0) neighbors.push(grid[currentCell.i - 1][currentCell.j]); // Left

    for (let neighbor of neighbors) {
      if (!neighbor.pathVisited) {
        neighbor.pathVisited = true;
        neighbor.parent = currentCell;
        queue.push(neighbor);
      }
    }
  }

  console.log("No solution found (this should not happen in a generated maze).");
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

while-loop BFS Main Loop while (queue.length > 0) { let currentCell = queue.shift(); ... }

Repeatedly dequeues the next cell to explore, checks if it's the goal, and otherwise queues up its unvisited neighbors - the core of breadth-first search.

conditional Goal Reached: Reconstruct Path if (currentCell === goal) { // Goal reached, reconstruct path let pathCell = goal; while (pathCell !== null) { solutionPath.unshift(pathCell); // Add to the beginning of the array pathCell = pathCell.parent; } console.log("Solution found!"); return; }

Once the search reaches the goal cell, walks backwards through each cell's parent pointer to rebuild the full path from start to goal.

for-loop Queue Up Unvisited Neighbors for (let neighbor of neighbors) { if (!neighbor.pathVisited) { neighbor.pathVisited = true; neighbor.parent = currentCell; queue.push(neighbor); } }

Adds any not-yet-visited neighbor to the queue and remembers which cell led to it, so the path can be reconstructed later.

if (!mazeGenerated) {
Prevents solving before the maze finishes generating, since walls wouldn't be finalized yet.
grid[i][j].pathVisited = false;
Clears any leftover pathfinding state from a previous solve attempt before starting a new search.
let startCell = grid[player.i][player.j];
BFS always starts from wherever the player currently is, not necessarily the maze's entrance.
queue.push(startCell);
Adds the starting cell to the queue - the first cell that will be explored.
let currentCell = queue.shift();
Removes and returns the oldest cell in the queue - shift() (not pop()) is what makes this breadth-first rather than depth-first.
if (currentCell === goal) {
Checks whether the cell just dequeued is the goal; if so, the shortest path has just been found.
solutionPath.unshift(pathCell);
Adds each cell to the FRONT of solutionPath while walking backwards from goal to start, so the final array ends up ordered from start to goal.
if (!currentCell.walls[0] && currentCell.j > 0) neighbors.push(grid[currentCell.i][currentCell.j - 1]);
Only considers the cell above as a neighbor if there's no wall blocking it AND it's still within the grid bounds.
neighbor.parent = currentCell;
Records which cell led to this neighbor, which is essential for reconstructing the path once the goal is found.

drawSolution()

beginShape()/vertex()/endShape() is p5.js's way of drawing custom multi-point shapes - much more efficient here than calling line() separately for every pair of cells.

function drawSolution() {
  // Draw path as a series of connected circles
  noFill();
  stroke(0, 0, 255, 150); // Blue for solution path
  strokeWeight(cellWidth / 4);

  beginShape();
  for (let cell of solutionPath) {
    let x = cell.i * cellWidth + cellWidth / 2;
    let y = cell.j * cellWidth + cellWidth / 2;
    vertex(x, y);
  }
  endShape();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Trace Each Path Cell for (let cell of solutionPath) { let x = cell.i * cellWidth + cellWidth / 2; let y = cell.j * cellWidth + cellWidth / 2; vertex(x, y); }

Converts each cell in the solution path into pixel coordinates at its center, and adds it as a vertex to a continuous line shape.

noFill();
Ensures the shape drawn by beginShape()/endShape() is an outline only, not a filled polygon.
stroke(0, 0, 255, 150);
Sets the line color to semi-transparent blue so it overlays the maze without fully hiding the walls beneath it.
strokeWeight(cellWidth / 4);
Makes the path line thicker for larger cells and thinner for smaller ones, keeping it proportional to the maze's scale.
beginShape();
Starts recording a custom multi-point shape that will connect every vertex added afterward.
let x = cell.i * cellWidth + cellWidth / 2;
Converts a cell's grid column into a pixel x-coordinate at the center of that cell.
vertex(x, y);
Adds a point to the shape at this cell's center - p5.js will connect it to the previous vertex with a line.
endShape();
Finishes and renders the shape, drawing straight lines between every vertex added since beginShape().

drawPlayer()

This function is a simple example of converting abstract grid coordinates (i, j) into real pixel coordinates by multiplying by cellWidth - a pattern used throughout this sketch.

function drawPlayer() {
  fill(0, 255, 0); // Green for player
  noStroke();
  let x = player.i * cellWidth + cellWidth / 2;
  let y = player.j * cellWidth + cellWidth / 2;
  circle(x, y, cellWidth * 0.6);
}
Line-by-line explanation (4 lines)
fill(0, 255, 0); // Green for player
Sets the fill color to bright green for the player marker.
let x = player.i * cellWidth + cellWidth / 2;
Converts the player's grid column (i) into a pixel x-coordinate centered within that cell.
let y = player.j * cellWidth + cellWidth / 2;
Converts the player's grid row (j) into a pixel y-coordinate centered within that cell.
circle(x, y, cellWidth * 0.6);
Draws the player as a circle whose diameter is 60% of a cell's width, leaving some margin so it doesn't touch the walls.

drawGoal()

drawGoal() mirrors drawPlayer() almost exactly, showing how similar drawing logic can be reused for different game objects just by swapping the source coordinates and color.

function drawGoal() {
  fill(255, 0, 0); // Red for goal
  noStroke();
  let x = goal.i * cellWidth + cellWidth / 2;
  let y = goal.j * cellWidth + cellWidth / 2;
  circle(x, y, cellWidth * 0.7);
}
Line-by-line explanation (3 lines)
fill(255, 0, 0); // Red for goal
Sets the fill color to red for the goal marker.
let x = goal.i * cellWidth + cellWidth / 2;
Converts the goal cell's column into a centered pixel x-coordinate.
circle(x, y, cellWidth * 0.7);
Draws the goal slightly larger than the player (70% vs 60% of cell width) to help it stand out.

keyPressed()

keyPressed() is a p5.js callback that automatically fires whenever any key is pressed. Combined with keyCode and constants like LEFT_ARROW, it's the standard way to handle discrete, grid-based movement (as opposed to continuous movement checked inside draw()).

🔬 What happens if you remove the wall check (!currentCell.walls[3]) entirely and let LEFT_ARROW always decrease nextI, regardless of walls? Try it and see how it breaks the whole point of a maze!

  if (keyCode === LEFT_ARROW) {
    if (!currentCell.walls[3]) { // Check left wall
      nextI--;
    }
  } else if (keyCode === RIGHT_ARROW) {
    if (!currentCell.walls[1]) { // Check right wall
      nextI++;
    }
  }
function keyPressed() {
  if (!mazeGenerated) return; // Don't move player while maze is generating

  let currentCell = grid[player.i][player.j];
  let nextI = player.i;
  let nextJ = player.j;

  if (keyCode === LEFT_ARROW) {
    if (!currentCell.walls[3]) { // Check left wall
      nextI--;
    }
  } else if (keyCode === RIGHT_ARROW) {
    if (!currentCell.walls[1]) { // Check right wall
      nextI++;
    }
  } else if (keyCode === UP_ARROW) {
    if (!currentCell.walls[0]) { // Check top wall
      nextJ--;
    }
  } else if (keyCode === DOWN_ARROW) {
    if (!currentCell.walls[2]) { // Check bottom wall
      nextJ++;
    }
  }

  // Ensure player stays within bounds
  nextI = constrain(nextI, 0, cols - 1);
  nextJ = constrain(nextJ, 0, rows - 1);

  // Update player position
  player.i = nextI;
  player.j = nextJ;

  // If player moves, hide solution if it was active
  if (solvingMaze) {
    solvingMaze = false;
    solutionPath = [];
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Check Arrow Key and Wall if (keyCode === LEFT_ARROW) { if (!currentCell.walls[3]) { // Check left wall nextI--; } } else if (keyCode === RIGHT_ARROW) { if (!currentCell.walls[1]) { // Check right wall nextI++; } } else if (keyCode === UP_ARROW) { if (!currentCell.walls[0]) { // Check top wall nextJ--; } } else if (keyCode === DOWN_ARROW) { if (!currentCell.walls[2]) { // Check bottom wall nextJ++; } }

Only allows the player's intended position to move in a direction if there is no wall blocking that side of the current cell.

if (!mazeGenerated) return;
Ignores key presses entirely until the maze finishes carving, so the player can't move on an incomplete grid.
let currentCell = grid[player.i][player.j];
Looks up the Cell object at the player's current position to check its walls.
if (keyCode === LEFT_ARROW) {
Checks which arrow key was just pressed using p5.js's built-in keyCode and LEFT_ARROW constant.
if (!currentCell.walls[3]) { nextI--; }
Only decreases the intended column if the current cell's left wall (index 3) has been removed - i.e., there's an open passage.
nextI = constrain(nextI, 0, cols - 1);
Clamps the intended column so the player can never move outside the grid, as a safety net.
player.i = nextI;
Commits the new position to the player object, which drawPlayer() will read next frame.
if (solvingMaze) { solvingMaze = false; solutionPath = []; }
Hides the previously shown solution path as soon as the player moves, since the shortest path from the new position may differ.

Cell (class)

The Cell class is the fundamental building block of the whole maze - each of the hundreds of Cell instances stored in the grid array knows its own walls and visited state, and exposes show(), highlight(), and checkNeighbors() methods that the rest of the sketch calls without needing to know how a cell draws itself internally. This is a great first example of object-oriented programming in p5.js.

🔬 This draws a translucent square over the cell currently being carved. What happens if you raise the alpha value from 100 to 255, making it fully opaque?

  highlight() {
    let x = this.i * cellWidth;
    let y = this.j * cellWidth;
    noStroke();
    fill(0, 255, 255, 100); // Cyan highlight for current cell
    rect(x, y, cellWidth, cellWidth);
  }
class Cell {
  constructor(i, j) {
    this.i = i;
    this.j = j;
    this.walls = [true, true, true, true]; // [top, right, bottom, left]
    this.visited = false; // For maze generation
    this.pathVisited = false; // For pathfinding
    this.parent = null; // For pathfinding to reconstruct the path
  }

  show() {
    let x = this.i * cellWidth;
    let y = this.j * cellWidth;

    stroke(255); // White walls
    strokeWeight(2);

    // Draw walls if they exist
    if (this.walls[0]) line(x, y, x + cellWidth, y);           // Top
    if (this.walls[1]) line(x + cellWidth, y, x + cellWidth, y + cellWidth); // Right
    if (this.walls[2]) line(x + cellWidth, y + cellWidth, x, y + cellWidth); // Bottom
    if (this.walls[3]) line(x, y + cellWidth, x, y);           // Left

    // Highlight visited cells during generation (optional, can be removed once generated)
    if (this.visited && !mazeGenerated) {
      noStroke();
      fill(255, 0, 255, 50); // Pink highlight
      rect(x, y, cellWidth, cellWidth);
    }
  }

  highlight() {
    let x = this.i * cellWidth;
    let y = this.j * cellWidth;
    noStroke();
    fill(0, 255, 255, 100); // Cyan highlight for current cell
    rect(x, y, cellWidth, cellWidth);
  }

  checkNeighbors() {
    let neighbors = [];

    // Check neighbors (top, right, bottom, left)
    let top    = this.j > 0 ? grid[this.i][this.j - 1] : null;
    let right  = this.i < cols - 1 ? grid[this.i + 1][this.j] : null;
    let bottom = this.j < rows - 1 ? grid[this.i][this.j + 1] : null;
    let left   = this.i > 0 ? grid[this.i - 1][this.j] : null;

    if (top && !top.visited) neighbors.push(top);
    if (right && !right.visited) neighbors.push(right);
    if (bottom && !bottom.visited) neighbors.push(bottom);
    if (left && !left.visited) neighbors.push(left);

    return neighbors;
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Draw Only Standing Walls if (this.walls[0]) line(x, y, x + cellWidth, y); // Top if (this.walls[1]) line(x + cellWidth, y, x + cellWidth, y + cellWidth); // Right if (this.walls[2]) line(x + cellWidth, y + cellWidth, x, y + cellWidth); // Bottom if (this.walls[3]) line(x, y + cellWidth, x, y); // Left

Only draws each of the 4 possible wall lines if that wall hasn't been removed by the maze generator, so open passages appear as gaps.

conditional Collect Unvisited Neighbors if (top && !top.visited) neighbors.push(top); if (right && !right.visited) neighbors.push(right); if (bottom && !bottom.visited) neighbors.push(bottom); if (left && !left.visited) neighbors.push(left);

Builds a list of neighboring cells that exist (are inside grid bounds) and haven't yet been visited by the generation algorithm.

this.walls = [true, true, true, true]; // [top, right, bottom, left]
Every new cell starts fully walled-in on all four sides; walls get removed as the maze is carved.
this.visited = false; // For maze generation
Tracks whether recursive backtracking has already visited this cell, so it isn't revisited during carving.
this.parent = null; // For pathfinding to reconstruct the path
Used only during BFS - remembers which cell led to this one, so the final path can be traced backwards.
if (this.walls[0]) line(x, y, x + cellWidth, y);
Draws the top wall as a horizontal line only if that wall still exists (hasn't been carved away).
if (this.visited && !mazeGenerated) {
While the maze is still being generated, tints already-visited cells pink so you can see the algorithm's progress; this highlight disappears once mazeGenerated becomes true.
let top = this.j > 0 ? grid[this.i][this.j - 1] : null;
Looks up the cell above this one, but only if this cell isn't already in the top row (to avoid an out-of-bounds array access).
if (top && !top.visited) neighbors.push(top);
Adds the cell above to the neighbors list only if it exists and hasn't been visited yet.
return neighbors;
Hands back the full list of valid, unvisited neighboring cells for recursiveBacktrackingStep() to randomly choose from.

windowResized()

windowResized() is a p5.js callback that automatically fires whenever the browser window changes size, making it the standard place to keep a full-window sketch responsive.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  // Recalculate cell width and redraw maze
  cellWidth = min(width, height) / cols;
  redraw(); // Redraw the maze based on new canvas size
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the p5.js canvas to match the browser window's new dimensions whenever it changes.
cellWidth = min(width, height) / cols;
Recalculates each cell's pixel size so the same maze (same cols/rows) stretches or shrinks to fill the new canvas size.
redraw();
Forces an extra redraw of the sketch immediately after resizing, so the maze doesn't sit blank for a frame.

📦 Key Variables

cols number

Number of columns in the maze grid, set from the size slider.

let cols;
rows number

Number of rows in the maze grid, set from the size slider.

let rows;
cellWidth number

The pixel width/height of a single square cell, calculated from canvas size divided by column count.

let cellWidth;
grid array

A 2D array of Cell objects representing every cell in the maze, indexed as grid[i][j].

let grid = [];
stack array

Used by recursive backtracking as a LIFO stack of cells currently being carved through - pushing to advance, popping to backtrack.

let stack = [];
current object

Reference to the Cell currently being processed by the maze generation algorithm; also used to draw the cyan highlight.

let current;
player object

Stores the player's current grid coordinates as {i, j}, updated by arrow key presses.

let player;
goal object

Reference to the Cell object the player is trying to reach, always the bottom-right cell.

let goal;
mazeGenerated boolean

Flag that becomes true once the recursive backtracking stack empties, unlocking player movement.

let mazeGenerated = false;
solvingMaze boolean

Flag indicating whether the BFS solution path should currently be drawn on screen.

let solvingMaze = false;
solutionPath array

Ordered list of Cell objects from the player's start to the goal, built by walking parent pointers after BFS finds the goal.

let solutionPath = [];
sizeSlider object

p5.dom slider element letting the user choose the maze's column/row count before generating.

let sizeSlider;
generateButton object

p5.dom button that triggers generateMaze() when clicked.

let generateButton;
solveButton object

p5.dom button that triggers solveMaze() when clicked.

let solveButton;
infoText object

p5.dom paragraph element displaying the current maze size as text.

let infoText;

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

PERFORMANCE solveMaze()

queue.shift() removes from the front of a JavaScript array, which is an O(n) operation - on large mazes (e.g. 50x50), the repeated shifting during BFS becomes noticeably slower than necessary.

💡 Use a proper queue structure (e.g. track a read index and only push, or use a linked-list-based queue) instead of Array.shift() for better performance on big grids.

BUG keyPressed()

Arrow keys also scroll the browser window by default since preventDefault() is never called, which can be jarring on some browsers/pages.

💡 Return false from keyPressed(), or call event.preventDefault() when the pressed key is one of the arrow keys, to stop default scrolling behavior.

FEATURE draw() / keyPressed()

There's no visual feedback or message when the player actually reaches the goal cell - the game just keeps running with no win state.

💡 Add a check (e.g. in keyPressed() or draw()) for player.i === goal.i && player.j === goal.j, and display a 'You Win!' message or trigger a celebratory animation.

STYLE drawPlayer() / drawGoal()

Magic numbers like 0.6 and 0.7 for circle sizing are hardcoded inline, making them harder to find and tune consistently.

💡 Extract them into named constants like const PLAYER_SIZE_RATIO = 0.6; near the top of the file for clarity and easier tuning.

BUG windowResized()

redraw() is called here, but since draw() already loops continuously (no noLoop() was ever called), this extra redraw() call has no real effect and could be misleading to a reader.

💡 Remove the redraw() call, or explicitly call noLoop() in setup() and use redraw() intentionally if you want the sketch to only render on demand.

🔄 Code Flow

Code flow showing setup, createui, draw, generatemaze, recursivebacktrackingstep, removewalls, solvemaze, drawsolution, drawplayer, drawgoal, keypressed, cell, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> createui[createui] createui --> draw[draw loop] draw --> draw-grid-loop[draw-grid-loop] draw-grid-loop --> cell-neighbor-check[cell-neighbor-check] cell-neighbor-check --> draw-generation-check[draw-generation-check] draw-generation-check --> draw-generation-step[draw-generation-step] draw-generation-step --> stack-empty-check[stack-empty-check] stack-empty-check -->|If not empty| neighbor-branch[neighbor-branch] neighbor-branch -->|If unvisited neighbors| recursivebacktrackingstep[recursivebacktrackingstep] recursivebacktrackingstep --> removewalls[removewalls] removewalls --> removewalls-x-check[removewalls-x-check] removewalls-x-check --> removewalls-y-check[removewalls-y-check] removewalls-y-check --> stack-empty-check neighbor-branch -->|If no unvisited neighbors| stack-empty-check stack-empty-check -->|If empty| solvemaze[solvemaze] solvemaze --> bfs-main-loop[bfs-main-loop] bfs-main-loop --> bfs-goal-check[bfs-goal-check] bfs-goal-check -->|If goal reached| drawsolution[drawsolution] bfs-goal-check -->|If not goal| bfs-neighbor-loop[bfs-neighbor-loop] bfs-neighbor-loop --> bfs-main-loop draw --> draw-solution-check[draw-solution-check] draw-solution-check --> drawplayer[drawplayer] drawplayer --> drawgoal[drawgoal] drawgoal --> draw draw --> keypressed[keypressed] keypressed --> keypressed-direction-check[keypressed-direction-check] keypressed-direction-check --> draw click setup href "#fn-setup" click createui href "#fn-createui" click draw href "#fn-draw" click draw-grid-loop href "#sub-draw-grid-loop" click cell-neighbor-check href "#sub-cell-neighbor-check" click draw-generation-check href "#sub-draw-generation-check" click draw-generation-step href "#sub-draw-generation-step" click stack-empty-check href "#sub-stack-empty-check" click neighbor-branch href "#sub-neighbor-branch" click recursivebacktrackingstep href "#fn-recursivebacktrackingstep" click removewalls href "#fn-removewalls" click removewalls-x-check href "#sub-removewalls-x-check" click removewalls-y-check href "#sub-removewalls-y-check" click solvemaze href "#fn-solvemaze" click bfs-main-loop href "#sub-bfs-main-loop" click bfs-goal-check href "#sub-bfs-goal-check" click bfs-neighbor-loop href "#sub-bfs-neighbor-loop" click drawsolution href "#fn-drawsolution" click drawplayer href "#fn-drawplayer" click drawgoal href "#fn-drawgoal" click keypressed href "#fn-keypressed" click keypressed-direction-check href "#sub-keypressed-direction-check"

❓ Frequently Asked Questions

What visual experience does the AI Maze Generator sketch provide?

The sketch visually creates procedurally generated mazes using a recursive backtracking algorithm, showcasing a grid of walls and paths that the player can navigate.

How can users interact with the AI Maze Generator sketch?

Users can adjust the maze size using a slider, generate new mazes with a button click, and attempt to solve the maze using a dedicated solve button.

What creative coding technique is demonstrated in this sketch?

This sketch demonstrates the recursive backtracking algorithm for maze generation and breadth-first search (BFS) for solving the maze.

Preview

AI Maze Generator - Recursive Backtracking Puzzle Navigate procedurally generated mazes! Adjust maz - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Maze Generator - Recursive Backtracking Puzzle Navigate procedurally generated mazes! Adjust maz - Code flow showing setup, createui, draw, generatemaze, recursivebacktrackingstep, removewalls, solvemaze, drawsolution, drawplayer, drawgoal, keypressed, cell, windowresized
Code Flow Diagram