Skip to content

Repository files navigation

mazeology logo

mazeology

A free browser maze game with an AI auto-solver — draw your way out.

Live Made by

Next.js React TypeScript Tailwind Lucide GitHub Pages

→ Play now


✨ What it is

Every time you refresh, a new random maze is generated. Draw your solution from the filled dot to the empty ring — with your cursor, finger, or stylus. Or press A and let the AI trace the path in a hand-drawn amber line.

Built as a minimalist single-screen art piece with Geist fonts, corridor-aware dead-end detection, and a mobile magnifier loupe.


🎯 Features

  • 🎲  Random maze on every refresh — recursive-backtracker, perfect mazes with exactly one solution
  • ✏️  Freehand drawing — mouse, finger, or pen (unified pointer events)
  • 🎯  Coverage-based solve detection — ≥90 % of your line inside the solution corridor counts; slight wobbles don't disqualify you
  • 🔴  Dead-end detection — stroke turns red when your pen tip has no route to the goal
  • ⚪  Off-track feedback — greys out when your path drifts too far
  • 🤖  AI auto-solve — press A for a hand-drawn solution with correlated jitter, corner overshoot, and variable-speed animation
  • 🔍  Mobile magnifier loupe — zoomed circular view above your finger, crosshair tracks the pen tip at maze edges
  • ⏸️  Pause & resume — lift your finger to think; touch back down within a cell to continue the same stroke
  • 🖼️  PNG export — save solved mazes at 2× DPI on a clean white background
  • 📱  Responsive — 44 px touch targets, horizontal-scroll control strip on narrow screens, safe-area insets
  • 🔠  Geist Sans + Geist Mono — minimalist typography throughout
  • 📈  SEO ready — full metadata, JSON-LD (WebSite + WebApplication + Person), sitemap, robots.txt, OG + Twitter card images
  • ♿  Accessible — semantic HTML, ARIA labels, keyboard shortcuts, respects prefers-reduced-motion
  • ⚡  Static export — deploys as pure HTML/CSS/JS, no server needed

⌨️ Keyboard Shortcuts

Key Action
N New maze
U Undo last stroke
⌘Z / Ctrl+Z Undo
C Clear all strokes
A AI auto-solve
E Export as PNG (after solve)

🧩 Difficulty

Cycle through by tapping the Difficulty button:

Level Grid Cell size on a 640 px board
Easy 15 × 15 42.6 px
Medium 25 × 25 25.6 px
Hard 40 × 40 16 px

🛠️ Tech Stack

Purpose
Next.js 16 App Router, static export
React 19 UI
TypeScript Types
Tailwind CSS 4 Utility styling
Geist Sans + Mono Typography
Lucide React Icons
GitHub Actions CI/CD to GitHub Pages

🚀 Development

Requires Node 20+ (Node 24 recommended).

# Clone
git clone https://github.com/Eeman1113/mazeology.git
cd mazeology

# Install
npm install

# Dev server → http://localhost:3000
npm run dev

# Static build → out/
npm run build

🌍 Deployment

Auto-deploys to GitHub Pages on every push to main via .github/workflows/deploy.yml:

  1. Checkout & Node 24 setup
  2. npm ci + npm run build (static export)
  3. Adds .nojekyll to skip Jekyll processing
  4. Uploads out/ as a Pages artifact
  5. Deploys via actions/deploy-pages@v4

Live at eeman1113.github.io/mazeology.


🧠 How it works

🎲  Maze generation

A recursive-backtracker DFS carves a perfect maze — exactly one path between any two cells — in an N × N grid. Start is at (0, 0), end at (N−1, N−1).

🎯  Solve detection

Problem. Decide when to fire solved on a freehand drawn line — but be forgiving about small overshoots outside the corridor.

Idea. Pre-compute the unique solution cell set via BFS. As the user draws, sub-sample each segment and count how much of its length falls inside solution cells. When the pen enters the end cell AND the running ratio ≥ 0.9, it's a solve.

Data structures

Set<number> Cell indices on the solution path (BFS output)
stroke.insideLen : number Running total of arc length that fell inside solution cells
stroke.totalLen : number Running total of arc length drawn (in-bounds)
stroke.startedInStart : boolean Was the pen-down inside the start cell?

