Maker.io main logo

Tile-Matching Game on the Adafruit Metro RP2350

2025-08-22 | By Adafruit Industries

License: See Original Project Displays HDMI / Video Cables LCD / TFT Arduino

Courtesy of Adafruit

Guide by M. LeBlanc-Williams

Overview

project_1

This project demonstrates how to run a tile-matching game on the Metro RP2350 using the mouse attached through its USB host pins. It includes controls such as an event button to perform a reset. This is a take on tile-matching games such as Candy Crush Saga and Bejeweled using some of your favorite Adafruit characters such as Blinka, Hans, and Cappy. It demonstrates some animation techniques that take advantage of CircuitPython's displayio graphics system.

gaming_ExamplePlaying-ezgifcom-optimize

The game is displayed on any HDMI compatible display using the HSTX ribbon connector on the Metro RP2350 and a DVI breakout.

Parts

Preparing the Metro RP2350

The USB Host port is the only part of this project that required soldering and only if you use standard header pins.

The USB Host pin connections are highlighted on the Metro image to the left. You will need a small piece of 0.1 inch male header, with 4 pins, to fit the holes.

You can cut header with diagonal cutters or break them with pliers or even your fingers. Just be sure to wear eye protection as they can fly when cut.

metro_2

metro_3

Put the short end of the header into the holes in the Metro marked USB Host.

If you are using solderless header then they are press fit into the holes. You will need some pressure to get them in if they are the Press-Fit version, pliers will be required. While they are designed to make electrical contact, you might want to solder them to be sure.

If using standard header, secure them with putty, blutack, tape, etc.

Turn the Metro over and you should see the header barely poking out of the bottom of the board. If the pins stick through a great deal, you may have the header pins upside down, double check the short end is sticking into the board.

Solder the 4 pin "nubbins" to the board.

put_4

put_5

put_6

Turn the board over and remove the material securing the pins. Now there is a new 4-pin header.

Get the USB Host cable and wire as follows:

GRD to Black

D+ to Green

D- to White

5V to Red

turn_7

turn_8

HSTX Connection to DVI

connection_9

Get the HSTX cable. Any length Adafruit sells is fine. CAREFULLY lift the dark grey bar up on the Metro, insert the cable silver side down, blue side up, then put the bar CAREFULLY down, ensuring it locks. If it feels like it doesn't want to go, do not force it.

Do the same with the other end and the DVI breakout. Note that the DVI breakout will be inverted/upside down when compared to the Metro - this is normal for these boards and the Adafruit cables.

Install CircuitPython

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython QuickStart

Follow this step-by-step to quickly get CircuitPython running on your board.

Download the latest version of CircuitPython for this board via circuitpython.org

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

click_10

board_11

To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!

If the drive does not appear, release all the buttons, and then repeat the process above.

You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

You will see a new disk drive appear called RP2350.

Drag the adafruit_circuitpython_etc.uf2 file to RP2350.

drag_12

drag_13

The RP2350 drive will disappear, and a new disk drive called CIRCUITPY will appear.

That's it, you're done! :)

download_14

Safe Mode

You want to edit your code.py or modify the files on your CIRCUITPY drive but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.

Safe mode in CircuitPython does not run any user code on startup and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.

Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.

Entering Safe Mode

To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)

In Safe Mode

If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.

If you connect to the serial console, you'll find the following message.

Copy Code
Auto-reload is off.
Running in safe mode! Not running saved code.

CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode.

Press any key to enter the REPL. Use CTRL-D to reload.

You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.

Flash Resetting UF2

If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.

Download flash erasing "nuke" UF2

Software Setup

CircuitPython Usage

To use the game, you need to add the game program files to the CIRCUITPY drive. The game consists of a handful of Python files and bitmaps.

