Djair Guilherme
Published © GPL3+

MatrixPortal S3 Maze Game with CircuitPython

A procedural maze game, using the native accelerometer of the Adafruit MatrixPortal S3.

BeginnerFull instructions provided1 hour19
MatrixPortal S3 Maze Game with CircuitPython

Things used in this project

Hardware components

Adafruit Matrix Portal S3 CircuitPython Powered Internet Display
×1
Adafruit Hub75 64x64 RGB LED Matrix Panel
×1

Software apps and online services

Adafruit Circuitpython

Story

Read more

Code

Matrix Portal S3 - Maze Game with Circuitpython

Python
Procedural Maze Game using 64x64 RGB Led Matrix and Accelerometer.
import board
import time
import random
import gc
import rgbmatrix
import framebufferio
import displayio
import terminalio
import adafruit_lis3dh
from adafruit_display_shapes.rect import Rect
from adafruit_display_text import label
import adafruit_rtttl
from simpleio import map_range

# Libera displays anteriores
displayio.release_displays()

# Configuração da matriz HUB75 64x64
matrix = rgbmatrix.RGBMatrix(
    width=64, height=64, bit_depth=4,
    rgb_pins=[board.MTX_R1, board.MTX_G1, board.MTX_B1,
              board.MTX_R2, board.MTX_G2, board.MTX_B2],
    addr_pins=[board.MTX_ADDRA, board.MTX_ADDRB, board.MTX_ADDRC,
               board.MTX_ADDRD, board.MTX_ADDRE],
    clock_pin=board.MTX_CLK, latch_pin=board.MTX_LAT, output_enable_pin=board.MTX_OE,
    doublebuffer=True
)

# Cria o display
display = framebufferio.FramebufferDisplay(matrix, auto_refresh=True)

# Configuração do acelerômetro
i2c = board.I2C()
lis3dh = adafruit_lis3dh.LIS3DH_I2C(i2c, address=0x19)
lis3dh.range = adafruit_lis3dh.RANGE_2_G

# Configuração do buzzer
buzzer = board.A3

# Cores
BLACK = 0x000000
WHITE = 0xFFFFFF
RED = 0xFF0000
GREEN = 0x00FF00
CYAN = 0x00FFFF
YELLOW = 0xFFFF00

# Small Cell_Size, bigger and complex maze.
CELL_SIZE = 2  # 1, complex maze, 4 is a very easy maze
MAZE_CELLS = 64 // CELL_SIZE  # Iqual Pixel Size of Screen

# Fisher-Yates Shuffle
def shuffle_list(lst):
    for i in range(len(lst)-1, 0, -1):
        j = random.randint(0, i)
        lst[i], lst[j] = lst[j], lst[i]
    return lst

