Circular Maze Puzzle — lets-plot

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 lets-plot

Python source (lets-plot)

""" anyplot.ai
maze-circular: Circular Maze Puzzle
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-05-20
"""

import os

import numpy as np
import pandas as pd
from lets_plot import *


LetsPlot.setup_html()

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"

BRAND = "#009E73"  # Okabe-Ito position 1 — START marker
ACCENT = "#C475FD"  # Okabe-Ito position 2 — GOAL marker

# Maze parameters
np.random.seed(42)
num_rings = 7
sectors_per_ring = [1]  # Center has 1 sector, each ring has more sectors
for ring in range(1, num_rings + 1):
    sectors_per_ring.append(max(6, ring * 4))  # Increasing sectors per ring

# Build adjacency graph for cells
# Each cell is (ring, sector) where ring 0 is center
cells = [(0, 0)]  # Center cell
for ring in range(1, num_rings + 1):
    for sector in range(sectors_per_ring[ring]):
        cells.append((ring, sector))

# Find neighbors for each cell
neighbors = {cell: [] for cell in cells}

# Center connects to all sectors in ring 1
for sector in range(sectors_per_ring[1]):
    neighbors[(0, 0)].append((1, sector))
    neighbors[(1, sector)].append((0, 0))

# Connections within same ring (adjacent sectors)
for ring in range(1, num_rings + 1):
    n_sectors = sectors_per_ring[ring]
    for sector in range(n_sectors):
        next_sector = (sector + 1) % n_sectors
        neighbors[(ring, sector)].append((ring, next_sector))
        neighbors[(ring, next_sector)].append((ring, sector))

# Connections between adjacent rings (radial passages)
for ring in range(1, num_rings):
    inner_sectors = sectors_per_ring[ring]
    outer_sectors = sectors_per_ring[ring + 1]
    ratio = outer_sectors / inner_sectors
    for inner_sector in range(inner_sectors):
        outer_start = int(inner_sector * ratio)
        outer_end = int((inner_sector + 1) * ratio)
        for outer_sector in range(outer_start, outer_end):
            neighbors[(ring, inner_sector)].append((ring + 1, outer_sector))
            neighbors[(ring + 1, outer_sector)].append((ring, inner_sector))

# DFS maze generation
visited = set()
passages = set()  # Edges in the spanning tree
stack = [(0, 0)]
visited.add((0, 0))

while stack:
    current = stack[-1]
    unvisited_neighbors = [n for n in neighbors[current] if n not in visited]

    if unvisited_neighbors:
        next_cell = unvisited_neighbors[np.random.randint(len(unvisited_neighbors))]
        visited.add(next_cell)
        passages.add((current, next_cell))
        passages.add((next_cell, current))
        stack.append(next_cell)
    else:
        stack.pop()

# Geometry parameters
ring_width = 1.0
center_radius = 0.5
entrance_sector = 0
n_arc_points = 100  # Increased for smoother arcs

# Collect wall segments — outer ring gets heavier weight for visual depth
wall_segments = []

# Draw arc walls (circular walls between rings)
for ring in range(1, num_rings + 1):
    outer_r = center_radius + ring * ring_width
    n_sectors = sectors_per_ring[ring]
    lw = 3.0 if ring == num_rings else 1.5  # Outer boundary emphasized

    for sector in range(n_sectors):
        # Check if there's a passage to the outer ring
        if ring < num_rings:
            has_passage = False
            outer_sectors_count = sectors_per_ring[ring + 1]
            ratio = outer_sectors_count / n_sectors
            outer_start = int(sector * ratio)
            outer_end = int((sector + 1) * ratio)
            for outer_sector in range(outer_start, outer_end):
                if ((ring, sector), (ring + 1, outer_sector)) in passages:
                    has_passage = True
                    break
            if has_passage:
                continue  # Don't draw outer wall — there's a passage

        # Skip entrance opening on outer wall
        if ring == num_rings and sector == entrance_sector:
            continue

        # Draw arc for outer wall of this sector
        theta_start = 2 * np.pi * sector / n_sectors - np.pi / 2
        theta_end = 2 * np.pi * (sector + 1) / n_sectors - np.pi / 2
        theta_vals = np.linspace(theta_start, theta_end, n_arc_points)
        for i in range(len(theta_vals) - 1):
            x1, y1 = outer_r * np.cos(theta_vals[i]), outer_r * np.sin(theta_vals[i])
            x2, y2 = outer_r * np.cos(theta_vals[i + 1]), outer_r * np.sin(theta_vals[i + 1])
            wall_segments.append({"x": x1, "y": y1, "xend": x2, "yend": y2, "lw": lw})