Thankfully, installing everything be done in one go. In the code block below, click the Download Project Bundle button to download the necessary libraries and game files in a zip file.

Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.

Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the entire contents of the appropriate CircuitPython folder to your CIRCUITPY drive. The program should self-start.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
An implementation of a match3 jewel swap game. The idea is to move one character at a time
to line up at least 3 characters.
"""
import time
from displayio import Group, OnDiskBitmap, TileGrid, Bitmap, Palette
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text.text_box import TextBox
from eventbutton import EventButton
import supervisor
import terminalio
from adafruit_usb_host_mouse import find_and_init_boot_mouse
from gamelogic import GameLogic, SELECTOR_SPRITE, EMPTY_SPRITE, GAMEBOARD_POSITION

GAMEBOARD_SIZE = (8, 7)
HINT_TIMEOUT = 10  # seconds before hint is shown
GAME_PIECES = 7  # Number of different game pieces (set between 3 and 8)

# pylint: disable=ungrouped-imports
if hasattr(supervisor.runtime, "display") and supervisor.runtime.display is not None:
    # use the built-in HSTX display for Metro RP2350
    display = supervisor.runtime.display
else:
    # pylint: disable=ungrouped-imports
    from displayio import release_displays
    import picodvi
    import board
    import framebufferio

    # initialize display
    release_displays()

    fb = picodvi.Framebuffer(
        320,
        240,
        clk_dp=board.CKP,
        clk_dn=board.CKN,
        red_dp=board.D0P,
        red_dn=board.D0N,
        green_dp=board.D1P,
        green_dn=board.D1N,
        blue_dp=board.D2P,
        blue_dn=board.D2N,
        color_depth=16,
    )
    display = framebufferio.FramebufferDisplay(fb)

def get_color_index(color, shader=None):
    for index, palette_color in enumerate(shader):
        if palette_color == color:
            return index
    return None

# Load the spritesheet
sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp")
sprite_sheet.pixel_shader.make_transparent(
    get_color_index(0x00ff00, sprite_sheet.pixel_shader)
)

# Main group will hold all the visual layers
main_group = Group()
display.root_group = main_group

# Add Background to the Main Group
background = Bitmap(display.width, display.height, 1)
bg_color = Palette(1)
bg_color[0] = 0x333333
main_group.append(TileGrid(
    background,
    pixel_shader=bg_color
))

# Add Game grid, which holds the game board, to the main group
game_grid = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=GAMEBOARD_SIZE[0],
    height=GAMEBOARD_SIZE[1],
    tile_width=32,
    tile_height=32,
    x=GAMEBOARD_POSITION[0],
    y=GAMEBOARD_POSITION[1],
    default_tile=EMPTY_SPRITE,
)
main_group.append(game_grid)

# Add a special selection groupd to highlight the selected piece and allow animation
selected_piece_group = Group()
selected_piece = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=EMPTY_SPRITE,
)
selected_piece_group.append(selected_piece)
selector = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=SELECTOR_SPRITE,
)
selected_piece_group.append(selector)
selected_piece_group.hidden = True
main_group.append(selected_piece_group)

# Add a group for the swap piece to help with animation
swap_piece = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=EMPTY_SPRITE,
)
swap_piece.hidden = True
main_group.append(swap_piece)

# Add foreground
foreground_bmp = OnDiskBitmap("/bitmaps/foreground.bmp")
foreground_bmp.pixel_shader.make_transparent(0)
foreground_tg = TileGrid(foreground_bmp, pixel_shader=foreground_bmp.pixel_shader)
foreground_tg.x = 0
foreground_tg.y = 0
main_group.append(foreground_tg)

# Add a group for the UI Elements
ui_group = Group()
main_group.append(ui_group)

# Create the mouse graphics and add to the main group
time.sleep(1)  # Allow time for USB host to initialize
mouse = find_and_init_boot_mouse("/bitmaps/mouse_cursor.bmp")
if mouse is None:
    raise RuntimeError("No mouse found connected to USB Host")
main_group.append(mouse.tilegrid)

# Create the game logic object
# pylint: disable=no-value-for-parameter, too-many-function-args
game_logic = GameLogic(
    display,
    mouse,
    game_grid,
    swap_piece,
    selected_piece_group,
    GAME_PIECES,
    HINT_TIMEOUT
)

def update_ui():
    # Update the UI elements with the current game state
    score_label.text = f"Score:\n{game_logic.score}"

waiting_for_release = False
game_over_shown = False

# Create the UI Elements
# Label for the Score
score_label = Label(
    terminalio.FONT,
    color=0xffff00,
    x=5,
    y=10,
)
ui_group.append(score_label)

message_dialog = Group()
message_dialog.hidden = True

def reset():
    global game_over_shown  # pylint: disable=global-statement
    # Reset the game logic
    game_logic.reset()
    message_dialog.hidden = True
    game_over_shown = False

def hide_group(group):
    group.hidden = True

reset()

reset_button = EventButton(
    reset,
    label="Reset",
    width=40,
    height=16,
    x=5,
    y=50,
    style=EventButton.RECT,
)
ui_group.append(reset_button)

message_label = TextBox(
    terminalio.FONT,
    text="",
    color=0x333333,
    background_color=0xEEEEEE,
    width=display.width // 3,
    height=90,
    align=TextBox.ALIGN_CENTER,
    padding_top=5,
)
message_label.anchor_point = (0, 0)
message_label.anchored_position = (
    display.width // 2 - message_label.width // 2,
    display.height // 2 - message_label.height // 2,
)
message_dialog.append(message_label)
message_button = EventButton(
    (hide_group, message_dialog),
    label="OK",
    width=40,
    height=16,
    x=display.width // 2 - 20,
    y=display.height // 2 - message_label.height // 2 + 60,
    style=EventButton.RECT,
)
message_dialog.append(message_button)
ui_group.append(message_dialog)

# main loop
while True:
    update_ui()
    # update mouse
    game_logic.update_mouse()

    if not message_dialog.hidden:
        if message_button.handle_mouse(
            (mouse.x, mouse.y),
            game_logic.pressed_btns and "left" in game_logic.pressed_btns,
            waiting_for_release
        ):
            game_logic.waiting_for_release = True
        continue

    if reset_button.handle_mouse(
        (mouse.x, mouse.y),
        game_logic.pressed_btns is not None and "left" in game_logic.pressed_btns,
        game_logic.waiting_for_release
    ):
        game_logic.waiting_for_release = True

    # process gameboard click if no menu
    game_logic.update()
    game_over = game_logic.check_for_game_over()
    if game_over and not game_over_shown:
        message_label.text = ("No more moves available. your final score is:\n"
                              + str(game_logic.score))
        message_dialog.hidden = False
        game_over_shown = True

View on GitHub

Display Size

Some versions of CircuitPython default to a higher resolution. To ensure the display gets automatically configured for the 320x240 resolution, which the Matching game is designed for, add a CIRCUITPY_DISPLAY_WIDTH variable with value 320 to the settings.toml file in the root directory of the CIRCUITPY drive. If you do not already have a settings.toml file, follow the instructions on this guide page to create one.

Download File

Copy Code
# This file is where you keep private settings, passwords, and tokens!
# If you put them in the code you risk committing that info or sharing it

CIRCUITPY_DISPLAY_WIDTH=320

Drive Structure

After copying the files, your drive should look like the listing below. It can contain other files as well but must contain these at a minimum.

drive_15

Code Overview

The code for the tile-matching game is divided into 3 files: The main code (which glues everything together), the game logic, and a custom UI element that was reused from the Minesweeper on Metro RP2350 guide. In many games, I try and separate the game logic from the user interface, which makes porting to other platforms much easier.

Main Code

The main code.py file handles setting everything up, responding to the user inputs, and updating the User Interface. Everything within the UI is handled by CircuitPython's displayio. There are some custom controls such as dialogs made using the TextBox component of the adafruit_display_text library as well as an event button, which will be looked at in more detail below. This file is fairly self-explanatory with comments throughout the file.

The setup mainly consists of setting up the game board, setting up the dialogs, and setting up the score label and reset button. It also creates some tilegrids that are used to animate the piece swaps.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
An implementation of a match3 jewel swap game. The idea is to move one character at a time
to line up at least 3 characters.
"""
import time
from displayio import Group, OnDiskBitmap, TileGrid, Bitmap, Palette
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text.text_box import TextBox
from eventbutton import EventButton
import supervisor
import terminalio
from adafruit_usb_host_mouse import find_and_init_boot_mouse
from gamelogic import GameLogic, SELECTOR_SPRITE, EMPTY_SPRITE, GAMEBOARD_POSITION

