Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/treasure-hunt-game/llms.txt

Use this file to discover all available pages before exploring further.

Every click of the New Maze button produces a board that has never existed before — yet every board is guaranteed to be solvable, have the correct number of open cells, and require at least 12 moves to traverse from start to treasure. This is the job of createRandomMaze(), a two-phase carve-and-validate algorithm that first cuts a single winding path from [0,0] to [7,7], then fills in additional dead-end branches to hit a target cell density, and finally validates that the resulting maze meets the minimum path-length requirement before returning the board.

Function Signature and Return Shape

export function createRandomMaze({ rows = 8, cols = 8, density = 0.55 } = {}) {
  // ...
  return { maze, solution: solution.path };
}
ParameterDefaultDescription
rows8Number of rows in the grid
cols8Number of columns in the grid
density0.55Target fraction of cells that should be open (0–1)
The function returns an object with two keys:
  • maze — the completed 8×8 two-dimensional array (1 = open, 0 = blocked)
  • solution — an array of [row, col] pairs representing the shortest path from [0,0] to [7,7] as computed by BFS after generation

Phase 1: carveUniqueMainPath

The first phase builds the backbone of the maze — a single connected corridor from start to target. It uses a depth-first walk with backtracking:
1

Initialize

Create a fully blocked (0) grid of the requested size. Open the start cell [0,0] and add it to the active path stack and the seen set.
2

Shuffle and filter candidates

From the current head of the path, apply all four ACTION_DELTAS to get neighboring cells. Shuffle the results randomly, then filter to keep only cells that are:
  • Inside the grid boundaries
  • Not already in the seen set
  • Have at most 1 open neighbor (openNeighborCount ≤ 1) — this prevents the carver from accidentally creating shortcuts or merging two corridors into a loop
3

Heuristic sort toward the target

Sort the filtered candidates by Manhattan distance to [7,7]. 68% of the time the sort prefers closer cells (greedy progress); 32% of the time it flips the sort, allowing the path to meander and produce varied layouts.
4

Advance or backtrack

If at least one candidate exists, open the first one, add it to the path and seen, and continue. If no candidates exist and the path has more than one cell, remove the current head (path.pop()), close it again (maze[row][col] = 0), and retry from the previous cell. Up to 5,000 attempts are made before giving up.
5

Return or fail

If the path head reaches [7,7], return { maze, path }. If 5,000 attempts expire without reaching the target, return null so the outer loop can retry.

The openNeighborCount Guard

export function openNeighborCount(maze, row, col) {
  return Object.values(ACTION_DELTAS).reduce((count, [dr, dc]) => {
    const nextRow = row + dr;
    const nextCol = col + dc;
    return count + (
      isInside(nextRow, nextCol, maze.length, maze[0].length) &&
      maze[nextRow][nextCol] === 1 ? 1 : 0
    );
  }, 0);
}
A cell is only carved open if its current open-neighbor count is ≤ 1. This single guard is what prevents the depth-first walk from creating parallel corridors that join back together — which would undermine the maze’s structure and allow unintended shortcuts around walls.

Phase 2: addDeadEnds

After the main path is carved, most of the grid is still 0. addDeadEnds opens additional cells to reach the density target, but it only opens cells that would form a dead-end branch rather than a shortcut:
function addDeadEnds(maze, targetDensity = 0.55) {
  const rows = maze.length;
  const cols = maze[0].length;
  const targetOpenCells = Math.floor(rows * cols * targetDensity);
  let openCells = maze.flat().filter(Boolean).length;
  let guard = 0;

  while (openCells < targetOpenCells && guard < 900) {
    guard += 1;
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (maze[row][col] === 1) continue;

    if (openNeighborCount(maze, row, col) === 1) {
      maze[row][col] = 1;
      openCells += 1;
    }
  }

  return maze;
}
A blocked cell is only opened when it has exactly 1 open neighbor. This means the new cell can only be reached from one direction — a genuine dead end. The loop runs up to 900 iterations to avoid an infinite loop if the density target becomes unreachable.

Validation and Retry Logic

After both phases complete, createRandomMaze runs a BFS validation pass on the result:
for (let attempt = 0; attempt < 70; attempt += 1) {
  const carved = carveUniqueMainPath(rows, cols, start, target);
  if (!carved) continue;
  const maze = addDeadEnds(carved.maze, density);
  maze[start[0]][start[1]] = 1;
  maze[target[0]][target[1]] = 1;
  const solution = findShortestPath(maze, start, target);
  if (solution.path.length >= 12) {
    return { maze, solution: solution.path };
  }
}
  • The start cell [0,0] and target cell [7,7] are explicitly forced open after addDeadEnds, regardless of what the carver did.
  • findShortestPath (BFS) computes the shortest route on the finished board.
  • The maze is only accepted if the shortest path contains at least 12 cells (i.e., at least 11 moves). This prevents trivially short boards where the treasure is just a few steps away.
  • Up to 70 attempts are made before falling back to the ORIGINAL_MAZE constant.

The density Parameter

The density value determines what fraction of the 64 cells should be open when generation finishes:
DensityOpen cells (8×8)Used when
0.56~36 cellsEasy and Medium difficulty (via newMaze() in app.js)
0.55~35 cellscreateRandomMaze() function default (no argument passed)
0.50~32 cellsHard difficulty (via newMaze() in app.js)
Hard mode uses a tighter 0.5 density because fewer open cells mean fewer route options, which rewards players who learn the maze and punishes inefficient exploration. The app.js call that supplies this value:
mazeData = createRandomMaze({ density: difficultySelect.value === "hard" ? 0.5 : 0.56 });

Fallback: ORIGINAL_MAZE

If all 70 attempts fail to produce a maze with a path of length ≥ 12, the generator returns a cloned copy of the static ORIGINAL_MAZE alongside its pre-computed BFS solution:
return { maze: cloneMaze(ORIGINAL_MAZE), solution: findShortestPath(ORIGINAL_MAZE).path };
In practice this fallback is rarely triggered because carveUniqueMainPath produces a valid connected path on the vast majority of attempts.
Every maze returned by createRandomMaze — whether freshly generated or the ORIGINAL_MAZE fallback — satisfies three hard guarantees: the start cell [0,0] is open, the target cell [7,7] is open, and there is at least one valid path between them that is 12 or more moves long. No maze will ever be handed to the game engine in an unsolvable state.

Build docs developers (and LLMs) love