Circular Maze Puzzle — Apache ECharts

A circular maze puzzle visualization featuring concentric rings connected by radial passages. Unlike rectangular mazes, this design creates a unique solving experience where the player navigates inward through ring-shaped corridors. The maze has an entry point on the outer edge and a goal at the center, with algorithmically generated walls ensuring exactly one solvable path.

Circular Maze Puzzle rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// maze-circular: Circular Maze Puzzle
// Library: echarts 6.1.0 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-09-02
//# anyplot-orientation: square
//
// ECharts has no native maze primitive, so the maze is built by hand: a graph
// of one center hub plus ring x sector cells, carved into a spanning tree via
// randomized DFS (recursive backtracker). A spanning tree connects every cell
// through exactly one path, which is what guarantees the maze has exactly one
// solution from the outer entry to the center goal -- no loops, no shortcuts.
// The walls are drawn as polylines (arcs approximated by sampled points) and
// straight radial segments inside a single hidden-axis custom series so the
// geometry maps 1:1 onto the mount's actual pixel scale via api.coord().

const t = window.ANYPLOT_TOKENS;

// --- Maze parameters (one concrete scenario: 7 rings, medium difficulty) ---
const RINGS = 7;
const SECTORS = 12;
const DIFFICULTY = "medium";
const SEED = 42;
const HUB_RADIUS = 0.12; // fraction of the outer radius reserved for the goal hub
const OUTER_RADIUS = 1.0;
const RING_WIDTH = (OUTER_RADIUS - HUB_RADIUS) / RINGS;
const ANGLE_STEP = (2 * Math.PI) / SECTORS;
const ANGLE_OFFSET = Math.PI / 2; // sector 0 starts at 12 o'clock

// --- Deterministic RNG (mulberry32) -----------------------------------------
let seedState = SEED >>> 0;
function rand() {
  seedState = (seedState + 0x6d2b79f5) >>> 0;
  let z = seedState;
  z = Math.imul(z ^ (z >>> 15), z | 1);
  z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
  return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
}

// --- Graph: "C" (center hub) plus one node per (ring, sector) cell ----------
function cellId(ring, sector) {
  return `${ring}_${sector}`;
}
function ringOf(node) {
  return node === "C" ? -1 : Number(node.split("_")[0]);
}
function neighborsOf(node) {
  if (node === "C") {
    const out = [];
    for (let s = 0; s < SECTORS; s++) out.push(cellId(0, s));
    return out;
  }
  const [ringStr, sectorStr] = node.split("_");
  const ring = Number(ringStr);
  const sector = Number(sectorStr);
  const out = [
    cellId(ring, (sector + 1) % SECTORS),
    cellId(ring, (sector - 1 + SECTORS) % SECTORS),
  ];
  out.push(ring === 0 ? "C" : cellId(ring - 1, sector));
  if (ring < RINGS - 1) out.push(cellId(ring + 1, sector));
  return out;
}
function edgeKey(a, b) {
  return a < b ? `${a}|${b}` : `${b}|${a}`;
}

// DIFFICULTY biases *which* spanning tree the carve picks -- it never adds
// loops, so the exactly-one-solution guarantee always holds. "easy" favors
// moves toward the center (short, direct solution path); "hard" favors
// lateral/outward moves (long, winding solution path with more dead ends).
const RING_MOVE_WEIGHT = {
  easy: { inward: 4, lateral: 1, outward: 0.5 },
  medium: { inward: 1, lateral: 1, outward: 1 },
  hard: { inward: 0.4, lateral: 1, outward: 2 },
}[DIFFICULTY];

function pickWeighted(current, options) {
  const currentRing = ringOf(current);
  const weights = options.map((node) => {
    const nodeRing = ringOf(node);
    if (nodeRing < currentRing) return RING_MOVE_WEIGHT.inward;
    if (nodeRing > currentRing) return RING_MOVE_WEIGHT.outward;
    return RING_MOVE_WEIGHT.lateral;
  });
  let roll = rand() * weights.reduce((sum, w) => sum + w, 0);
  for (let i = 0; i < options.length; i++) {
    roll -= weights[i];
    if (roll <= 0) return options[i];
  }
  return options[options.length - 1];
}