class Maze:
    def __init__(self):
        self.cell_width = MAZE_CELLS
        self.cell_height = MAZE_CELLS
        self.maze = []
        # Upper Left Size
        self.entry_pos = (1, 1)
        # Down Right Size, at end of path
        self.exit_pos = (MAZE_CELLS - 2, MAZE_CELLS - 2)
        self.generate()
        
    def get_entry(self):
        return self.entry_pos
    
    def get_exit(self):
        return self.exit_pos

    def generate(self):
        # Init Maze (1 = wall, 0 = open path, 2 = maze exit)
        self.maze = [[1 for _ in range(self.cell_width)] for _ in range(self.cell_height)]
        
        # Create maze limits (1 cell thick)
        for i in range(self.cell_width):
            self.maze[0][i] = 1  # Upper Border
            self.maze[self.cell_height-1][i] = 1  # Down Border
        
        for i in range(self.cell_height):
            self.maze[i][0] = 1  # Left Border
            self.maze[i][self.cell_width-1] = 1  # Right Border
        
        # Create entry point
        entry_x, entry_y = self.entry_pos
        self.maze[entry_y][entry_x] = 0
        
        # Create exit, without connection
        exit_x, exit_y = self.exit_pos
        self.maze[exit_y][exit_x] = 2
        
        # Recursive Backtracking - Creating Maze
        stack = [(entry_x, entry_y)]
        self.maze[entry_y][entry_x] = 0
        
        # Possible Directions, in Cells
        directions = [(0, 2), (2, 0), (0, -2), (-2, 0)]
        
        while stack:
            current_x, current_y = stack[-1]
            shuffled_directions = shuffle_list(directions.copy())
            found = False
            
            for dx, dy in shuffled_directions:
                nx, ny = current_x + dx, current_y + dy
                if (1 <= nx < self.cell_width-1 and 1 <= ny < self.cell_height-1 and 
                    self.maze[ny][nx] == 1):
                    # Remove wall between cells
                    self.maze[current_y + dy//2][current_x + dx//2] = 0
                    self.maze[ny][nx] = 0
                    stack.append((nx, ny))
                    found = True
                    break
            
            if not found:
                stack.pop()
        
        # Connects exit to path
        self.connect_exit_to_maze()

    def connect_exit_to_maze(self):
        exit_x, exit_y = self.exit_pos
        
        # Try to connect exit using any of near cells
        connection_points = [
            (exit_x - 1, exit_y),    # Left
            (exit_x, exit_y - 1),    # Up
            (exit_x - 1, exit_y - 1) # Diagonal (if other fails)
        ]
        
        for cx, cy in connection_points:
            if (1 <= cx < self.cell_width-1 and 1 <= cy < self.cell_height-1):
                self.maze[cy][cx] = 0  # Create Path
                break

    def is_wall(self, cell_x, cell_y):
        """Verifica se a célula na posição dada é uma parede"""
        if 0 <= cell_x < self.cell_width and 0 <= cell_y < self.cell_height:
            return self.maze[cell_y][cell_x] == 1
        return True  # Fora dos limites é considerado parede

class Game:
    def __init__(self):
        self.level = 1
        self.maze = Maze()
        self.player_pos = self.maze.get_entry()  
        self.main_group = displayio.Group()
        self.player = None
        self.setup_display()
        
        # Histórico para suavizar o movimento
        self.move_history = []
        self.history_size = 3
        
    def show_next_level_message(self):
        # Limpa a tela
        while len(self.main_group) > 0:
            self.main_group.pop()
        
        text_area = label.Label(terminalio.FONT, text="NEXT LEVEL", color=YELLOW)
        text_width = len("NEXT LEVEL") * 6  
        text_area.x = (64 - text_width) // 2
        text_area.y = 32
        self.main_group.append(text_area)
        
        display.root_group = self.main_group
        display.refresh()
        
        # Show 2s
        time.sleep(2.0)
        
    def setup_display(self):
        # Clean Main Group
        while len(self.main_group) > 0:
            self.main_group.pop()
        
        # Creates a bitmap for Maze (64x64 pixels)
        bitmap = displayio.Bitmap(64, 64, 4)
        palette = displayio.Palette(4)
        palette[0] = BLACK  # Path
        palette[1] = CYAN   # Wall
        palette[2] = GREEN  # Exit
        palette[3] = RED    # Player (Entry point)

        # Fills the bitmap based on the maze of cells
        for cell_y in range(self.maze.cell_height):
            for cell_x in range(self.maze.cell_width):
                cell_value = self.maze.maze[cell_y][cell_x]
                color_index = 0  # Default: Empty Path
                
                if cell_value == 1:  # Wall
                    color_index = 1
                elif cell_value == 2:  # Exit
                    color_index = 2
                # Input is drawn as normal path (black)
                
                # Fills the CELL_SIZExCELL_SIZE block of pixels
                pixel_x = cell_x * CELL_SIZE
                pixel_y = cell_y * CELL_SIZE
                
                for dy in range(CELL_SIZE):
                    for dx in range(CELL_SIZE):
                        if (0 <= pixel_x + dx < 64 and 0 <= pixel_y + dy < 64):
                            bitmap[pixel_x + dx, pixel_y + dy] = color_index

        # Creates a TileGrid with the bitmap
        tilegrid = displayio.TileGrid(bitmap, pixel_shader=palette)
        self.main_group.append(tilegrid)
        
        # Creates the player with size proportional to CELL_SIZE
        player_pixel_x = self.player_pos[0] * CELL_SIZE
        player_pixel_y = self.player_pos[1] * CELL_SIZE
        
        self.player = Rect(
            x=player_pixel_x,
            y=player_pixel_y,
            width=CELL_SIZE,
            height=CELL_SIZE,
            fill=RED
        )
        self.main_group.append(self.player)
        
        display.root_group = self.main_group
    
    def get_tilt_direction(self):
        # Reads accelerometer values ​​and converts them to G
        x, y, z = (value / adafruit_lis3dh.STANDARD_GRAVITY for value in lis3dh.acceleration)
        
        # Calculates the magnitude of the tilt on each axis
        x_mag = abs(x)
        y_mag = abs(y)
        
        # Determine which axis has the greatest inclination
        if x_mag > y_mag:
            # X Axis (Left/Right)
            if x > 0.1:
                dx = 1  # Right
            elif x < -0.01:
                dx = -1  # Left
            else:
                dx = 0
            dy = 0  # Block Y
        else:
            # Y Axis (UP/Down)
            if y > 0.2:
                dy = 1  # Down
            elif y < -0.2:
                dy = -1  # UP
            else:
                dy = 0
            dx = 0  # Block X
        
        return dx, dy

    def move_player(self):
        dx, dy = self.get_tilt_direction()
        
        if dx != 0 or dy != 0:
            new_cell_x = self.player_pos[0] + dx
            new_cell_y = self.player_pos[1] + dy
            
            # Valid Move? (Not Wall)
            if not self.maze.is_wall(new_cell_x, new_cell_y):
                self.player_pos = (new_cell_x, new_cell_y)
                
                # Update player position
                player_pixel_x = self.player_pos[0] * CELL_SIZE
                player_pixel_y = self.player_pos[1] * CELL_SIZE
                self.player.x = player_pixel_x
                self.player.y = player_pixel_y
                
                # Check Exit
                if self.player_pos == self.maze.get_exit():
                    self.next_level()
    
    def next_level(self):
        # Play Buzzer Sound (if connected)
        try:
            adafruit_rtttl.play(buzzer, "success:d=8,o=5,b=330:8e6,8g6")
        except:
            pass
        
        # Show Next level Message
        self.show_next_level_message()
        
        # New Level and new maze
        self.level += 1
        self.maze = Maze()
        self.player_pos = self.maze.get_entry()
        self.setup_display()
        
        time.sleep(0.5)
    
    def run(self):
        last_update = time.monotonic()
        update_interval = 0.2  # Update at 200ms
        
        while True:
            current_time = time.monotonic()
            
            if current_time - last_update >= update_interval:
                self.move_player()
                last_update = current_time
            
            time.sleep(0.05)

# Start Game as Class
game = Game()
game.run()

Credits

Djair Guilherme
24 projects • 20 followers
Brazilian artist. Puzzle maker, Arduino lover.
Thanks to Orestis Zekai.

Comments