Circular Maze Puzzle — Altair

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 Altair

Python source (Altair)

""" anyplot.ai
maze-circular: Circular Maze Puzzle
Library: altair 6.1.0 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-20
"""

import os
import sys


# Remove this file's directory from sys.path to avoid circular import with the altair package
if sys.path and os.path.exists(os.path.join(sys.path[0] or ".", "altair.py")):
    sys.path = sys.path[1:]

import altair as alt
import numpy as np
import pandas as pd


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
WALL_COLOR = "#1A1A17" if THEME == "light" else "#E0DFD8"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
ENTRY_COLOR = "#4467A3"  # Okabe-Ito position 3, theme-independent (same in light and dark)
GOAL_COLOR = "#AE3030"  # Okabe-Ito position 5, theme-independent

# Difficulty: "easy" / "medium" / "hard" — scales sector density (more sectors = harder maze)
DIFFICULTY = os.getenv("ANYPLOT_DIFFICULTY", "medium")
density_map = {"easy": 0.70, "medium": 1.0, "hard": 1.40}
density = density_map.get(DIFFICULTY, 1.0)

# Maze parameters
np.random.seed(42)
num_rings = 7
sectors_per_ring = [1] + [max(4, round((12 + i * 4) * density)) for i in range(num_rings)]
ring_width = 1.0

# Data structures for maze walls
walls = []
for ring in range(num_rings):
    ring_walls = []
    for _sector in range(sectors_per_ring[ring + 1]):
        ring_walls.append({"outer": True, "cw": True})
    walls.append(ring_walls)

# Union-Find for Kruskal's maze generation (inlined, no helper functions)
parent = {}
for ring in range(num_rings):
    for sector in range(sectors_per_ring[ring + 1]):
        parent[(ring, sector)] = (ring, sector)

possible_walls = []
for ring in range(num_rings):
    num_sectors = sectors_per_ring[ring + 1]
    for sector in range(num_sectors):
        next_sector = (sector + 1) % num_sectors
        possible_walls.append(("cw", ring, sector, ring, next_sector))
        if ring < num_rings - 1:
            outer_sectors = sectors_per_ring[ring + 2]
            ratio = outer_sectors / num_sectors
            outer_sector = int(sector * ratio)
            possible_walls.append(("outer", ring, sector, ring + 1, outer_sector))

np.random.shuffle(possible_walls)

for wall_type, r1, s1, r2, s2 in possible_walls:
    cell1, cell2 = (r1, s1), (r2, s2)
    root1 = cell1
    while parent[root1] != root1:
        root1 = parent[root1]
    c = cell1
    while parent[c] != root1:
        parent[c], c = root1, parent[c]

    root2 = cell2
    while parent[root2] != root2:
        root2 = parent[root2]
    c = cell2
    while parent[c] != root2:
        parent[c], c = root2, parent[c]

    if root1 != root2:
        parent[root1] = root2
        if wall_type == "cw":
            walls[r1][s1]["cw"] = False
        else:
            walls[r1][s1]["outer"] = False

# Entry fixed at top (θ ≈ π/2) so START label is always clearly readable
entry_sector = sectors_per_ring[num_rings] // 4

# Generate wall segment coordinates
outer_boundary_data = []  # Outer boundary — rendered thicker for visual emphasis
wall_data = []  # Interior walls
wall_count = 0

theta_vals = np.linspace(0, 2 * np.pi, sectors_per_ring[num_rings] + 1)
entry_theta_start = theta_vals[entry_sector]
entry_theta_end = theta_vals[entry_sector + 1]
outer_r = num_rings * ring_width

# Outer boundary arcs with gap at entry — tracked separately for thicker strokeWidth
if entry_sector > 0:
    theta = np.linspace(0, entry_theta_start, 60)
    x_arr, y_arr = outer_r * np.cos(theta), outer_r * np.sin(theta)
    for i in range(len(x_arr)):
        outer_boundary_data.append({"x": x_arr[i], "y": y_arr[i], "wall_id": f"ob_{wall_count}", "order": i})
    wall_count += 1

if entry_sector < sectors_per_ring[num_rings] - 1:
    theta = np.linspace(entry_theta_end, 2 * np.pi, 60)
    x_arr, y_arr = outer_r * np.cos(theta), outer_r * np.sin(theta)
    for i in range(len(x_arr)):
        outer_boundary_data.append({"x": x_arr[i], "y": y_arr[i], "wall_id": f"ob_{wall_count}", "order": i})
    wall_count += 1

# Concentric ring walls (arcs with gaps at passages)
for ring in range(num_rings - 1):
    r = (ring + 1) * ring_width
    num_sectors = sectors_per_ring[ring + 1]
    ring_theta = np.linspace(0, 2 * np.pi, num_sectors + 1)
    arc_start = None

    for sector in range(num_sectors):
        if walls[ring][sector]["outer"]:
            if arc_start is None:
                arc_start = ring_theta[sector]
        else:
            if arc_start is not None:
                theta = np.linspace(arc_start, ring_theta[sector], 30)
                x_arr, y_arr = r * np.cos(theta), r * np.sin(theta)
                for i in range(len(x_arr)):
                    wall_data.append({"x": x_arr[i], "y": y_arr[i], "wall_id": f"r_{ring}_{wall_count}", "order": i})
                wall_count += 1
                arc_start = None

    if arc_start is not None:
        theta = np.linspace(arc_start, 2 * np.pi, 30)
        x_arr, y_arr = r * np.cos(theta), r * np.sin(theta)
        for i in range(len(x_arr)):
            wall_data.append({"x": x_arr[i], "y": y_arr[i], "wall_id": f"r_{ring}_{wall_count}", "order": i})
        wall_count += 1