// Randomized DFS (recursive backtracker) -> spanning tree = exactly one path
// between any two cells, which is what guarantees a single maze solution.
const visited = new Set(["C"]);
const passages = new Set();
const stack = ["C"];
while (stack.length > 0) {
  const current = stack[stack.length - 1];
  const options = neighborsOf(current).filter((n) => !visited.has(n));
  if (options.length === 0) {
    stack.pop();
    continue;
  }
  const next = pickWeighted(current, options);
  passages.add(edgeKey(current, next));
  visited.add(next);
  stack.push(next);
}
const entrySector = Math.floor(rand() * SECTORS);

// --- Geometry helpers --------------------------------------------------------
function angleOf(sector) {
  return ANGLE_OFFSET + sector * ANGLE_STEP;
}
function ringRadius(ringBoundary) {
  return HUB_RADIUS + ringBoundary * RING_WIDTH;
}
function arcPoints(radius, angleStart, angleEnd, steps) {
  const pts = [];
  for (let k = 0; k <= steps; k++) {
    const angle = angleStart + ((angleEnd - angleStart) * k) / steps;
    pts.push([radius * Math.cos(angle), radius * Math.sin(angle)]);
  }
  return pts;
}

// --- Wall segments (data-space points, mapped to pixels via api.coord) -----
const arcWalls = []; // circumferential walls, one polyline per drawn segment
const radialWalls = []; // radial walls, one [p1, p2] pair per drawn segment

for (let ringBoundary = 0; ringBoundary <= RINGS; ringBoundary++) {
  const radius = ringRadius(ringBoundary);
  for (let sector = 0; sector < SECTORS; sector++) {
    let present;
    if (ringBoundary === 0) {
      present = !passages.has(edgeKey("C", cellId(0, sector)));
    } else if (ringBoundary === RINGS) {
      present = sector !== entrySector; // outer boundary, minus the entry gap
    } else {
      present = !passages.has(
        edgeKey(cellId(ringBoundary - 1, sector), cellId(ringBoundary, sector)),
      );
    }
    if (present) {
      arcWalls.push(arcPoints(radius, angleOf(sector), angleOf(sector + 1), 6));
    }
  }
}
for (let ring = 0; ring < RINGS; ring++) {
  const innerRadius = ringRadius(ring);
  const outerRadius = ringRadius(ring + 1);
  for (let sector = 0; sector < SECTORS; sector++) {
    const prevSector = (sector - 1 + SECTORS) % SECTORS;
    const present = !passages.has(edgeKey(cellId(ring, prevSector), cellId(ring, sector)));
    if (present) {
      const angle = angleOf(sector);
      radialWalls.push([
        [innerRadius * Math.cos(angle), innerRadius * Math.sin(angle)],
        [outerRadius * Math.cos(angle), outerRadius * Math.sin(angle)],
      ]);
    }
  }
}

// --- Entry marker: an inward-pointing arrow at the outer gap ---------------
const entryAngle = angleOf(entrySector) + ANGLE_STEP / 2;
const entryCos = Math.cos(entryAngle);
const entrySin = Math.sin(entryAngle);
const entryArrowOuter = [(OUTER_RADIUS + 0.09) * entryCos, (OUTER_RADIUS + 0.09) * entrySin];
const entryArrowInner = [(OUTER_RADIUS + 0.015) * entryCos, (OUTER_RADIUS + 0.015) * entrySin];
const entryLabelAt = [(OUTER_RADIUS + 0.14) * entryCos, (OUTER_RADIUS + 0.14) * entrySin];

// --- Init & render ------------------------------------------------------------
const chart = echarts.init(document.getElementById("container"));

const AX = { type: "value", min: -1.3, max: 1.3, show: false };