GAMEBOARD_SIZE = (8, 7)
HINT_TIMEOUT = 10  # seconds before hint is shown
GAME_PIECES = 7  # Number of different game pieces (set between 3 and 8)

# pylint: disable=ungrouped-imports
if hasattr(supervisor.runtime, "display") and supervisor.runtime.display is not None:
    # use the built-in HSTX display for Metro RP2350
    display = supervisor.runtime.display
else:
    # pylint: disable=ungrouped-imports
    from displayio import release_displays
    import picodvi
    import board
    import framebufferio

    # initialize display
    release_displays()

    fb = picodvi.Framebuffer(
        320,
        240,
        clk_dp=board.CKP,
        clk_dn=board.CKN,
        red_dp=board.D0P,
        red_dn=board.D0N,
        green_dp=board.D1P,
        green_dn=board.D1N,
        blue_dp=board.D2P,
        blue_dn=board.D2N,
        color_depth=16,
    )
    display = framebufferio.FramebufferDisplay(fb)

def get_color_index(color, shader=None):
    for index, palette_color in enumerate(shader):
        if palette_color == color:
            return index
    return None

# Load the spritesheet
sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp")
sprite_sheet.pixel_shader.make_transparent(
    get_color_index(0x00ff00, sprite_sheet.pixel_shader)
)

# Main group will hold all the visual layers
main_group = Group()
display.root_group = main_group

# Add Background to the Main Group
background = Bitmap(display.width, display.height, 1)
bg_color = Palette(1)
bg_color[0] = 0x333333
main_group.append(TileGrid(
    background,
    pixel_shader=bg_color
))

# Add Game grid, which holds the game board, to the main group
game_grid = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=GAMEBOARD_SIZE[0],
    height=GAMEBOARD_SIZE[1],
    tile_width=32,
    tile_height=32,
    x=GAMEBOARD_POSITION[0],
    y=GAMEBOARD_POSITION[1],
    default_tile=EMPTY_SPRITE,
)
main_group.append(game_grid)

# Add a special selection groupd to highlight the selected piece and allow animation
selected_piece_group = Group()
selected_piece = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=EMPTY_SPRITE,
)
selected_piece_group.append(selected_piece)
selector = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=SELECTOR_SPRITE,
)
selected_piece_group.append(selector)
selected_piece_group.hidden = True
main_group.append(selected_piece_group)

# Add a group for the swap piece to help with animation
swap_piece = TileGrid(
    sprite_sheet,
    pixel_shader=sprite_sheet.pixel_shader,
    width=1,
    height=1,
    tile_width=32,
    tile_height=32,
    x=0,
    y=0,
    default_tile=EMPTY_SPRITE,
)
swap_piece.hidden = True
main_group.append(swap_piece)

# Add foreground
foreground_bmp = OnDiskBitmap("/bitmaps/foreground.bmp")
foreground_bmp.pixel_shader.make_transparent(0)
foreground_tg = TileGrid(foreground_bmp, pixel_shader=foreground_bmp.pixel_shader)
foreground_tg.x = 0
foreground_tg.y = 0
main_group.append(foreground_tg)

# Add a group for the UI Elements
ui_group = Group()
main_group.append(ui_group)

# Create the mouse graphics and add to the main group
time.sleep(1)  # Allow time for USB host to initialize
mouse = find_and_init_boot_mouse("/bitmaps/mouse_cursor.bmp")
if mouse is None:
    raise RuntimeError("No mouse found connected to USB Host")
main_group.append(mouse.tilegrid)

# Create the game logic object
# pylint: disable=no-value-for-parameter, too-many-function-args
game_logic = GameLogic(
    display,
    mouse,
    game_grid,
    swap_piece,
    selected_piece_group,
    GAME_PIECES,
    HINT_TIMEOUT
)

def update_ui():
    # Update the UI elements with the current game state
    score_label.text = f"Score:\n{game_logic.score}"

waiting_for_release = False
game_over_shown = False

# Create the UI Elements
# Label for the Score
score_label = Label(
    terminalio.FONT,
    color=0xffff00,
    x=5,
    y=10,
)
ui_group.append(score_label)

message_dialog = Group()
message_dialog.hidden = True

def reset():
    global game_over_shown  # pylint: disable=global-statement
    # Reset the game logic
    game_logic.reset()
    message_dialog.hidden = True
    game_over_shown = False

