Maze Runner Matrix
.jpg)
In this project, we build a dynamic maze game on an 8×32 LED matrix, fully controlled by capacitive touch sensors!
You move a pulsing yellow avatar through a randomly generated maze, avoiding blue obstacles and aiming for the moving green comet border to win! 🌟
The game regenerates with a new maze every time you win or lose, and the border animates around the maze for a clean "living" effect.
This project looks super polished, runs smooth on a Raspberry Pi Pico W, and is coded entirely in CircuitPython.
Supplies

Raspberry Pi Pico W (microcontroller)
8×32 NeoPixel LED Matrix
MPR121 Capacitive Touch Sensor (Adafruit)
Male-to-male jumper wires
Micro USB cable (for Pico W)
Breadboard
External 5V Power Supply (recommended for NeoPixels if >100 LEDs)
Set Up Instructions

- Install CircuitPython on your Pico W.
- (Follow: https://circuitpython.org/board/raspberry_pi_pico_w/)
- Install CircuitPython libraries:
- neopixel
- adafruit_mpr121
- audiomp3
- audiopwmio
- (You can install these by dragging the .mpy files into the lib/ folder on your CIRCUITPY drive.)
- Create a folder sounds/ and add:
- start.mp3 (a short "move" sound)
- loss.mp3 (a short "fail" sound)
- Copy the provided code.py into the root of your Pico W's CIRCUITPY drive.
How the Gameplay Works
- A random maze is generated each round.
- Walls and obstacles are drawn in blue.
- The border constantly animates with a slow comet effect in green.
- A pulsing yellow dot represents your player.
- You move by touching the capacitive pads.
- Touching a blue wall or obstacle = lose ❌
- Reaching the moving green border = win 🏁
If you lose or win, a new maze is instantly generated.
code.py:
import board
import busio
import neopixel
import time
import random
import adafruit_mpr121
from audiomp3 import MP3Decoder
from audiopwmio import PWMAudioOut as AudioOut
# ————— Configuration —————
WIDTH, HEIGHT = 8, 32
NUM_LEDS = WIDTH * HEIGHT
pixels = neopixel.NeoPixel(board.GP0, NUM_LEDS, auto_write=False)
i2c = busio.I2C(board.GP3, board.GP2)
touch = adafruit_mpr121.MPR121(i2c)
speaker = AudioOut(board.GP15)
# Boost touch sensitivity
for ch in range(12):
touch[ch].threshold = 6
touch[ch].release_threshold = 3
# ————— Helpers —————
def xy_to_index(x, y):
if y & 1:
return y * WIDTH + (WIDTH - 1 - x)
return y * WIDTH + x
def play_mp3(path):
try:
with open(path, "rb") as f:
mp3 = MP3Decoder(f)
speaker.play(mp3)
while speaker.playing:
pass
except OSError as e:
print(f"❌ Could not play {path}: {e}")
def play_move_sound():
play_mp3("sounds/start.mp3")
def play_loss_sound():
play_mp3("sounds/loss.mp3")
# ————— Movement Map —————
electrode_to_dir = {
0:(1,0),1:(1,0),10:(1,0),11:(1,0),
2:(0,-1),3:(0,-1),
4:(-1,0),5:(-1,0),6:(-1,0),7:(-1,0),
8:(0,1),9:(0,1),
}
# ————— Maze Generation —————
def generate_maze():
visited = {(1,1)}
stack = [(1,1)]
while stack:
x,y = stack[-1]
nbrs = [(dx,dy) for dx,dy in ((1,0),(-1,0),(0,1),(0,-1))
if 1<=x+dx<WIDTH-1 and 1<=y+dy<HEIGHT-1 and (x+dx,y+dy) not in visited]
if nbrs:
dx,dy = random.choice(nbrs)
visited.add((x+dx,y+dy))
stack.append((x+dx,y+dy))
else:
stack.pop()
corridors = visited
walls = {(x,y) for x in range(WIDTH) for y in range(HEIGHT)} - corridors
# sprinkle ~30% obstacles inside corridors
interior = [(x,y) for x,y in corridors if 1<=x<WIDTH-1 and 1<=y<HEIGHT-1]
count = max(1, len(interior)*3//10)
shuffled = interior[:]
for i in range(len(shuffled)-1, 0, -1):
j = random.randint(0, i)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
obstacles = set(shuffled[:count])
# Choose a safe starting spot
safe_spots = [pos for pos in corridors if pos not in obstacles]
start = random.choice(safe_spots)
print(f"🔄 Maze ready: corridors={len(corridors)}, obstacles={len(obstacles)}, start={start}")
return corridors, walls, obstacles, start
# ————— Draw Maze Base —————
def draw_maze_base(corridors, walls, obstacles):
for x in range(WIDTH):
for y in range(HEIGHT):
idx = xy_to_index(x,y)
if (x,y) in walls or (x,y) in obstacles:
pixels[idx] = (0,0,50) # blue walls/obstacles
else:
pixels[idx] = (10,10,10) # gray floor
# ————— Border Path Setup —————
def generate_border_path():
path = []
for x in range(WIDTH): path.append((x,0))
for y in range(1,HEIGHT): path.append((WIDTH-1,y))
for x in range(WIDTH-2,-1,-1): path.append((x,HEIGHT-1))
for y in range(HEIGHT-2,0,-1): path.append((0,y))
return path
BORDER_PATH = generate_border_path()
border_pos = 0
# ————— Pulse —————
YELLOW_PULSE = [80,100,120,140,120,100]
pulse_idx = 0
# ————— Main Game Loop —————
while True:
corridors, walls, obstacles, start = generate_maze()
pos = list(start)
prev = [False]*12
playing = True
print(f"😊 Starting at {pos}")
while playing:
# touch debounce
state = prev.copy()
try:
for i in range(12):
state[i] = touch[i].value
except OSError:
pass
# pulse animation
pulse_idx = (pulse_idx + 1) % len(YELLOW_PULSE)
yellow_bright = YELLOW_PULSE[pulse_idx]
# border comet movement
border_pos = (border_pos + 1) % len(BORDER_PATH)
# draw static maze first
draw_maze_base(corridors, walls, obstacles)
# draw moving comet border
for i,(x,y) in enumerate(BORDER_PATH):
idx = xy_to_index(x,y)
if i == border_pos:
pixels[idx] = (0,100,0)
else:
pixels[idx] = (0,30,0)
# draw yellow avatar
pixels[xy_to_index(*pos)] = (yellow_bright, yellow_bright, 0)
pixels.show()
moved = False
for i,pressed in enumerate(state):
if moved: break
if pressed and not prev[i]:
dx,dy = electrode_to_dir[i]
new = [pos[0]+dx, pos[1]+dy]
print(f"➡ Electrode {i} → {(dx,dy)}, target {new}")
x,y = new
if 0<=x<WIDTH and 0<=y<HEIGHT and (x in (0,WIDTH-1) or y in (0,HEIGHT-1)):
print("🏁 WIN at border", new)
play_move_sound()
for _ in range(3):
for col in [(50,0,0),(0,50,0),(0,0,50)]:
pixels[xy_to_index(*new)] = col
pixels.show()
time.sleep(0.1)
playing = False
elif (x,y) in walls or (x,y) in obstacles:
print("💥 LOSS hitting blue", new)
for _ in range(4):
pixels.fill((50,0,0)); pixels.show(); time.sleep(0.2)
draw_maze_base(corridors, walls, obstacles); time.sleep(0.2)
play_loss_sound()
playing = False
elif 0<=x<WIDTH and 0<=y<HEIGHT:
pos[:] = new
print("🏃 Moved to", pos)
play_move_sound()
# ————— INSTANT DRAW AFTER MOVING —————
draw_maze_base(corridors, walls, obstacles)
for j,(bx,by) in enumerate(BORDER_PATH):
idx = xy_to_index(bx,by)
if j == border_pos:
pixels[idx] = (0,100,0)
else:
pixels[idx] = (0,30,0)
pixels[xy_to_index(*pos)] = (yellow_bright, yellow_bright, 0)
pixels.show()
# ————————————————————————————————
moved = True
else:
print("🚧 Out of bounds", new)
prev = state
time.sleep(0.07)
continue
Gameplay Instructions
Touch electrodes on the MPR121 to move:
- Right (pads 0,1,10,11)
- Up (pads 2,3)
- Left (pads 4,5,6,7)
- Down (pads 8,9)
Goal:
Reach any green border cell by moving carefully around obstacles!
Avoid:
Touching blue walls or obstacles.
Victory:
You'll see a rainbow win effect, and the maze resets for a fresh challenge!