Pre-processing — solve the maze once.

solveMazePath(maze):
  prev[all cells] = -1
  prev[startIdx]  = startIdx
  queue = [startIdx]

  while queue not empty:
    cur = dequeue
    if cur == endIdx: break
    for each corridor-open neighbor n of cur:
      if prev[n] == -1:
        prev[n] = cur
        enqueue n

  # trace back from end to start
  path = []
  cur = endIdx
  while cur != prev[cur]:
    path.push(cur); cur = prev[cur]
  path.push(startIdx)
  return path.reverse()

solveMaze(maze):
  return Set(solveMazePath(maze))

Complexity: O(V + E) where V ≤ 1600 cells, E ≤ 3200 corridor openings on Hard.

Per-segment measurement (runs on every pointer batch).

measureSegment(A, B):
  len  = distance(A, B)
  step = max(2, cellSize / 4)
  n    = ceil(len / step)         // sub-samples
  inCount = 0
  for i in 0..n-1:
    t = (i + 0.5) / n             // midpoint of each sub-segment
    x = A.x + (B.x - A.x) * t
    y = A.y + (B.y - A.y) * t
    cellIdx = floor(y/cellSize)*cols + floor(x/cellSize)
    if solutionSet.has(cellIdx): inCount++
  return {
    inside: (inCount / n) * len,   // arc length inside corridor
    total:  len
  }

# on each new pointer batch:
insideLen += result.inside
totalLen  += result.total
ratio      = insideLen / totalLen

Per-batch measurement pipeline.

Solve detection per-batch measurement pipeline

Running counters — walkthrough of a 5-segment stroke. Ratio is only checked when a segment intersects the end cell.

seg Δlen inside % insideLen totalLen ratio status
1 48 100% 48.0 48 1.000 ✓ on-track
2 52 90% 94.8 100 0.948 ✓ on-track
3 30 50% 109.8 130 0.845 ⚠ off-track (grey)
4 41 100% 150.8 171 0.882 ⚠ still off-track
5 22 100% 172.8 193 0.895 segment intersects end AABB — not solved (ratio < 0.9)

Threshold check at solve moment

condition required met?
startedInStart true
segment intersects endAABB true
insideLen / totalLen ≥ 0.9 true ✗ 0.895

→ Keep drawing. The user needs to backtrack onto the corridor to lift the ratio above 0.9 before re-entering the end cell.

Running ratio over drawn arc length with 0.9 threshold band