def hide_group(group):
    group.hidden = True

reset()

reset_button = EventButton(
    reset,
    label="Reset",
    width=40,
    height=16,
    x=5,
    y=50,
    style=EventButton.RECT,
)
ui_group.append(reset_button)

message_label = TextBox(
    terminalio.FONT,
    text="",
    color=0x333333,
    background_color=0xEEEEEE,
    width=display.width // 3,
    height=90,
    align=TextBox.ALIGN_CENTER,
    padding_top=5,
)
message_label.anchor_point = (0, 0)
message_label.anchored_position = (
    display.width // 2 - message_label.width // 2,
    display.height // 2 - message_label.height // 2,
)
message_dialog.append(message_label)
message_button = EventButton(
    (hide_group, message_dialog),
    label="OK",
    width=40,
    height=16,
    x=display.width // 2 - 20,
    y=display.height // 2 - message_label.height // 2 + 60,
    style=EventButton.RECT,
)
message_dialog.append(message_button)
ui_group.append(message_dialog)

# main loop
while True:
    update_ui()
    # update mouse
    game_logic.update_mouse()

    if not message_dialog.hidden:
        if message_button.handle_mouse(
            (mouse.x, mouse.y),
            game_logic.pressed_btns and "left" in game_logic.pressed_btns,
            waiting_for_release
        ):
            game_logic.waiting_for_release = True
        continue

    if reset_button.handle_mouse(
        (mouse.x, mouse.y),
        game_logic.pressed_btns is not None and "left" in game_logic.pressed_btns,
        game_logic.waiting_for_release
    ):
        game_logic.waiting_for_release = True

    # process gameboard click if no menu
    game_logic.update()
    game_over = game_logic.check_for_game_over()
    if game_over and not game_over_shown:
        message_label.text = ("No more moves available. your final score is:\n"
                              + str(game_logic.score))
        message_dialog.hidden = False
        game_over_shown = True

View on GitHub

Game Logic

The game logic handles the Tile-matching game logic. I came up with the majority of the code and used Claude to come up with the logic to check for remaining moves, which I then used to check if the game was over as well as showing hints.

The file includes a couple of different classes. That is the GameBoard class, which helps keep track of the state of various elements, and the GameLogic class, which applies game logic based on the current conditions.

This file also includes a few settings, but they really shouldn't be altered unless you have a good reason. The GAMEBOARD_POSITION is meant to represent the upper left-hand corner of where the game board starts. The SPRITE variables are for the sprite indices, and the DEBOUNCE_TIME is the amount of delay in seconds so that the mouse doesn't accidentally double click.

Download File

Copy Code
GAMEBOARD_POSITION = (55, 8)

SELECTOR_SPRITE = 9
EMPTY_SPRITE = 10
DEBOUNCE_TIME = 0.1  # seconds for debouncing mouse clicks

One of the more interesting functions in the Game Logic is apply_gravity. This scans through the board column by column and if there is an empty tile, the piece above it is moved down. If the top row is empty, a new piece is generated. It keeps doing this until nothing changes.

The update function is where the score is calculated. It checks for any matches, removes the tiles, and updates the score. The score is calculated based on the number of pieces in a match, the number of simultaneous matches, and the length of the chain of moves done.

The code to check if there are any move moves is interesting as well. The functions check_match_after_move, check_horizontal_match, check_vertical_match, and find_all_possible_matches work together to find these matches. This is done by scanning each piece and then attempting to make a move using a virtual board. This virtual board is simply a copy of the board as an array made through the Game Board's game_grid_copy function. By doing all of this using simple structures, the algorithm is fairly quick but is only performed during an update and saved into an available moves list to avoid lag.

The other interesting functionality is the code to show a hint. It takes one of the moves from the available moves list and performs a swap animation. Animating the tile swaps will be covered in more detail in the Animations section of this guide.

Most of the mouse handling code was also placed into the game logic because while the pieces are being shifted around, the mouse needs to continue to be updated, or the buffer gets full, and it stops responding.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT

import random
import time
from adafruit_ticks import ticks_ms

GAMEBOARD_POSITION = (55, 8)

SELECTOR_SPRITE = 9
EMPTY_SPRITE = 10
DEBOUNCE_TIME = 0.2  # seconds for debouncing mouse clicks

