CircuitPython Usage
To use the game, you need to update code.py with the game program to the CIRCUITPY drive.
Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file 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 code.py file to your CIRCUITPY drive. The program should self start.
There is active development work underway for USB Host support. If you are having trouble with your mice, try upgrading your device to CircuitPython 10.0.0-alpha.6 or newer.
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.
Note that the CIRCUITPY/sd directory is required.
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Match3 game inspired by the Set card game. Two players compete
to find sets of cards that share matching or mis-matching traits.
"""
import array
import atexit
import io
import os
import sys
import time
import board
import busio
import digitalio
import supervisor
import terminalio
import usb
from tilepalettemapper import TilePaletteMapper
from displayio import TileGrid, Group, Palette, OnDiskBitmap, Bitmap
from adafruit_display_text.text_box import TextBox
import adafruit_usb_host_descriptors
from adafruit_debouncer import Debouncer
import adafruit_sdcard
import msgpack
import storage
try:
from match3_game_helpers import (
Match3Game,
STATE_GAMEOVER,
STATE_PLAYING_SETCALLED,
GameOverException,
)
except ImportError:
# Needed for Fruit Jam OS if "Play again" is selected at end of game
os.chdir('/apps/Metro_RP2350_Match3')
from match3_game_helpers import (
Match3Game,
STATE_GAMEOVER,
STATE_PLAYING_SETCALLED,
GameOverException,
)
original_autoreload_val = supervisor.runtime.autoreload
supervisor.runtime.autoreload = False
AUTOSAVE_FILENAME = "match3_game_autosave.dat"
main_group = Group()
display = supervisor.runtime.display
# set up scale factor of 2 for larger display resolution
scale_factor = 1
if display.width > 360:
scale_factor = 2
main_group.scale = scale_factor
save_to = None
game_state = None
try:
# check for autosave file on CPSAVES drive
if AUTOSAVE_FILENAME in os.listdir("/saves/"):
savegame_buffer = io.BytesIO()
with open(f"/saves/{AUTOSAVE_FILENAME}", "rb") as f:
savegame_buffer.write(f.read())
savegame_buffer.seek(0)
game_state = msgpack.unpack(savegame_buffer)
print(game_state)
# if we made it to here then /saves/ exist so use it for
# save data
save_to = f"/saves/{AUTOSAVE_FILENAME}"
except OSError as e:
# no /saves/ dir likely means no CPSAVES
pass
sd_pins_in_use = False
if game_state is None:
# try to use sdcard for saves
# The SD_CS pin is the chip select line.
SD_CS = board.SD_CS
# Connect to the card and mount the filesystem.
try:
cs = digitalio.DigitalInOut(SD_CS)
except ValueError:
sd_pins_in_use = True
print(f"sd pins in use: {sd_pins_in_use}")
try:
if not sd_pins_in_use:
sdcard = adafruit_sdcard.SDCard(
busio.SPI(board.SD_SCK, board.SD_MOSI, board.SD_MISO), cs
)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
if "set_game_autosave.dat" in os.listdir("/sd/"):
savegame_buffer = io.BytesIO()
with open("/sd/set_game_autosave.dat", "rb") as f:
savegame_buffer.write(f.read())
savegame_buffer.seek(0)
game_state = msgpack.unpack(savegame_buffer)
print(game_state)
if "placeholder.txt" not in os.listdir("/sd/"):
# if we made it to here then /sd/ exists and has a card
# so use it for save data
save_to = "/sd/set_game_autosave.dat"
except OSError:
# no SDcard
pass
# background color
bg_bmp = Bitmap(
display.width // scale_factor // 10, display.height // scale_factor // 10, 1
)
bg_palette = Palette(1)
bg_palette[0] = 0x888888
bg_tg = TileGrid(bg_bmp, pixel_shader=bg_palette)
bg_group = Group(scale=10)
bg_group.append(bg_tg)
main_group.append(bg_group)
# create Game helper object
match3_game = Match3Game(
game_state=game_state,
display_size=(display.width // scale_factor, display.height // scale_factor),
save_location=save_to,
)
main_group.append(match3_game)
# create a group to hold the game over elements
game_over_group = Group()
# create a TextBox to hold the game over message
game_over_label = TextBox(
terminalio.FONT,
text="",
color=0xFFFFFF,
background_color=0x111111,
width=display.width // scale_factor // 2,
height=110,
align=TextBox.ALIGN_CENTER,
)
# move it to the center top of the display
game_over_label.anchor_point = (0, 0)
game_over_label.anchored_position = (
display.width // scale_factor // 2 - (game_over_label.width) // 2,
40,
)
# make it hidden, we'll show it when the game is over.
game_over_group.hidden = True
# add the game over lable to the game over group
game_over_group.append(game_over_label)
# load the play again, and exit button bitmaps
play_again_btn_bmp = OnDiskBitmap("btn_play_again.bmp")
exit_btn_bmp = OnDiskBitmap("btn_exit.bmp")
# create TileGrid for the play again button
play_again_btn = TileGrid(
bitmap=play_again_btn_bmp, pixel_shader=play_again_btn_bmp.pixel_shader
)
# transparent pixels in the corners for the rounded corner effect
play_again_btn_bmp.pixel_shader.make_transparent(0)
# centered within the display, offset to the left
play_again_btn.x = (
display.width // scale_factor // 2 - (play_again_btn_bmp.width) // 2 - 30
)
# inside the bounds of the game over label, so it looks like a dialog visually
play_again_btn.y = 100
# create TileGrid for the exit button
exit_btn = TileGrid(bitmap=exit_btn_bmp, pixel_shader=exit_btn_bmp.pixel_shader)
# transparent pixels in the corners for the rounded corner effect
exit_btn_bmp.pixel_shader.make_transparent(0)
# add the play again and exit buttons to the game over group
game_over_group.append(play_again_btn)
main_group.append(game_over_group)
# Along right border
exit_btn.x = display.width // scale_factor - (exit_btn_bmp.width)
exit_btn.y = 100
main_group.append(exit_btn)
# wait a second for USB devices to be ready
time.sleep(1)
# load the mouse bitmap
mouse_bmp = OnDiskBitmap("mouse_cursor.bmp")
# make the background pink pixels transparent
mouse_bmp.pixel_shader.make_transparent(0)
# list for mouse tilegrids
mouse_tgs = []
# list for palette mappers, one for each mouse
palette_mappers = []
# list for mouse colors
colors = [0x2244FF, 0xFFFF00]
# remap palette will have the 3 colors from mouse bitmap
# and the two colors from the mouse colors list
remap_palette = Palette(3 + len(colors))
# index 0 is transparent
remap_palette.make_transparent(0)
# copy the 3 colors from the mouse bitmap palette
for i in range(3):
remap_palette[i] = mouse_bmp.pixel_shader[i]
# copy the 2 colors from the mouse colors list
for i in range(2):
remap_palette[i + 3] = colors[i]
# create tile palette mappers
for i in range(2):
if sys.implementation.version[0] == 9:
palette_mapper = TilePaletteMapper(remap_palette, 3, 1, 1)
elif sys.implementation.version[0] >= 10:
palette_mapper = TilePaletteMapper(remap_palette, 3)
palette_mappers.append(palette_mapper)
# create tilegrid for each mouse
mouse_tg = TileGrid(mouse_bmp, pixel_shader=palette_mapper)
mouse_tg.x = display.width // scale_factor // 2 - (i * 12)
mouse_tg.y = display.height // scale_factor // 2
mouse_tgs.append(mouse_tg)
# remap index 2 to each of the colors in mouse colors list
palette_mapper[0] = [0, 1, i + 3]
# USB info lists
mouse_interface_indexes = []
mouse_endpoint_addresses = []
detached_interfaces = []
# USB device object instance list
mice = []
# buffers list for mouse packet data
mouse_bufs = []
# debouncers list for debouncing mouse left clicks
mouse_debouncers = []
mouse_sync = []
# scan for connected USB devices
for find_endpoint, default_sync in [
(adafruit_usb_host_descriptors.find_boot_mouse_endpoint, 0),
(adafruit_usb_host_descriptors.find_report_mouse_endpoint, -1)
]:
for device in usb.core.find(find_all=True):
if device in mice:
print('found device twice')
continue
# check if current device is has a boot mouse endpoint
try:
mouse_interface_index, mouse_endpoint_address = (find_endpoint(device))
if mouse_interface_index is not None and mouse_endpoint_address is not None:
if (
mouse_interface_index in mouse_interface_indexes and
mouse_endpoint_address in mouse_endpoint_addresses
):
print('found index/address twice')
continue
# if it does have a mouse endpoint then add information to the
# usb info lists
mouse_interface_indexes.append(mouse_interface_index)
mouse_endpoint_addresses.append(mouse_endpoint_address)
# add the mouse device instance to list
mice.append(device)
print(
f"{default_sync} mouse interface: {mouse_interface_index} "
+ f"endpoint_address: {hex(mouse_endpoint_address)}"
)
mouse_sync.append(default_sync)
# detach kernel driver if needed
detached = []
# Typically HID devices have interfaces 0,1,2
for intf in range(3):
print(f'interface: {intf} ',end="")
try:
if device.is_kernel_driver_active(intf):
device.detach_kernel_driver(intf)
detached.append(intf)
print(f"Detached kernel driver from interface {intf}")
else:
print("not active")
except usb.core.USBError as e:
print(e)
detached_interfaces.append(detached)
except usb.core.USBError as e:
# The mouse might have glitched and may not be detected but at least we don't crash
print(e)
if len(mice) >= 2:
break
if len(mice) >= 2:
break
# set the mouse configuration on any detected mice so they can be used
for device in mice:
device.set_configuration()
def is_mouse1_left_clicked():
"""
Check if mouse 1 left click is pressed
:return: True if mouse 1 left click is pressed
"""
return is_left_mouse_clicked(mouse_bufs[0])
def is_mouse2_left_clicked():
"""
Check if mouse 2 left click is pressed
:return: True if mouse 2 left click is pressed
"""
return is_left_mouse_clicked(mouse_bufs[1])
def is_left_mouse_clicked(buf):
"""
Check if a mouse is pressed given its packet buffer
filled with read data
:param buf: the buffer containing the packet data
:return: True if mouse left click is pressed
"""
val = buf[0] & (1 << 0) != 0
return val
def is_right_mouse_clicked(buf):
"""
check if a mouse right click is pressed given its packet buffer
:param buf: the buffer containing the packet data
:return: True if mouse right click is pressed
"""
val = buf[0] & (1 << 1) != 0
return val
# print(f"addresses: {mouse_endpoint_addresses}")
# print(f"indexes: {mouse_interface_indexes}")
for mouse_tg in mouse_tgs:
# add the mouse to the main group
main_group.append(mouse_tg)
# Buffer to hold data read from the mouse
# Boot mice have 4 byte reports
mouse_bufs.append(array.array("b", [0] * 8))
# create debouncer objects for left click functions
mouse_debouncers.append(Debouncer(is_mouse1_left_clicked))
mouse_debouncers.append(Debouncer(is_mouse2_left_clicked))
# set main_group as root_group, so it is visible on the display
display.root_group = main_group
# variable to hold winning player
winner = None
def get_mouse_deltas(buffer, read_count, sync):
"""
Given a mouse packet buffer and a read count of number of bytes read,
return the delta x and y values of the mouse.
:param buffer: the buffer containing the packet data
:param read_count: the number of bytes read from the mouse
:return: tuple containing x and y delta values
"""
if read_count == 6 and sync == -1:
delta_x = buffer[2]
delta_y = buffer[3]
elif read_count == 4 or (read_count == 8 and sync > 50):
delta_x = buffer[1]
delta_y = buffer[2]
elif read_count == 8:
delta_x = buffer[2]
delta_y = buffer[4]
if delta_y != 0:
sync = -999
elif delta_y == 0 and sync > -1:
sync += 1
else:
raise ValueError(f"Unsupported mouse packet size: {read_count}, must be 4 or 8")
return delta_x, delta_y, sync
def atexit_callback():
"""
re-attach USB devices to kernel if needed, and set
autoreload back to the original state.
:return:
"""
for _i, _mouse in enumerate(mice):
detached_from_device = detached_interfaces[_i]
if detached_from_device:
for _intf in detached_from_device:
if not _mouse.is_kernel_driver_active(_intf):
_mouse.attach_kernel_driver(_intf)
print(f'#{_i} Index: {_intf} (reattaching)')
else:
print(f'#{_i} Index: {_intf} (Not Attaching)')
supervisor.runtime.autoreload = original_autoreload_val
atexit.register(atexit_callback)
# main loop
while True:
# if set has been called
if match3_game.cur_state == STATE_PLAYING_SETCALLED:
# update the progress bar ticking down
match3_game.update_active_turn_progress()
# loop over the mice objects
for i, mouse in enumerate(mice):
mouse_tg = mouse_tgs[i]
# attempt mouse read
try:
# read data from the mouse, small timeout so we move on
# quickly if there is no data
data_len = 0
data_len = mouse.read(
mouse_endpoint_addresses[i], mouse_bufs[i], timeout=20
)
mouse_deltas = get_mouse_deltas(mouse_bufs[i], data_len, mouse_sync[i])
mouse_sync[i] = mouse_deltas[2]
if mouse_sync[i] == -1:
mouse_bufs[i][0] = mouse_bufs[i][1]
# if we got data, then update the mouse cursor on the display
# using min and max to keep it within the bounds of the display
mouse_tg.x = max(
0,
min(
display.width // scale_factor - 1, mouse_tg.x + mouse_deltas[0] // 2
),
)
mouse_tg.y = max(
0,
min(
display.height // scale_factor - 1,
mouse_tg.y + mouse_deltas[1] // 2,
),
)
# timeout error is raised if no data was read within the allotted timeout
except usb.core.USBTimeoutError:
pass
# common non-fatal error
except usb.core.USBError:
pass
# update the mouse debouncers
mouse_debouncers[i].update()
try:
# if the current mouse is right-clicking
if is_right_mouse_clicked(mouse_bufs[i]):
# let the game object handle the right-click
match3_game.handle_right_click(i)
# if the current mouse left-clicked
if mouse_debouncers[i].rose:
# get the current mouse coordinates
coords = (mouse_tg.x, mouse_tg.y, 0)
# if the mouse point is within the exit
# button bounding box
if exit_btn.contains(coords):
supervisor.reload()
# if the current state is GAMEOVER
if match3_game.cur_state != STATE_GAMEOVER:
# let the game object handle the click event
match3_game.handle_left_click(i, coords)
else:
# if the mouse point is within the play again
# button bounding box
if play_again_btn.contains(coords):
# set next code file to this one
supervisor.set_next_code_file(__file__)
# reload
supervisor.reload()
# if the game is over
except GameOverException:
# check for a winner
winner = None
if match3_game.scores[0] > match3_game.scores[1]:
winner = 0
elif match3_game.scores[0] < match3_game.scores[1]:
winner = 1
# if there was a winner
if winner is not None:
# show a message with the winning player
message = f"\nGame Over\nPlayer{winner + 1} Wins!"
game_over_label.color = colors[winner]
game_over_label.text = message
else: # there wasn't a winner
# show a tie game message
message = "\nGame Over\nTie Game Everyone Wins!"
# centered within the display, offset to the right
# inside the bounds of the game over label, so it looks like a dialog visually
exit_btn.x = display.width // scale_factor // 2 - (exit_btn_bmp.width) // 2 + 30
exit_btn.y = 100
# make the gameover group visible
game_over_group.hidden = False
# delete the autosave file.
os.remove(save_to)
Page last edited July 29, 2025
Text editor powered by tinymce.