const option = {
  animation: false,
  backgroundColor: "transparent",
  title: {
    text: "maze-circular · javascript · echarts · anyplot.ai",
    subtext: `${RINGS} rings · ${SECTORS} sectors · ${DIFFICULTY} difficulty · seed ${SEED}`,
    left: "center",
    top: 26,
    textStyle: { color: t.ink, fontSize: 22, fontWeight: "bold" },
    subtextStyle: { color: t.inkSoft, fontSize: 15 },
  },
  // top+bottom sums to the same 300px as left+right so the plotting box
  // stays square (no elliptical distortion); the split is uneven to shift
  // the box down slightly and balance the whitespace above vs below it.
  grid: { left: 150, right: 150, top: 170, bottom: 130 },
  xAxis: AX,
  yAxis: AX,
  series: [
    {
      type: "custom",
      coordinateSystem: "cartesian2d",
      silent: true,
      data: [0],
      renderItem: (params, api) => {
        const children = [];

        for (const pts of arcWalls) {
          children.push({
            type: "polyline",
            shape: { points: pts.map((p) => api.coord(p)) },
            style: { stroke: t.ink, lineWidth: 5, fill: "none", lineCap: "round" },
          });
        }
        for (const [p1, p2] of radialWalls) {
          const a = api.coord(p1);
          const b = api.coord(p2);
          children.push({
            type: "line",
            shape: { x1: a[0], y1: a[1], x2: b[0], y2: b[1] },
            style: { stroke: t.ink, lineWidth: 5, lineCap: "round" },
          });
        }

        // Goal hub at the center
        const centerPx = api.coord([0, 0]);
        const hubRadiusPx = api.coord([HUB_RADIUS, 0])[0] - centerPx[0];
        children.push({
          type: "circle",
          shape: { cx: centerPx[0], cy: centerPx[1], r: hubRadiusPx },
          style: { fill: t.palette[0], stroke: t.pageBg, lineWidth: 3 },
        });
        children.push({
          type: "text",
          style: {
            text: "GOAL",
            x: centerPx[0],
            y: centerPx[1],
            fill: t.pageBg,
            fontSize: 15,
            fontWeight: "bold",
            align: "center",
            verticalAlign: "middle",
          },
        });

        // Entry arrow (points inward through the gap) + label
        const outerPx = api.coord(entryArrowOuter);
        const innerPx = api.coord(entryArrowInner);
        const dx = innerPx[0] - outerPx[0];
        const dy = innerPx[1] - outerPx[1];
        const len = Math.hypot(dx, dy) || 1;
        const perpX = (-dy / len) * 10;
        const perpY = (dx / len) * 10;
        children.push({
          type: "polygon",
          shape: {
            points: [
              [innerPx[0], innerPx[1]],
              [outerPx[0] + perpX, outerPx[1] + perpY],
              [outerPx[0] - perpX, outerPx[1] - perpY],
            ],
          },
          style: { fill: t.palette[0] },
        });
        const labelPx = api.coord(entryLabelAt);
        children.push({
          type: "text",
          style: {
            text: "START",
            x: labelPx[0],
            y: labelPx[1],
            fill: t.palette[0],
            fontSize: 15,
            fontWeight: "bold",
            align: "center",
            verticalAlign: "middle",
          },
        });

        return { type: "group", silent: true, children };
      },
    },
  ],
};

chart.setOption(option);

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/maze-circular/echarts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "maze-circular",
  "language": "javascript",
  "library": "echarts",
  "page": "https://anyplot.ai/maze-circular/javascript/echarts",
  "hub": "https://anyplot.ai/maze-circular",
  "code_json": "https://api.anyplot.ai/specs/maze-circular/echarts/code",
  "spec_json": "https://api.anyplot.ai/specs/maze-circular",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/maze-circular/javascript/echarts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/maze-circular/javascript/echarts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/maze-circular/javascript/echarts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/maze-circular/javascript/echarts/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Circular Maze Puzzle on anyplot.ai.

Other implementations