class GameBoard:
    "Contains the game board"
    def __init__(self, game_grid, swap_piece, selected_piece_group):
        self.x = GAMEBOARD_POSITION[0]
        self.y = GAMEBOARD_POSITION[1]
        self._game_grid = game_grid
        self._selected_coords = None
        self._selected_piece = selected_piece_group[0]
        self._selector = selected_piece_group[1]
        self._swap_piece = swap_piece
        self.selected_piece_group = selected_piece_group

    def add_game_piece(self, column, row, piece_type):
        if 0 <= column < self.columns and 0 <= row < self.rows:
            if self._game_grid[(column, row)] != EMPTY_SPRITE:
                raise ValueError("Position already occupied")
            self._game_grid[(column, row)] = piece_type
        else:
            raise IndexError("Position out of bounds")

    def remove_game_piece(self, column, row):
        if 0 <= column < self.columns and 0 <= row < self.rows:
            self._game_grid[(column, row)] = EMPTY_SPRITE
        else:
            raise IndexError("Position out of bounds")

    def reset(self):
        for column in range(self.columns):
            for row in range(self.rows):
                if self._game_grid[(column, row)] != EMPTY_SPRITE:
                    self.remove_game_piece(column, row)
        # Hide the animation TileGrids
        self._selector.hidden = True
        self._swap_piece.hidden = True
        self.selected_piece_group.hidden = True

    def move_game_piece(self, old_x, old_y, new_x, new_y):
        if 0 <= old_x < self.columns and 0 <= old_y < self.rows:
            if 0 <= new_x < self.columns and 0 <= new_y < self.rows:
                if self._game_grid[(new_x, new_y)] == EMPTY_SPRITE:
                    self._game_grid[(new_x, new_y)] = self._game_grid[(old_x, old_y)]
                    self._game_grid[(old_x, old_y)] = EMPTY_SPRITE
                else:
                    raise ValueError("New position already occupied")
            else:
                raise IndexError("New position out of bounds")
        else:
            raise IndexError("Old position out of bounds")

    @property
    def columns(self):
        return self._game_grid.width

    @property
    def rows(self):
        return self._game_grid.height

    @property
    def selected_piece(self):
        if self._selected_coords is not None and self._selected_piece[0] != EMPTY_SPRITE:
            return self._selected_piece[0]
        return None

    @property
    def swap_piece(self):
        return self._swap_piece

    def set_swap_piece(self, column, row):
        # Set the swap piece to the piece at the specified coordinates
        piece = self.get_piece(column, row)
        if self._swap_piece[0] is None and self._swap_piece[0] == EMPTY_SPRITE:
            raise ValueError("Can't swap an empty piece")
        if self._swap_piece.hidden:
            self._swap_piece[0] = piece
            self._swap_piece.x = column * 32 + self.x
            self._swap_piece.y = row * 32 + self.y
            self._swap_piece.hidden = False
            self._game_grid[(column, row)] = EMPTY_SPRITE
        else:
            self._game_grid[(column, row)] = self._swap_piece[0]
            self._swap_piece[0] = EMPTY_SPRITE
            self._swap_piece.hidden = True

    @property
    def selected_coords(self):
        if self._selected_coords is not None:
            return self._selected_coords
        return None

    @property
    def selector_hidden(self):
        return self._selector.hidden

    @selector_hidden.setter
    def selector_hidden(self, value):
        # Set the visibility of the selector
        self._selector.hidden = value

    def set_selected_coords(self, column, row):
        # Set the selected coordinates to the specified column and row
        if 0 <= column < self.columns and 0 <= row < self.rows:
            self._selected_coords = (column, row)
            self.selected_piece_group.x = column * 32 + self.x
            self.selected_piece_group.y = row * 32 + self.y
        else:
            raise IndexError("Selected coordinates out of bounds")

    def select_piece(self, column, row, show_selector=True):
        # Take care of selecting a piece
        piece = self.get_piece(column, row)
        if self.selected_piece is None and piece == EMPTY_SPRITE:
            # If no piece is selected and the clicked piece is empty, do nothing
            return

        if (self.selected_piece is not None and
            (self._selected_coords[0] != column or self._selected_coords[1] != row)):
            # If a piece is already selected and the coordinates don't match, do nothing
            return

        if self.selected_piece is None:
            # No piece selected, so select the specified piece
            self._selected_piece[0] = self.get_piece(column, row)
            self._selected_coords = (column, row)
            self.selected_piece_group.x = column * 32 + self.x
            self.selected_piece_group.y = row * 32 + self.y
            self.selected_piece_group.hidden = False
            self.selector_hidden = not show_selector
            self._game_grid[(column, row)] = EMPTY_SPRITE
        else:
            self._game_grid[(column, row)] = self._selected_piece[0]
            self._selected_piece[0] = EMPTY_SPRITE
            self.selected_piece_group.hidden = True
            self._selected_coords = None

    def get_piece(self, column, row):
        if 0 <= column < self.columns and 0 <= row < self.rows:
            return self._game_grid[(column, row)]
        return None

    @property
    def game_grid_copy(self):
        # Return a copy of the game grid as a 2D list
        return [[self._game_grid[(x, y)] for x in range(self.columns)] for y in range(self.rows)]