# Radial walls (sector dividers with gaps at passages)
for ring in range(num_rings):
    num_sectors = sectors_per_ring[ring + 1]
    r_inner = ring * ring_width if ring > 0 else 0.3
    r_outer = (ring + 1) * ring_width
    radial_theta = np.linspace(0, 2 * np.pi, num_sectors + 1)

    for sector in range(num_sectors):
        if walls[ring][sector]["cw"]:
            theta_val = radial_theta[sector + 1] if sector < num_sectors - 1 else 2 * np.pi
            x1, y1 = r_inner * np.cos(theta_val), r_inner * np.sin(theta_val)
            x2, y2 = r_outer * np.cos(theta_val), r_outer * np.sin(theta_val)
            wid = f"rad_{ring}_{sector}_{wall_count}"
            wall_data.append({"x": x1, "y": y1, "wall_id": wid, "order": 0})
            wall_data.append({"x": x2, "y": y2, "wall_id": wid, "order": 1})
            wall_count += 1

# DataFrames
df_outer = pd.DataFrame(outer_boundary_data)
df_walls = pd.DataFrame(wall_data)

# Goal at center: filled halo + star symbol
goal_halo_df = pd.DataFrame({"x": [0.0], "y": [0.0]})
goal_star_df = pd.DataFrame({"x": [0.0], "y": [0.0], "label": ["★"]})

# Entry label and inward-pointing arrow indicator
entry_angle = (entry_theta_start + entry_theta_end) / 2
entry_label_r = outer_r + 0.90
entry_arrow_r = outer_r + 0.30

entry_df = pd.DataFrame(
    {"x": [entry_label_r * np.cos(entry_angle)], "y": [entry_label_r * np.sin(entry_angle)], "label": ["START"]}
)
arrow_df = pd.DataFrame(
    {"x": [entry_arrow_r * np.cos(entry_angle)], "y": [entry_arrow_r * np.sin(entry_angle)], "label": ["▼"]}
)

max_extent = outer_r + 1.6

# Shared scale domains to guarantee equal-aspect circle
x_scale = alt.Scale(domain=[-max_extent, max_extent])
y_scale = alt.Scale(domain=[-max_extent, max_extent])

# Outer boundary wall lines — thicker for visual boundary emphasis
outer_chart = (
    alt.Chart(df_outer)
    .mark_line(color=WALL_COLOR, strokeWidth=4.0)
    .encode(
        x=alt.X("x:Q", axis=None, scale=x_scale),
        y=alt.Y("y:Q", axis=None, scale=y_scale),
        detail="wall_id:N",
        order="order:O",
    )
)

# Interior wall lines
walls_chart = (
    alt.Chart(df_walls)
    .mark_line(color=WALL_COLOR, strokeWidth=2.5)
    .encode(
        x=alt.X("x:Q", axis=None, scale=x_scale),
        y=alt.Y("y:Q", axis=None, scale=y_scale),
        detail="wall_id:N",
        order="order:O",
    )
)

# Goal halo — subtle filled circle marking the center zone
goal_halo_chart = (
    alt.Chart(goal_halo_df)
    .mark_point(color=GOAL_COLOR, size=350, filled=True, opacity=0.30)
    .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale))
)

# Goal star at center — Unicode ★ for reliable rendering
goal_chart = (
    alt.Chart(goal_star_df)
    .mark_text(fontSize=24, fontWeight="bold", color=GOAL_COLOR)
    .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale), text="label:N")
)

# Inward-pointing arrow at entry gap (▼ points toward center when entry is at top)
arrow_chart = (
    alt.Chart(arrow_df)
    .mark_text(fontSize=14, color=ENTRY_COLOR)
    .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale), text="label:N")
)

# Entry label above the maze
entry_chart = (
    alt.Chart(entry_df)
    .mark_text(fontSize=18, fontWeight="bold", color=ENTRY_COLOR)
    .encode(x=alt.X("x:Q", axis=None, scale=x_scale), y=alt.Y("y:Q", axis=None, scale=y_scale), text="label:N")
)

chart = (
    alt.layer(outer_chart, walls_chart, goal_halo_chart, goal_chart, arrow_chart, entry_chart)
    .properties(width=600, height=600, background=PAGE_BG, title="maze-circular · python · altair · anyplot.ai")
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure_axis(grid=False)
    .configure_title(fontSize=16, color=INK)
)

chart.save(f"plot-{THEME}.png", scale_factor=4.0)
chart.save(f"plot-{THEME}.html")

Part of Circular Maze Puzzle on anyplot.ai.

Other implementations