# Draw radial walls (between sectors in same ring)
for ring in range(1, num_rings + 1):
    n_sectors = sectors_per_ring[ring]
    inner_r = center_radius + (ring - 1) * ring_width if ring > 1 else center_radius
    outer_r = center_radius + ring * ring_width
    lw = 3.0 if ring == num_rings else 1.5  # Outer boundary emphasized

    for sector in range(n_sectors):
        next_sector = (sector + 1) % n_sectors
        if ((ring, sector), (ring, next_sector)) in passages:
            continue  # Don't draw wall — there's a passage

        theta = 2 * np.pi * (sector + 1) / n_sectors - np.pi / 2
        x1, y1 = inner_r * np.cos(theta), inner_r * np.sin(theta)
        x2, y2 = outer_r * np.cos(theta), outer_r * np.sin(theta)
        wall_segments.append({"x": x1, "y": y1, "xend": x2, "yend": y2, "lw": lw})

# Draw inner walls for ring 1 connecting to center
inner_r = center_radius
for sector in range(sectors_per_ring[1]):
    if ((0, 0), (1, sector)) not in passages:
        n_inner_sectors = sectors_per_ring[1]
        theta_start = 2 * np.pi * sector / n_inner_sectors - np.pi / 2
        theta_end = 2 * np.pi * (sector + 1) / n_inner_sectors - np.pi / 2
        theta_vals = np.linspace(theta_start, theta_end, n_arc_points // 2)
        for i in range(len(theta_vals) - 1):
            x1, y1 = inner_r * np.cos(theta_vals[i]), inner_r * np.sin(theta_vals[i])
            x2, y2 = inner_r * np.cos(theta_vals[i + 1]), inner_r * np.sin(theta_vals[i + 1])
            wall_segments.append({"x": x1, "y": y1, "xend": x2, "yend": y2, "lw": 1.5})

df_walls = pd.DataFrame(wall_segments)

# Markers for start and goal
outer_r = center_radius + num_rings * ring_width
entrance_n_sectors = sectors_per_ring[num_rings]
entrance_theta = (
    2 * np.pi * entrance_sector / entrance_n_sectors + 2 * np.pi * (entrance_sector + 1) / entrance_n_sectors
) / 2 - np.pi / 2
start_x = (outer_r + 0.8) * np.cos(entrance_theta)
start_y = (outer_r + 0.8) * np.sin(entrance_theta)

df_markers = pd.DataFrame(
    {
        "x": [start_x, 0],
        "y": [start_y, 0.15],  # GOAL shifted up to clear innermost ring walls
        "label": ["START", "GOAL"],
        "color": [BRAND, ACCENT],
        "info": [f"Entry — outer ring, sector {entrance_sector}", f"Navigate {num_rings} rings, seed 42"],
    }
)

# Plot — wall size aesthetic drives visual hierarchy; tooltips + ggtb() use lets_plot interactivity
plot_radius = outer_r + 1.5
plot = (
    ggplot()
    + geom_segment(aes(x="x", y="y", xend="xend", yend="yend", size="lw"), data=df_walls, color=INK, show_legend=False)
    + scale_size_identity()
    + geom_text(
        aes(x="x", y="y", label="label", color="color"),
        data=df_markers,
        size=8,
        fontface="bold",
        show_legend=False,
        tooltips=layer_tooltips().line("@label").line("@info"),
    )
    + scale_color_identity()
    + coord_fixed(ratio=1, xlim=(-plot_radius, plot_radius), ylim=(-plot_radius, plot_radius))
    + theme_void()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(size=16, color=INK, hjust=0.5),
        plot_margin=[40, 40, 40, 40],
    )
    + labs(title="maze-circular · python · letsplot · anyplot.ai")
    + ggsize(600, 600)
    + ggtb()
)

# Save
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Circular Maze Puzzle on anyplot.ai.

Other implementations