class GameLogic:
    "Contains the Logic to examine the game board and determine if a move is valid."
    def __init__(self, display, mouse, game_grid, swap_piece,
                 selected_piece_group, game_pieces, hint_timeout):
        self._display = display
        self._mouse = mouse
        self.game_board = GameBoard(game_grid, swap_piece, selected_piece_group)
        self._score = 0
        self._available_moves = []
        if not 3 <= game_pieces <= 8:
            raise ValueError("game_pieces must be between 3 and 8")
        self._game_pieces = game_pieces  # Number of different game pieces
        self._hint_timeout = hint_timeout
        self._last_update_time = ticks_ms() # For hint timing
        self._last_click_time = ticks_ms()  # For debouncing mouse clicks
        self.pressed_btns = None
        self.waiting_for_release = False

    def update_mouse(self):
        self.pressed_btns = self._mouse.update()
        if self.waiting_for_release and not self.pressed_btns:
            # If both buttons are released, we can process the next click
            self.waiting_for_release = False

    def update(self):
        gb = self.game_board
        if (gb.x <= self._mouse.x <= gb.x + gb.columns * 32 and
            gb.y <= self._mouse.y <= gb.y + gb.rows * 32 and
            not self.waiting_for_release):
            piece_coords = ((self._mouse.x - gb.x) // 32, (self._mouse.y - gb.y) // 32)
            if self.pressed_btns and "left" in self.pressed_btns:
                self._piece_clicked(piece_coords)
                self.waiting_for_release = True
        if self.time_since_last_update > self._hint_timeout:
            self.show_hint()

    def _piece_clicked(self, coords):
        """ Handle a piece click event. """
        if ticks_ms() <= self._last_click_time:
            self._last_click_time -= 2**29 # ticks_ms() wraps around after 2**29 ms

        if ticks_ms() <= self._last_click_time + (DEBOUNCE_TIME * 1000):
            print("Debouncing click, too soon after last click.")
            return
        self._last_click_time = ticks_ms()  # Update last click time

        column, row = coords
        self._last_update_time = ticks_ms()
        # Check if the clicked piece is valid
        if not 0 <= column < self.game_board.columns or not 0 <= row < self.game_board.rows:
            print(f"Clicked coordinates ({column}, {row}) are out of bounds.")
            return

        # If clicked piece is empty and no piece is selected, do nothing
        if (self.game_board.get_piece(column, row) == EMPTY_SPRITE and
            self.game_board.selected_piece is None):
            print(f"No piece at ({column}, {row}) and no piece selected.")
            return

        if self.game_board.selected_piece is None:
            # If no piece is selected, select the piece at the clicked coordinates
            self.game_board.select_piece(column, row)
            return

        if (self.game_board.selected_coords is not None and
            (self.game_board.selected_coords[0] == column and
             self.game_board.selected_coords[1] == row)):
            # If the clicked piece is already selected, deselect it
            self.game_board.select_piece(column, row)
            return

        # If piece is selected and the new coordinates are 1 position
        # away horizontally or vertically, swap the pieces
        if self.game_board.selected_coords is not None:
            previous_x, previous_y = self.game_board.selected_coords
            if ((abs(previous_x - column) == 1 and previous_y == row) or
                (previous_x == column and abs(previous_y - row) == 1)):
                # Swap the pieces
                self._swap_selected_piece(column, row)

    def show_hint(self):
        """ Show a hint by selecting a random available
        move and swapping the pieces back and forth. """
        if self._available_moves:
            move = random.choice(self._available_moves)
            from_coords = move['from']
            to_coords = move['to']
            self.game_board.select_piece(from_coords[0], from_coords[1])
            self._animate_swap(to_coords[0], to_coords[1])
            self.game_board.select_piece(from_coords[0], from_coords[1])
            self._animate_swap(to_coords[0], to_coords[1])
            self._last_update_time = ticks_ms()     # Reset hint timer

    def _swap_selected_piece(self, column, row):
        """ Swap the selected piece with the piece at the specified column and row.
        If the swap is not valid, revert to the previous selection. """
        old_coords = self.game_board.selected_coords
        self._animate_swap(column, row)
        if not self._update_board():
            self.game_board.select_piece(column, row, show_selector=False)
            self._animate_swap(old_coords[0], old_coords[1])

    def _animate_swap(self, column, row):
        """ Copy the pieces to separate tilegrids, animate the swap, and update the game board. """
        if 0 <= column < self.game_board.columns and 0 <= row < self.game_board.rows:
            selected_coords = self.game_board.selected_coords
            if selected_coords is None:
                print("No piece selected to swap.")
                return

            # Set the swap piece value to the column, row value
            self.game_board.set_swap_piece(column, row)
            self.game_board.selector_hidden = True

            # Calculate the steps for animation to move the pieces in the correct direction
            selected_piece_steps = (
                (self.game_board.swap_piece.x - self.game_board.selected_piece_group.x) // 32,
                (self.game_board.swap_piece.y - self.game_board.selected_piece_group.y) // 32
            )
            swap_piece_steps = (
                (self.game_board.selected_piece_group.x - self.game_board.swap_piece.x) // 32,
                (self.game_board.selected_piece_group.y - self.game_board.swap_piece.y) // 32
            )

            # Move the tilegrids in small steps to create an animation effect
            for _ in range(32):
                # Move the selected piece tilegrid to the swap piece position
                self.game_board.selected_piece_group.x += selected_piece_steps[0]
                self.game_board.selected_piece_group.y += selected_piece_steps[1]
                # Move the swap piece tilegrid to the selected piece position
                self.game_board.swap_piece.x += swap_piece_steps[0]
                self.game_board.swap_piece.y += swap_piece_steps[1]
                time.sleep(0.002)

            # Set the existing selected piece coords to the swap piece value
            self.game_board.set_swap_piece(selected_coords[0], selected_coords[1])

            # Update the selected piece coordinates to the new column, row
            self.game_board.set_selected_coords(column, row)

            # Deselect the selected piece (which sets the value)
            self.game_board.select_piece(column, row)

    def _apply_gravity(self):
        """ Go through each column from the bottom up and move pieces down
        continue until there are no more pieces to move """
        # pylint:disable=too-many-nested-blocks
        while True:
            self.pressed_btns = self._mouse.update()
            moved = False
            for x in range(self.game_board.columns):
                for y in range(self.game_board.rows - 1, -1, -1):
                    piece = self.game_board.get_piece(x, y)
                    if piece != EMPTY_SPRITE:
                        # Check if the piece can fall
                        for new_y in range(y + 1, self.game_board.rows):
                            if self.game_board.get_piece(x, new_y) == EMPTY_SPRITE:
                                # Move the piece down
                                self.game_board.move_game_piece(x, y, x, new_y)
                                moved = True
                            break
                    # If the piece was in the top slot before falling, add a new piece
                    if y == 0 and self.game_board.get_piece(x, 0) == EMPTY_SPRITE:
                        self.game_board.add_game_piece(x, 0, random.randint(0, self._game_pieces))
                        moved = True
            if not moved:
                break

    def _check_for_matches(self):
        """ Scan the game board for matches of 3 or more in a row or column """
        matches = []
        for x in range(self.game_board.columns):
            for y in range(self.game_board.rows):
                piece = self.game_board.get_piece(x, y)
                if piece != EMPTY_SPRITE:
                    # Check horizontal matches
                    horizontal_match = [(x, y)]
                    for dx in range(1, 3):
                        if (x + dx < self.game_board.columns and
                            self.game_board.get_piece(x + dx, y) == piece):
                            horizontal_match.append((x + dx, y))
                        else:
                            break
                    if len(horizontal_match) >= 3:
                        matches.append(horizontal_match)

                    # Check vertical matches
                    vertical_match = [(x, y)]
                    for dy in range(1, 3):
                        if (y + dy < self.game_board.rows and
                            self.game_board.get_piece(x, y + dy) == piece):
                            vertical_match.append((x, y + dy))
                        else:
                            break
                    if len(vertical_match) >= 3:
                        matches.append(vertical_match)
        return matches

    def _update_board(self):
        """ Update the game logic, check for matches, and apply gravity. """
        matches_found = False
        multiplier = 1
        matches = self._check_for_matches()
        while matches:
            if matches:
                for match in matches:
                    for x, y in match:
                        self.game_board.remove_game_piece(x, y)
                        self._score += 10 * multiplier * len(matches) * (len(match) - 2)
                time.sleep(0.5)  # Pause to show the match removal
                self._apply_gravity()
                matches_found = True
                matches = self._check_for_matches()
                multiplier += 1
        self._available_moves = self._find_all_possible_matches()
        print(f"{len(self._available_moves)} available moves found.")
        return matches_found

    def reset(self):
        """ Reset the game board and score. """
        print("Reset started")
        self.game_board.reset()
        self._score = 0
        self._last_update_time = ticks_ms()
        self._apply_gravity()
        self._update_board()
        print("Reset completed")

    def _check_match_after_move(self, row, column, direction, move_type='horizontal'):
        """ Move the piece in a copy of the board to see if it creates a match."""
        if move_type == 'horizontal':
            new_row, new_column = row, column + direction
        else:  # vertical
            new_row, new_column = row + direction, column

        # Check if move is within bounds
        if (new_row < 0 or new_row >= self.game_board.rows or
            new_column < 0 or new_column >= self.game_board.columns):
            return False, False

        # Create a copy of the grid with the moved piece
        new_grid = self.game_board.game_grid_copy
        piece = new_grid[row][column]
        new_grid[row][column], new_grid[new_row][new_column] = new_grid[new_row][new_column], piece

        # Check for horizontal matches at the new position
        horizontal_match = self._check_horizontal_match(new_grid, new_row, new_column, piece)

        # Check for vertical matches at the new position
        vertical_match = self._check_vertical_match(new_grid, new_row, new_column, piece)

        # Also check the original position for matches after the swap
        original_piece = new_grid[row][column]
        horizontal_match_orig = self._check_horizontal_match(new_grid, row, column, original_piece)
        vertical_match_orig = self._check_vertical_match(new_grid, row, column, original_piece)

        all_matches = (horizontal_match + vertical_match +
                       horizontal_match_orig + vertical_match_orig)

        return True, len(all_matches) > 0

    @staticmethod
    def _check_horizontal_match(grid, row, column, piece):
        """Check for horizontal 3-in-a-row matches centered
        around or including the given position."""
        matches = []
        columns = len(grid[0])

        # Check all possible 3-piece horizontal combinations that include this position
        for start_column in range(max(0, column - 2), min(columns - 2, column + 1)):
            if (start_column + 2 < columns and
                grid[row][start_column] == piece and
                grid[row][start_column + 1] == piece and
                grid[row][start_column + 2] == piece):
                matches.append([(row, start_column),
                                (row, start_column + 1),
                                (row, start_column + 2)])

        return matches

    @staticmethod
    def _check_vertical_match(grid, row, column, piece):
        """Check for vertical 3-in-a-row matches centered around or including the given position."""
        matches = []
        rows = len(grid)

        # Check all possible 3-piece vertical combinations that include this position
        for start_row in range(max(0, row - 2), min(rows - 2, row + 1)):
            if (start_row + 2 < rows and
                grid[start_row][column] == piece and
                grid[start_row + 1][column] == piece and
                grid[start_row + 2][column] == piece):
                matches.append([(start_row, column),
                                (start_row + 1, column),
                                (start_row + 2, column)])

        return matches

    def check_for_game_over(self):
        """ Check if there are no available moves left on the game board. """
        if not self._available_moves:
            return True
        return False

    def _find_all_possible_matches(self):
        """
        Scan the entire game board to find all possible moves that would create a 3-in-a-row match.
        """
        possible_moves = []

        for row in range(self.game_board.rows):
            for column in range(self.game_board.columns):
                # Check move right
                can_move, creates_match = self._check_match_after_move(
                    row, column, 1, 'horizontal')
                if can_move and creates_match:
                    possible_moves.append({
                        'from': (column, row),
                        'to': (column + 1, row),
                    })

                # Check move left
                can_move, creates_match = self._check_match_after_move(
                    row, column, -1, 'horizontal')
                if can_move and creates_match:
                    possible_moves.append({
                        'from': (column, row),
                        'to': (column - 1, row),
                    })

                # Check move down
                can_move, creates_match = self._check_match_after_move(
                    row, column, 1, 'vertical')
                if can_move and creates_match:
                    possible_moves.append({
                        'from': (column, row),
                        'to': (column, row + 1),
                    })

                # Check move up
                can_move, creates_match = self._check_match_after_move(
                    row, column, -1, 'vertical')
                if can_move and creates_match:
                    possible_moves.append({
                        'from': (column, row),
                        'to': (column, row - 1),
                    })

        # Remove duplicates because from and to can be reversed
        unique_moves = set()
        for move in possible_moves:
            from_coords = tuple(move['from'])
            to_coords = tuple(move['to'])
            if from_coords > to_coords:
                unique_moves.add((to_coords, from_coords))
            else:
                unique_moves.add((from_coords, to_coords))
        possible_moves = [{'from': move[0], 'to': move[1]} for move in unique_moves]

        return possible_moves

    @property
    def score(self):
        return self._score

    @property
    def time_since_last_update(self):
        return (ticks_ms() - self._last_update_time) / 1000.0

View on GitHub

Event Button

This button was reused from the Minesweeper guide. The event button builds on the standard button available in the adafruit_button library. It adds the option to specify a callback function when the button is clicked as well as some mouse handling code so that a click is only registered if another element wasn't already selected, and the click is within the boundaries of the button. This way if another UI element is selected and the mouse is dragged onto the button; it is handled properly.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT

from adafruit_button import Button

class EventButton(Button):
    """A button that can be used to trigger a callback when clicked.

    :param callback: The callback function to call when the button is clicked.
    A tuple can be passed with an argument that will be passed to the
    callback function. The first element of the tuple should be the
    callback function, and the remaining elements will be passed as
    arguments to the callback function.
    """
    def __init__(self, callback, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.args = []
        self.selected = False
        if isinstance(callback, tuple):
            self.callback = callback[0]
            self.args = callback[1:]
        else:
            self.callback = callback

    def click(self):
        """Call the function when the button is pressed."""
        self.callback(*self.args)

    def handle_mouse(self, point, clicked, waiting_for_release):
        if waiting_for_release:
            return False

        # Handle mouse events for the button
        if self.contains(point):
            self.selected = True
            if clicked:
                self.click()
                return True
        else:
            self.selected = False
        return False

View on GitHub

Animations

There are several animations that make the game work. They use different techniques, and I wanted to cover each of them here.

Piece Drops

The first type is piece drops. This is a relatively simple animation where the values of the tilegrid are simply updated with the corresponding value. The dropping effect is achieved by updating the columns from the bottom up. Initially, I was going to try and make each piece a separate tilegrid and move them all at the same time, but that both increased the game's complexity and caused a lot of lag, especially in the initial piece drop.

Even with using a single tilegrid, there's still a bit of lag when everything drops after a reset. The delay during the animation can cause issues with the mouse buffer getting full and this causes it to not respond until a reset has been performed. To get around this, the mouse update function is called within the gravity function.

One interesting bit is when matches are made, in order to show which pieces were matches, a half second delay before dropping highlights where the pieces were removed before dropping them again. Although the technique is simple, it is very effective.

gaming_InitialDrop-ezgifcom-optimize

Selecting Pieces

Selecting pieces is accomplished with a displayio group. This group contains 2 tilegrids stacked on top of each other. The lower tilegrid is the sprite of the piece that is selected and the upper tilegrid contains the selector sprite. When a piece is clicked, the group is moved above the clicked piece. The tilegrid is set in the selected layer, the group is made visible, and the piece on the game board is replaced with an empty sprite. This helps prepare it for swapping. If the piece is clicked again, it is deselected by reversing the process.

gaming_InitialDrop-ezgifcom-optimize

Swapping Positions

Swapping the positions of pieces is done in a very clever way. The first piece is already selected and placed in a tilegrid as described above. When a second piece adjacent to the first is clicked, the swap piece is loaded into another tilegrid in a manner similar to the first piece. The steps that the pieces need to move are then calculated. This is to help figure out which direction each piece should go, based on the current position and the target position, to perform the swap animation. After that the tilegrids are simply moved and displayio takes care of the rest. Once both pieces have been moved, the pieces are unloaded from the tilegrids and the game board is updated.

Hints are similar to the piece swapping, except the selector is hidden during the process.

gaming_PieceSwap-ezgifcom-optimize

Usage

The goal of the game is to get the highest score you can by matching at least 3 tiles in a row. If you are able to match 4 or 5, you can get additional points. Also, making 2 simultaneous matches with the same piece will increase your score.

gaming_ExamplePlaying-ezgifcom-optimize

If swapping the pieces doesn't create a match, the pieces will be unswapped and gameplay will continue.

gaming_NoMatch-ezgifcom-optimize

If a piece is clicked that in not adjacent to the selected piece, then nothing will happen.

gaming_Unswappable-ezgifcom-optimize

If you take a while to make a move, a hint will be displayed by swapping a random one of the possible moves and swapping them back.

gaming_Hint-ezgifcom-optimize

Once there are no more moves available, the Game Over screen is display with your final score.

display_16

Have fun playing and feel free to add additional features such as continuous play by clearing the board when there are no moves without resetting the score to continue playing as long as you'd like.

製造商零件編號 6267
METRO RP2350 WITH PSRAM
Adafruit Industries LLC
製造商零件編號 6055
ADAFRUIT RP2350 22-PIN FPC HSTX
Adafruit Industries LLC
製造商零件編號 6036
22-PIN 0.5MM PITCH FPC FLEX CABL
Adafruit Industries LLC
製造商零件編號 4449
CBL ASSY USB F RCPT TO CBL 0.98'
Adafruit Industries LLC
製造商零件編號 392
BRKWY 0.1" 36POS MALE 10PK
Adafruit Industries LLC
製造商零件編號 4474
CABLE A PLUG TO C PLUG 3'
Adafruit Industries LLC
製造商零件編號 608
CABLE M-M HDMI-A 1M
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.