Circular Maze Puzzle — Plotly

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 Plotly

Python source (Plotly)

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

import os
import sys
from collections import deque


# Remove current directory from path to avoid importing local plotly.py
sys.path = [p for p in sys.path if p not in ("", ".", os.path.dirname(__file__))]

import numpy as np
import plotly.graph_objects as go


# Theme
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
ACCENT = "#009E73"  # Okabe-Ito position 1

# Maze parameters — outer rings have more sectors than inner rings
np.random.seed(42)
NUM_RINGS = 7
SECTORS = [42, 36, 30, 24, 18, 12, 6]

# Wall arrays: radial_walls[r][s] = wall clockwise from sector s in ring r
#              ring_walls[r][s]   = wall between ring r and ring r+1 at sector s
radial_walls = [[True] * SECTORS[r] for r in range(NUM_RINGS)]
ring_walls = [[True] * SECTORS[r] for r in range(NUM_RINGS - 1)]

# Depth-first search maze generation — guarantees exactly one solution
visited = {(0, 0)}
dfs_stack = [(0, 0)]

while dfs_stack:
    cr, cs = dfs_stack[-1]
    n = SECTORS[cr]

    neighbors = [(cr, (cs + 1) % n, "cw"), (cr, (cs - 1) % n, "ccw")]
    if cr < NUM_RINGS - 1:
        neighbors.append((cr + 1, int(cs * SECTORS[cr + 1] / n), "in"))
    if cr > 0:
        neighbors.append((cr - 1, int(cs * SECTORS[cr - 1] / n), "out"))

    unvisited = [(r, s, d) for r, s, d in neighbors if (r, s) not in visited]

    if unvisited:
        nr, ns, d = unvisited[np.random.randint(len(unvisited))]
        if d == "cw":
            radial_walls[cr][cs] = False
        elif d == "ccw":
            radial_walls[cr][ns] = False
        elif d == "in":
            ring_walls[cr][cs] = False
        else:
            ring_walls[nr][ns] = False
        visited.add((nr, ns))
        dfs_stack.append((nr, ns))
    else:
        dfs_stack.pop()

# Build undirected passage graph for BFS
graph = {}

for r in range(NUM_RINGS):
    for s in range(SECTORS[r]):
        if not radial_walls[r][s]:
            a, b = (r, s), (r, (s + 1) % SECTORS[r])
            graph.setdefault(a, []).append(b)
            graph.setdefault(b, []).append(a)

for r in range(NUM_RINGS - 1):
    for s in range(SECTORS[r]):
        if not ring_walls[r][s]:
            inner_s = int(s * SECTORS[r + 1] / SECTORS[r])
            a, b = (r, s), (r + 1, inner_s)
            graph.setdefault(a, []).append(b)
            graph.setdefault(b, []).append(a)

# BFS to find the unique solution path to the innermost ring
bfs_queue = deque([((0, 0), [(0, 0)])])
bfs_seen = {(0, 0)}
solution = []

while bfs_queue:
    cell, path = bfs_queue.popleft()
    if cell[0] == NUM_RINGS - 1:
        solution = path
        break
    for nb in graph.get(cell, []):
        if nb not in bfs_seen:
            bfs_seen.add(nb)
            bfs_queue.append((nb, path + [nb]))

sol_x, sol_y = [], []
for r, s in solution:
    r_mid = NUM_RINGS - r - 0.5
    angle = (s + 0.5) * 2 * np.pi / SECTORS[r]
    sol_x.append(r_mid * np.cos(angle))
    sol_y.append(r_mid * np.sin(angle))
sol_x.append(0.0)
sol_y.append(0.0)

# Drawing constants
OUTER_R = NUM_RINGS
WALL_W = 3

fig = go.Figure()

# Ring arc walls — inner boundary arcs where wall exists
for r in range(NUM_RINGS - 1):
    n = SECTORS[r]
    inner_r = NUM_RINGS - r - 1
    sa = 2 * np.pi / n
    for s in range(n):
        if ring_walls[r][s]:
            theta = np.linspace(s * sa, (s + 1) * sa, 30)
            fig.add_trace(
                go.Scatter(
                    x=inner_r * np.cos(theta),
                    y=inner_r * np.sin(theta),
                    mode="lines",
                    line={"color": INK, "width": WALL_W},
                    showlegend=False,
                    hoverinfo="skip",
                )
            )