Once solved fires, the stroke locks — additional pointer moves on that stroke are dropped (can't "un-solve" by drawing into walls after).

Complexity per pointer batch: O(k) sub-samples where k ≈ raw events (1–20 per frame). Set.has is O(1). Well under one frame at 60 Hz on Hard.


🔴  Dead-end detection

Problem. Detect when the user's pen tip has painted itself into a corner — no reachable path to the goal that doesn't retrace already-drawn cells.

Idea. Track cells the current stroke has legally traversed. When a new cell is committed, BFS from that cell through the corridor graph — treating the trail as blocked. If the goal isn't reachable, flip deadEnd = true (monotonic per stroke).

Data structures per stroke

visitedCells : Set<number> Committed cells (corridor-legal path so far)
lastCommittedCell : number Where we are, -1 before first commit
candidateCell : number Cell being considered for commit, -1 if none
candidateSamples : number Residency counter
wasOOB : boolean Last raw sample landed outside the maze
deadEnd : boolean Sticky flag once true

State machine — how a raw pointer sample updates commitment.

Dead-end commit state machine

Why each gate matters

  • Residency (≥ 2 samples) — a single corner-grazing sample can't contaminate the trail.
  • Corridor-legal transition (areConnected) — if the pen physically crosses a wall to hop to a non-4-neighbor or a wall-blocked neighbor, the candidate is rejected. Prevents the "poisoned trail" where a wall-crossed pocket looks unreachable to BFS.
  • First-commit exception — when lastCommittedCell == -1, no anchor exists yet, so the first legal cell commits unconditionally.
  • Global gate before BFS — dead-end can only fire once visitedCells.size ≥ 3 and totalLen ≥ 2 × cellSize, so early wobbles at pen-down can't trip it.

Reachability BFS (goalReachable).

goalReachable(maze, tipIdx, visited, goalIdx):
  if tipIdx == goalIdx: return true

  seen = Uint8Array(cellCount)
  seen[tipIdx] = 1
  queue = [tipIdx]

  while queue not empty:
    cur = dequeue
    cell = maze.cells[cur]
    for each corridor-open neighbor n of cell:
      if seen[n]:                            continue
      if n != goalIdx AND visited.has(n):    continue   # trail blocks re-entry
      seen[n] = 1
      queue.push(n)
      if n == goalIdx: return true

  return false

Complexity: O(V + E) per commit — but throttled by the commit gate, so it runs on the order of the stroke's cell count, not per raw sample.

The reachability check — two scenarios side by side.

Two dead-end BFS scenarios drawn on real maze grids

Complexity summary.

Op Cost Frequency
Sample-in-cell math O(1) every raw sample
Candidate residency O(1) every raw sample
areConnected gate O(1) every candidate promotion
goalReachable BFS O(V + E) only on new commits after the global gate

Per-stroke on Hard: ~50 commits × ~1600 cells ≈ 80 k neighbor visits over the whole stroke. Trivial.


🤖  AI auto-solve

Problem. Trace the true solution in ~3 s with a stroke that feels hand-drawn — not a ruler.

Idea. Take the unique solution cell sequence. Place a waypoint at each cell center, add correlated jitter so the line drifts organically. At sharp corners, push the Bézier control past the corner (overshoot) and bias the outgoing midpoint inside the turn (inside-cut). Then animate the SVG reveal on a variable-speed clock — pen slows at corners, races down straights.


Stage 1 — Solution path.

solveMazePath(maze) returns [c₀, c₁, …, cₙ] from start to end (BFS + predecessor trace).


Stage 2 — Correlated jitter (per-run seeded).

Every autoSolve() picks a fresh 32-bit seed → mulberry32 PRNG. Downstream everything (phases, amplitudes, walk noise, ±10 % duration) derives from it. Same maze, same button, different-looking stroke each time.

For each waypoint Pᵢ (cell center), displacement is a sum of three correlated components:

dxᵢ = driftAmp · sin(i · fA + φA)         low-freq drift
    + tremorAmp · sin(i · fX + φC)        high-freq tremor
    + walkX                                smoothed random walk

walkX ← walkX · 0.82 + (rng()−0.5) · 0.04 · cellSize

The walk is a leaky integrator (coefficient 0.82) — successive samples inherit ~82 % of the previous walk, giving correlated motion instead of independent noise. Same construction for dyᵢ with different frequencies/phases so it doesn't loop into a diagonal.

Hard cap: |(dxᵢ, dyᵢ)| ≤ 0.22 × cellSize. Safety rail — the line never crosses a wall. First and last waypoints get zero jitter — starts and ends stay decisive.

Independent random vs correlated jitter — both at the same amplitude. The independent series jumps around each waypoint. The correlated series inherits ~82 % of the previous walk each step, drifting smoothly like a hand.

Independent random vs correlated jitter comparison


Stage 3 — Corner geometry: overshoot + inside-cut.

At interior waypoint Pᵢ, compute unit vectors of incoming edge v̂ᵢₙ and outgoing edge v̂ₒᵤₜ. If turn angle θ = acos(v̂ᵢₙ · v̂ₒᵤₜ) > 60°:

overshoot = (0.18 + rng() * 0.12) · cellSize
ctrl      = Pᵢ + v̂ᵢₙ · overshoot                    # push control past corner
mid       = midpoint(Pᵢ, Pᵢ₊₁)
          + normalize(v̂ᵢₙ − v̂ₒᵤₜ) · 0.10 · cellSize  # bias endpoint inward

The path segment through Pᵢ becomes a quadratic Bézier Q(ctrl, mid).

Corner geometry — naive vs human turn (with actual Bézier curves and vectors, not just words).

Naive corner geometry vs human corner with overshoot and inside-cut

  • Naive (ctrl = Pᵢ, mid = geometric midpoint): the curve arrives, halts, and turns exactly 90°. Reads as robotic.
  • Human (control point pushed along v̂ᵢₙ, endpoint biased along the inner bisector): the curve overshoots the corner slightly, then cuts back inside — the signature you see when a real hand traces a turn.

Stage 4 — Variable-speed animation.

The SVG path has stroke-dasharray = totalLength, initial stroke-dashoffset = totalLength (invisible). A requestAnimationFrame loop drives dashoffset toward 0. But not linearly — speed varies with local curvature.

Setup (once, on animation start):

1. Sample the built path at N = 220 arc-length points via getPointAtLength.
2. For each sample i, compute local turn angle from tangents:
     inV  = samples[i]   − samples[i-1]
     outV = samples[i+1] − samples[i]
     angle[i] = acos(normalize(inV) · normalize(outV))
3. Map angle → speed multiplier:
     norm     = clamp01(angle[i] / (π/2))
     ss       = smoothstep(norm)          # 3x² − 2x³
     speed[i] = lerp(1.0, 0.35, ss)       # 1.0 straight → 0.35 at 90°
     speed[i] += 0.06 · sin(i · 0.15)     # subtle breathing
4. Build cumulative time:
     dt[i]   = 1 / speed[i]
     cumT[i] = Σ dt[j] for j < i          # normalized to [0, 1]

Per-frame draw:

t       = min(1, (now − start) / durationMs)
easedT  = easeInOutQuint(t)               # outer envelope
lo, hi  = binarySearch(cumT, easedT)      # find bracket
frac    = (easedT − cumT[lo]) / (cumT[hi] − cumT[lo])
arcFrac = (lo + frac) / (N − 1)
drawn   = arcFrac · totalLength
strokeDashoffset = totalLength − drawn

Speed profile along a typical solution path — pen visibly slows at corners, races down straights.

Pen speed multiplier along the solution arc, dipping at corners

Cumulative-time map — the y-axis is how much of the animation duration must elapse to have drawn this much arc length. Steep segments (corners) advance slowly per unit of time; shallow segments (straights) advance quickly. The rAF loop binary-searches cumT for the eased t, which gives the target arc length for this frame.

Cumulative time cumT vs arc-length index — steep at corners, shallow at straights


Complexity summary.

Stage Cost
BFS solve path O(V + E) once per maze
Waypoint jitter O(N) cells
Path build (Bézier chain) O(N)
getPointAtLength × 220 samples O(N_samples)
Speed map + cumulative O(N_samples)
Per-frame update O(log N_samples) binary search

For a Medium maze (~50 cells solution), all setup is well under 1 ms; per-frame cost is ~5 µs. Silky at 60 Hz.

🔍  Mobile loupe

On touch pointerdown, a small circular magnifier appears above the finger showing a 2.75× zoomed view around the pen tip via a second SVG with a computed sub-viewBox. The viewBox clamps to maze bounds; the crosshair inside the loupe tracks the actual pen tip's position within the clamped viewBox — so it stays glued to the head of your drawn line at the maze edges.

⏸️  Pause & resume

If your last active stroke isn't solved or dead-ended, re-touching within ~1 cell of where you lifted extends that stroke instead of starting fresh. All state (visited cells, coverage, startedInStart, dead-end status) persists.


📁 Project Structure

mazeology/
├── app/
│   ├── layout.tsx              # Metadata, JSON-LD, fonts
│   ├── page.tsx                # Header, hidden SEO block, footer
│   ├── globals.css             # Design tokens + animations
│   ├── icon.svg                # Favicon (also the header mark)
│   ├── manifest.ts             # PWA manifest
│   ├── robots.ts               # /robots.txt
│   ├── sitemap.ts              # /sitemap.xml
│   ├── opengraph-image.tsx     # 1200×630 OG image
│   ├── twitter-image.tsx       # 1200×600 Twitter card
│   ├── components/
│   │   ├── MazeBoard.tsx       # Main interactive maze board
│   │   └── MazeMark.tsx        # Header wordmark glyph
│   └── lib/
│       └── maze.ts             # Generator, BFS solver, cell utils
├── public/
│   └── humans.txt              # Credits
└── .github/workflows/
    └── deploy.yml              # Auto-deploy on push to main

👤 Author

Made by Eeman Majumder.

Repo: github.com/Eeman1113/mazeology


Every refresh is a new maze.

Releases

Packages

Contributors

Languages