# Outer boundary circle with entry gap at sector 0
gap_start = 0.0
gap_end = 2 * np.pi / SECTORS[0]
theta_outer = np.linspace(gap_end, gap_start + 2 * np.pi, 300)
fig.add_trace(
    go.Scatter(
        x=OUTER_R * np.cos(theta_outer),
        y=OUTER_R * np.sin(theta_outer),
        mode="lines",
        line={"color": INK, "width": WALL_W + 1},
        showlegend=False,
        hoverinfo="skip",
    )
)

# Radial walls — spokes between ring boundaries
for r in range(NUM_RINGS):
    r_out = NUM_RINGS - r
    r_in = NUM_RINGS - r - 1
    n = SECTORS[r]
    for s in range(n):
        if radial_walls[r][s]:
            theta = (s + 1) * 2 * np.pi / n
            fig.add_trace(
                go.Scatter(
                    x=[r_in * np.cos(theta), r_out * np.cos(theta)],
                    y=[r_in * np.sin(theta), r_out * np.sin(theta)],
                    mode="lines",
                    line={"color": INK, "width": WALL_W},
                    showlegend=False,
                    hoverinfo="skip",
                )
            )

# Solution path — hidden by default; click legend entry to reveal
fig.add_trace(
    go.Scatter(
        x=sol_x,
        y=sol_y,
        mode="lines",
        name="Show Solution",
        line={"color": "#C475FD", "width": 4, "dash": "dot"},
        opacity=0.85,
        visible="legendonly",
    )
)

# Center goal circle
goal_r = 0.4
theta_g = np.linspace(0, 2 * np.pi, 60)
fig.add_trace(
    go.Scatter(
        x=goal_r * np.cos(theta_g),
        y=goal_r * np.sin(theta_g),
        fill="toself",
        fillcolor=ACCENT,
        line={"color": ACCENT, "width": 2},
        showlegend=False,
        hoverinfo="skip",
    )
)
fig.add_trace(
    go.Scatter(
        x=[0.0],
        y=[0.0],
        mode="markers",
        marker={"symbol": "star", "size": 20, "color": "#DDCC77", "line": {"color": ACCENT, "width": 2}},
        showlegend=False,
        hoverinfo="skip",
    )
)

# Entry arrow and labels
entry_angle = 0.5 * 2 * np.pi / SECTORS[0]
fig.add_annotation(
    x=OUTER_R * np.cos(entry_angle),
    y=OUTER_R * np.sin(entry_angle),
    ax=(OUTER_R + 1.5) * np.cos(entry_angle),
    ay=(OUTER_R + 1.5) * np.sin(entry_angle),
    xref="x",
    yref="y",
    axref="x",
    ayref="y",
    showarrow=True,
    arrowhead=2,
    arrowsize=2,
    arrowwidth=3,
    arrowcolor=ACCENT,
)
fig.add_annotation(
    x=(OUTER_R + 2.8) * np.cos(entry_angle),
    y=(OUTER_R + 2.8) * np.sin(entry_angle),
    text="START",
    showarrow=False,
    xref="x",
    yref="y",
    font={"size": 22, "color": ACCENT, "family": "Arial Black"},
)
fig.add_annotation(
    x=0.0,
    y=-(OUTER_R + 1.5),
    text="GOAL",
    showarrow=False,
    xref="x",
    yref="y",
    font={"size": 22, "color": ACCENT, "family": "Arial Black"},
)

# Layout
fig.update_layout(
    title={
        "text": "maze-circular · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "xanchor": "center",
    },
    xaxis={
        "showgrid": False,
        "zeroline": False,
        "showticklabels": False,
        "scaleanchor": "y",
        "scaleratio": 1,
        "range": [-(OUTER_R + 4.0), OUTER_R + 4.0],
    },
    yaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-(OUTER_R + 4.0), OUTER_R + 4.0]},
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    showlegend=True,
    legend={
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "font": {"color": INK_SOFT, "size": 10},
        "x": 0.02,
        "y": 0.98,
        "xanchor": "left",
        "yanchor": "top",
    },
    margin={"l": 50, "r": 50, "t": 100, "b": 50},
)

# Save
fig.write_image(f"plot-{THEME}.png", width=600, height=600, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Circular Maze Puzzle on anyplot.ai.

Other implementations