Maker.io main logo

Minesweeper on Metro RP2350

33

2025-07-22 | By Adafruit Industries

License: See Original Project Displays HDMI LCD / TFT

Courtesy of Adafruit

Guide by M. LeBlanc-Williams

Overview

minesweeper_1

This project demonstrates how to run the Minesweeper logic puzzle game on the Metro RP2350 using the mouse attached through its USB host pins. It includes controls such as dropdown menus in order to set the difficulty and confirm a reset. This is a take on the classic game of Minesweeper that has been implemented on many systems. It allows right clicking to set flags on known mines or tag squares as unknown with a question mark square.

gaming_Gameplay

The game is displayed on any HDMI compatible display using the HSTX ribbon connector on the Metro 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.

preparing_2

preparing_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.

preparing_4

preparing_5

preparing_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

board_7

board_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

click_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! :)

drive_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.

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.

list_15

Code

The code.py for the game is shown below.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
An implementation of minesweeper. The logic game where the player
correctly identifies the locations of mines on a grid by clicking on squares
and revealing the number of mines in adjacent squares.

The player can also flag squares they suspect contain mines. The game ends when
the player successfully reveals all squares without mines or clicks on a mine.
"""
import array
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
import usb.core
from gamelogic import GameLogic, BLANK, INFO_BAR_HEIGHT, DIFFICULTIES
from menu import Menu, SubMenu

# 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)

game_logic = GameLogic(display) # pylint: disable=no-value-for-parameter

# Load the spritesheet
sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp")

# 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] = 0xaaaaaa
main_group.append(TileGrid(
    background,
    pixel_shader=bg_color
))

# Add Game group, which holds the game board, to the main group
game_group = Group()
main_group.append(game_group)

# 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
mouse_bmp = OnDiskBitmap("/bitmaps/mouse_cursor.bmp")
mouse_bmp.pixel_shader.make_transparent(0)
mouse_tg = TileGrid(mouse_bmp, pixel_shader=mouse_bmp.pixel_shader)
mouse_tg.x = display.width // 2
mouse_tg.y = display.height // 2
main_group.append(mouse_tg)

MENU_ITEM_HEIGHT = INFO_BAR_HEIGHT

def create_game_board():
    # Remove the old game board
    if len(game_group) > 0:
        game_group.pop()

    x = display.width // 2 - (game_logic.grid_width * 16) // 2
    y = ((display.height - INFO_BAR_HEIGHT) // 2 -
         (game_logic.grid_height * 16) // 2 + INFO_BAR_HEIGHT)

    # Create a new game board
    game_board = TileGrid(
        sprite_sheet,
        pixel_shader=sprite_sheet.pixel_shader,
        width=game_logic.grid_width,
        height=game_logic.grid_height,
        tile_height=16,
        tile_width=16,
        x=x,
        y=y,
        default_tile=BLANK,
    )

    game_group.append(game_board)
    return game_board

def update_ui():
    # Update the UI elements with the current game state
    mines_left_label.text = f"Mines: {game_logic.mines_left}"
    elapsed_time_label.text = f"Time: {game_logic.elapsed_time}"

# variable for the mouse USB device instance
mouse = None

# wait a second for USB devices to be ready
time.sleep(1)

# scan for connected USB devices
for device in usb.core.find(find_all=True):
    # print information about the found devices
    print(f"{device.idVendor:04x}:{device.idProduct:04x}")
    print(device.manufacturer, device.product)
    print(device.serial_number)

    # assume this device is the mouse
    mouse = device

    # detach from kernel driver if active
    if mouse.is_kernel_driver_active(0):
        mouse.detach_kernel_driver(0)

    # set the mouse configuration so it can be used
    mouse.set_configuration()

buf = array.array("b", [0] * 4)
waiting_for_release = False
left_button = right_button = False
mouse_coords = (0, 0)

# Create the UI Elements (Ideally fit into 320x16 area)
# Label for the Mines Left (Left of Center)
mines_left_label = Label(
    terminalio.FONT,
    color=0x000000,
    x=5,
    y=0,
)
mines_left_label.anchor_point = (0, 0)
mines_left_label.anchored_position = (5, 2)
ui_group.append(mines_left_label)
# Label for the Elapsed Time (Right of Center)
elapsed_time_label = Label(
    terminalio.FONT,
    color=0x000000,
    x=display.width - 50,
    y=0,
)
elapsed_time_label.anchor_point = (1, 0)
elapsed_time_label.anchored_position = (display.width - 5, 2)
ui_group.append(elapsed_time_label)

# Menu button to change difficulty
difficulty_menu = SubMenu(
    "Difficulty",
    70,
    80,
    display.width // 2 - 70,
    0
)

reset_menu = SubMenu(
    "Reset",
    50,
    40,
    display.width // 2 + 15,
    0
)

message_dialog = Group()
message_dialog.hidden = True

def reset():
    # Reset the game logic
    game_logic.reset()

    # Create a new game board and assign it into the game logic
    game_logic.game_board = create_game_board()

    message_dialog.hidden = True

def set_difficulty(diff):
    game_logic.difficulty = diff
    reset()
    difficulty_menu.select_item(DIFFICULTIES[diff]['label'].lower().replace(" ", "_"))

def hide_group(group):
    group.hidden = True

for i, difficulty in enumerate(DIFFICULTIES):
    # Create a button for each difficulty
    selected = i == game_logic.difficulty
    difficulty_menu.add_item((set_difficulty, i), difficulty['label'], selected)

reset_menu.add_item(reset, "OK")

menu = Menu()
menu.append(difficulty_menu)
menu.append(reset_menu)
ui_group.append(menu)

reset()

message_label = TextBox(
    terminalio.FONT,
    text="",
    color=0x333333,
    background_color=0xEEEEEE,
    width=display.width // 4,
    height=50,
    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 + 20,
    style=EventButton.RECT,
)
message_dialog.append(message_button)
ui_group.append(message_dialog)

# Popup message for game over/win

menus = (reset_menu, difficulty_menu)

# main loop
while True:
    update_ui()
    # attempt mouse read
    try:
        # try to read data from the mouse, small timeout so the code will move on
        # quickly if there is no data
        data_len = mouse.read(0x81, buf, timeout=10)
        left_button = buf[0] & 0x01
        right_button = buf[0] & 0x02

        # if there was 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 - 1, mouse_tg.x + buf[1] // 2))
        mouse_tg.y = max(0, min(display.height - 1, mouse_tg.y + buf[2] // 2))
        mouse_coords = (mouse_tg.x, mouse_tg.y)

        if waiting_for_release and not left_button and not right_button:
            # If both buttons are released, we can process the next click
            waiting_for_release = False

    # timeout error is raised if no data was read within the allotted timeout
    except usb.core.USBTimeoutError:
        # no problem, just go on
        pass
    except AttributeError as exc:
        raise RuntimeError("Mouse not found") from exc
    if not message_dialog.hidden:
        if message_button.handle_mouse(mouse_coords, left_button, waiting_for_release):
            waiting_for_release = True
        continue

    if menu.handle_mouse(mouse_coords, left_button, waiting_for_release):
        waiting_for_release = True
    else:
        # process gameboard click if no menu
        ms_board = game_logic.game_board
        if (ms_board.x <= mouse_tg.x <= ms_board.x + game_logic.grid_width * 16 and
            ms_board.y <= mouse_tg.y <= ms_board.y + game_logic.grid_height * 16 and
            not waiting_for_release):
            coords = ((mouse_tg.x - ms_board.x) // 16, (mouse_tg.y - ms_board.y) // 16)
            if right_button:
                game_logic.square_flagged(coords)
            elif left_button:
                if not game_logic.square_clicked(coords):
                    message_label.text = "Game Over"
                    message_dialog.hidden = False
            if left_button or right_button:
                waiting_for_release = True
        status = game_logic.check_for_win()
        if status:
            message_label.text = "You win!"
            message_dialog.hidden = False
            # Display message
        if status is None:
            continue

View on GitHub

Code Overview

The code for minesweeper is divided into 4 files: The main code (which glues everything together), the game logic, and a couple of custom UI elements. 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 and menu control, 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 menus and dialogs, and setting up the mine count and time labels.

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
An implementation of minesweeper. The logic game where the player
correctly identifies the locations of mines on a grid by clicking on squares
and revealing the number of mines in adjacent squares.

The player can also flag squares they suspect contain mines. The game ends when
the player successfully reveals all squares without mines or clicks on a mine.
"""
import array
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
import usb.core
from gamelogic import GameLogic, BLANK, INFO_BAR_HEIGHT, DIFFICULTIES
from menu import Menu, SubMenu

# 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)

game_logic = GameLogic(display) # pylint: disable=no-value-for-parameter

# Load the spritesheet
sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp")

# 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] = 0xaaaaaa
main_group.append(TileGrid(
    background,
    pixel_shader=bg_color
))

# Add Game group, which holds the game board, to the main group
game_group = Group()
main_group.append(game_group)

# 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
mouse_bmp = OnDiskBitmap("/bitmaps/mouse_cursor.bmp")
mouse_bmp.pixel_shader.make_transparent(0)
mouse_tg = TileGrid(mouse_bmp, pixel_shader=mouse_bmp.pixel_shader)
mouse_tg.x = display.width // 2
mouse_tg.y = display.height // 2
main_group.append(mouse_tg)

MENU_ITEM_HEIGHT = INFO_BAR_HEIGHT

def create_game_board():
    # Remove the old game board
    if len(game_group) > 0:
        game_group.pop()

    x = display.width // 2 - (game_logic.grid_width * 16) // 2
    y = ((display.height - INFO_BAR_HEIGHT) // 2 -
         (game_logic.grid_height * 16) // 2 + INFO_BAR_HEIGHT)

    # Create a new game board
    game_board = TileGrid(
        sprite_sheet,
        pixel_shader=sprite_sheet.pixel_shader,
        width=game_logic.grid_width,
        height=game_logic.grid_height,
        tile_height=16,
        tile_width=16,
        x=x,
        y=y,
        default_tile=BLANK,
    )

    game_group.append(game_board)
    return game_board

def update_ui():
    # Update the UI elements with the current game state
    mines_left_label.text = f"Mines: {game_logic.mines_left}"
    elapsed_time_label.text = f"Time: {game_logic.elapsed_time}"

# variable for the mouse USB device instance
mouse = None

# wait a second for USB devices to be ready
time.sleep(1)

# scan for connected USB devices
for device in usb.core.find(find_all=True):
    # print information about the found devices
    print(f"{device.idVendor:04x}:{device.idProduct:04x}")
    print(device.manufacturer, device.product)
    print(device.serial_number)

    # assume this device is the mouse
    mouse = device

    # detach from kernel driver if active
    if mouse.is_kernel_driver_active(0):
        mouse.detach_kernel_driver(0)

    # set the mouse configuration so it can be used
    mouse.set_configuration()

buf = array.array("b", [0] * 4)
waiting_for_release = False
left_button = right_button = False
mouse_coords = (0, 0)

# Create the UI Elements (Ideally fit into 320x16 area)
# Label for the Mines Left (Left of Center)
mines_left_label = Label(
    terminalio.FONT,
    color=0x000000,
    x=5,
    y=0,
)
mines_left_label.anchor_point = (0, 0)
mines_left_label.anchored_position = (5, 2)
ui_group.append(mines_left_label)
# Label for the Elapsed Time (Right of Center)
elapsed_time_label = Label(
    terminalio.FONT,
    color=0x000000,
    x=display.width - 50,
    y=0,
)
elapsed_time_label.anchor_point = (1, 0)
elapsed_time_label.anchored_position = (display.width - 5, 2)
ui_group.append(elapsed_time_label)

# Menu button to change difficulty
difficulty_menu = SubMenu(
    "Difficulty",
    70,
    80,
    display.width // 2 - 70,
    0
)

reset_menu = SubMenu(
    "Reset",
    50,
    40,
    display.width // 2 + 15,
    0
)

message_dialog = Group()
message_dialog.hidden = True

def reset():
    # Reset the game logic
    game_logic.reset()

    # Create a new game board and assign it into the game logic
    game_logic.game_board = create_game_board()

    message_dialog.hidden = True

def set_difficulty(diff):
    game_logic.difficulty = diff
    reset()
    difficulty_menu.select_item(DIFFICULTIES[diff]['label'].lower().replace(" ", "_"))

def hide_group(group):
    group.hidden = True

for i, difficulty in enumerate(DIFFICULTIES):
    # Create a button for each difficulty
    selected = i == game_logic.difficulty
    difficulty_menu.add_item((set_difficulty, i), difficulty['label'], selected)

reset_menu.add_item(reset, "OK")

menu = Menu()
menu.append(difficulty_menu)
menu.append(reset_menu)
ui_group.append(menu)

reset()

message_label = TextBox(
    terminalio.FONT,
    text="",
    color=0x333333,
    background_color=0xEEEEEE,
    width=display.width // 4,
    height=50,
    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 + 20,
    style=EventButton.RECT,
)
message_dialog.append(message_button)
ui_group.append(message_dialog)

# Popup message for game over/win

menus = (reset_menu, difficulty_menu)

# main loop
while True:
    update_ui()
    # attempt mouse read
    try:
        # try to read data from the mouse, small timeout so the code will move on
        # quickly if there is no data
        data_len = mouse.read(0x81, buf, timeout=10)
        left_button = buf[0] & 0x01
        right_button = buf[0] & 0x02

        # if there was 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 - 1, mouse_tg.x + buf[1] // 2))
        mouse_tg.y = max(0, min(display.height - 1, mouse_tg.y + buf[2] // 2))
        mouse_coords = (mouse_tg.x, mouse_tg.y)

        if waiting_for_release and not left_button and not right_button:
            # If both buttons are released, we can process the next click
            waiting_for_release = False

    # timeout error is raised if no data was read within the allotted timeout
    except usb.core.USBTimeoutError:
        # no problem, just go on
        pass
    except AttributeError as exc:
        raise RuntimeError("Mouse not found") from exc
    if not message_dialog.hidden:
        if message_button.handle_mouse(mouse_coords, left_button, waiting_for_release):
            waiting_for_release = True
        continue

    if menu.handle_mouse(mouse_coords, left_button, waiting_for_release):
        waiting_for_release = True
    else:
        # process gameboard click if no menu
        ms_board = game_logic.game_board
        if (ms_board.x <= mouse_tg.x <= ms_board.x + game_logic.grid_width * 16 and
            ms_board.y <= mouse_tg.y <= ms_board.y + game_logic.grid_height * 16 and
            not waiting_for_release):
            coords = ((mouse_tg.x - ms_board.x) // 16, (mouse_tg.y - ms_board.y) // 16)
            if right_button:
                game_logic.square_flagged(coords)
            elif left_button:
                if not game_logic.square_clicked(coords):
                    message_label.text = "Game Over"
                    message_dialog.hidden = False
            if left_button or right_button:
                waiting_for_release = True
        status = game_logic.check_for_win()
        if status:
            message_label.text = "You win!"
            message_dialog.hidden = False
            # Display message
        if status is None:
            continue

View on GitHub

Game Logic

The game logic handles the Minesweeper game logic. Much of the logic was borrowed from the CircuitPython Minesweeper Game guide, which is a basic version of Minesweeper designed for the PyPortal with touch input. This version adds difficulty levels as well as a timer.

This file starts by defining the difficulty levels. Each difficulty level has a label (which automatically appears in the Difficulty menu), the size of the grid, and the number of mines. Feel free to add your own additional levels, but keep in mind, the maximum grid size is limited by the screen size to 20x14. The mine count will need to be at least 10 and no more than grid_width - 1 * grid_height - 1, which effective makes 4x5 the smallest possible grid size.

Download File

Copy Code
DIFFICULTIES = (
    {
        'label': "Beginner",
        'grid_size': (8,8),
        'mines': 10,
    },
    {
        'label': "Intermediate",
        'grid_size': (14, 14),
        'mines': 30,
    },
    {
        'label': "Expert",
        'grid_size': (20, 14),
        'mines': 58,
    },
)

The choices are automatically added to the Difficulty menu in the order in which they appear in the DIFFICULTIES list. Also, having more than about 14 level choices may cause the menu to be longer than the screen height, which would make the choice unavailable. Here are the default difficulty settings.

settings_16

Download Project Bundle

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

import random
from microcontroller import nvm
from adafruit_ticks import ticks_ms
from displayio import TileGrid

# Mine Densities are about the same as the original
DIFFICULTIES = (
    {
        'label': "Beginner",
        'grid_size': (8,8),
        'mines': 10,
    },
    {
        'label': "Intermediate",
        'grid_size': (14, 14),
        'mines': 30,
    },
    {
        'label': "Expert",
        'grid_size': (20, 14),
        'mines': 58,
    },
)

INFO_BAR_HEIGHT = 16

OPEN = 0
OPEN1 = 1
OPEN2 = 2
OPEN3 = 3
OPEN4 = 4
OPEN5 = 5
OPEN6 = 6
OPEN7 = 7
OPEN8 = 8

BLANK = 9
FLAG = 10
MINE_CLICKED = 11
MINE_FLAGGED_WRONG = 12
MINE = 13
MINE_QUESTION = 14
MINE_QUESTION_OPEN = 15

STATUS_NEWGAME = 0
STATUS_PLAYING = 1
STATUS_WON = 2
STATUS_LOST = 3

class GameLogic:
    def __init__(self, display):
        self._board_data = bytearray()
        self.game_board = None
        self._difficulty = nvm[0]
        if self._difficulty not in DIFFICULTIES:
            self._difficulty = 0
        self._display = display
        self._start_time = None
        self._end_time = None
        self._mine_count = 0
        self._status = STATUS_NEWGAME
        self.reset()

    def reset(self):
        if (self.grid_width * 16 > self._display.width or
            self.grid_height * 16 > self._display.height - INFO_BAR_HEIGHT):
            raise ValueError("Grid size exceeds display size")
        self._mine_count = DIFFICULTIES[self._difficulty]['mines']
        if self._mine_count > (self.grid_width - 1) * (self.grid_height - 1):
            raise ValueError("Too many mines for grid size")
        if self._mine_count < 10:
            raise ValueError("There must be at least 10 mines")
        self._board_data = bytearray(self.grid_width * self.grid_height)
        self._status = STATUS_NEWGAME
        self._start_time = None
        self._end_time = None

    def _seed_mines(self, coords):
        for _ in range(DIFFICULTIES[self._difficulty]['mines']):
            while True:
                mine_x = random.randint(0, self.grid_width - 1)
                mine_y = random.randint(0, self.grid_height - 1)
                if self._get_data(mine_x, mine_y) == 0 and (mine_x, mine_y) != coords:
                    self._set_data(mine_x, mine_y, MINE)
                    break
        self._compute_counts()

    def _set_data(self, x, y, value):
        self._board_data[y * self.grid_width + x] = value

    def _get_data(self, x, y):
        return self._board_data[y * self.grid_width + x]

    def _set_board(self, x, y, value):
        if not isinstance(self.game_board, TileGrid):
            raise ValueError("Game board not initialized")
        self.game_board[x, y] = value # pylint: disable=unsupported-assignment-operation

    def _get_board(self, x, y):
        if not isinstance(self.game_board, TileGrid):
            raise ValueError("Game board not initialized")
        return self.game_board[x, y] # pylint: disable=unsubscriptable-object

    def _compute_counts(self):
        """For each mine, increment the count in each non-mine square around it"""
        for y in range(self.grid_height):
            for x in range(self.grid_width):
                if self._get_data(x, y) != MINE:
                    continue                  # keep looking for mines
                for dx in (-1, 0, 1):
                    if x + dx < 0 or x + dx >= self.grid_width:
                        continue              # off screen
                    for dy in (-1, 0, 1):
                        if y + dy < 0 or y + dy >= self.grid_height:
                            continue          # off screen
                        grid_value = self._get_data(x + dx, y + dy)
                        if grid_value == MINE:
                            continue          # don't process mines
                        self._set_data(x + dx, y + dy, grid_value + 1)

    def _flag_count(self):
        flags = 0
        for x in range(self.grid_width):
            for y in range(self.grid_height):
                if self._get_board(x, y) == FLAG:
                    flags += 1
        return flags

    def expand_uncovered(self, start_x, start_y):
        # pylint: disable=too-many-nested-blocks
        number_uncovered = 1
        stack = [(start_x, start_y)]
        while len(stack) > 0:
            x, y = stack.pop()
            if self._get_board(x, y) == BLANK:
                under_the_tile = self._get_data(x, y)
                if under_the_tile <= OPEN8:
                    self._set_board(x, y, under_the_tile)
                    number_uncovered += 1
                    if under_the_tile == OPEN:
                        for dx in (-1, 0, 1):
                            if x + dx < 0 or x + dx >= self.grid_width:
                                continue              # off screen
                            for dy in (-1, 0, 1):
                                if y + dy < 0 or y + dy >= self.grid_height:
                                    continue          # off screen
                                if dx == 0 and dy == 0:
                                    continue          # don't process where the mine
                                stack.append((x + dx, y + dy))
        return number_uncovered

    def square_flagged(self, coords):
        if self._status in (STATUS_WON, STATUS_LOST):
            return False

        x, y = coords
        TOGGLE_STATES = (BLANK, FLAG, MINE_QUESTION)
        for state in TOGGLE_STATES:
            if self._get_board(x, y) == state:
                self._set_board(x, y,
                    TOGGLE_STATES[(TOGGLE_STATES.index(state) + 1) % len(TOGGLE_STATES)])
                break
        return True

    def square_clicked(self, coords):
        x, y = coords

        if self._status in (STATUS_WON, STATUS_LOST):
            return False

        # First click is never a mine, so start the game
        if self._status == STATUS_NEWGAME:
            self._seed_mines(coords)
            self._status = STATUS_PLAYING
            if self._start_time is None:
                self._start_time = ticks_ms()

        if self._get_board(x, y) != FLAG:
            under_the_tile = self._get_data(x, y)
            if under_the_tile == MINE:
                self._set_data(x, y, MINE_CLICKED)
                self._set_board(x, y, MINE_CLICKED)
                self._status = STATUS_LOST
                self.reveal_board()
                if self._end_time is None:
                    self._end_time = ticks_ms()
                return False          #lost
            elif OPEN1 <= under_the_tile <= OPEN8:
                self._set_board(x, y, under_the_tile)
            elif under_the_tile == OPEN:
                self._set_board(x, y, BLANK)
                self.expand_uncovered(x, y)
            else:
                raise ValueError(f'Unexpected value {under_the_tile} on board')
        return True

    def reveal_board(self):
        for x in range(self.grid_width):
            for y in range(self.grid_height):
                if self._get_board(x, y) == FLAG and self._get_data(x, y) != MINE:
                    self._set_board(x, y, MINE_FLAGGED_WRONG)
                else:
                    self._set_board(x, y, self._get_data(x, y))

    def check_for_win(self):
        """Check for a complete, winning game. That's one with all squares uncovered
        and all bombs correctly flagged, with no non-bomb squares flaged.
        """
        if self._status in (STATUS_WON, STATUS_LOST):
            return None

        # first make sure everything has been explored and decided
        for x in range(self.grid_width):
            for y in range(self.grid_height):
                if self._get_board(x, y) == BLANK or self._get_board(x, y) == MINE_QUESTION:
                    return None               # still ignored or question squares
        # then check for mistagged bombs
        for x in range(self.grid_width):
            for y in range(self.grid_height):
                if self._get_board(x, y) == FLAG and self._get_data(x, y) != MINE:
                    return False               # misflagged bombs, not done
        self._status = STATUS_WON
        if self._end_time is None:
            self._end_time = ticks_ms()
        return True               # nothing unexplored, and no misflagged bombs

    @property
    def grid_width(self):
        return DIFFICULTIES[self._difficulty]['grid_size'][0]

    @property
    def grid_height(self):
        return DIFFICULTIES[self._difficulty]['grid_size'][1]

    @property
    def status(self):
        return self._status

    @property
    def elapsed_time(self):
        """Elapsed time in seconds since the game started with a maximum of 999 seconds"""
        if self._start_time is None:
            return 0
        if self._end_time is None:
            return min(999, (ticks_ms() - self._start_time) // 1000)
        return min(999, (self._end_time - self._start_time) // 1000)

    @property
    def mines_left(self):
        # This number can be negative
        return self._mine_count - self._flag_count()

    @property
    def difficulty(self):
        return self._difficulty

    @difficulty.setter
    def difficulty(self, value):
        if not 0 <= value < len(DIFFICULTIES):
            raise ValueError("Invalid difficulty option")
        self._difficulty = value
        nvm[0] = value
        self.reset()

View on GitHub

Event Button

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 = []
        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):
        # pylint: disable=attribute-defined-outside-init
        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

Menu

The menu is used for handling the options at the top. This allows selecting the difficulty or resetting the game. The reason that the reset option is inside a menu is to help avoid accidentally clicking the button when playing with some of the larger game grids. It was cleaner to implement this way than a dialog that popped up asking to confirm.

The menu class is really just a container for the submenus with similar mouse handling code to that of the event button. When one of the menu items is clicked (which is really just an event button), the submenu's contents are drawn below it, which also consists of event buttons. Each submenu is in a separate displayio to help with controlling the visibility.

Submenu items can also be "selected", which highlights the selected item in green. This is useful for showing the currently selected difficulty level.

Download Project Bundle

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

from displayio import Group
from adafruit_display_shapes.rect import Rect
from eventbutton import EventButton

MENU_ITEM_HEIGHT = 16

class Menu(Group):
    def handle_mouse(self, point, clicked, waiting_for_release):
        if waiting_for_release:
            return False
        # Check if the point is in the menu items group
        handled_submenu = None
        for submenu in self:
            if isinstance(submenu, SubMenu):
                if submenu.handle_mouse(point, clicked):
                    handled_submenu = submenu
        if clicked:
            # Hide any visible menus
            for submenu in self:
                if isinstance(submenu, SubMenu) and submenu != handled_submenu:
                    submenu.hide()
        return handled_submenu is not None

class SubMenu(Group):
    def __init__(self, label, button_width, menu_width, x, y):
        super().__init__()
        self._label = label
        self._button_width = button_width
        self._menu_width = menu_width
        self._menu_items_group = None
        self._xpos = x
        self._ypos = y
        self._menu_items = []
        self._root_button = None

    def add_item(self, function, label, selected=False):
        key = label.lower().replace(" ", "_")
        self._menu_items.append(
            {
                "key": key,
                "function": function,
                "label": label,
                "selected": selected,
            }
        )
        self._render()

    def select_item(self, key):
        for item in self._menu_items:
            if item["key"] == key:
                item["selected"] = True
            else:
                item["selected"] = False
        self._render()

    @staticmethod
    def _create_button(callback, label, width, x, y=0, border=True, selected=False):
        if border:
            outline_color = 0x000000
            selected_outline = 0x333333
        else:
            outline_color = 0xEEEEEE
            selected_outline = 0xBBBBBB

        if selected:
            selected_label = label_color = 0x008800
        else:
            selected_label = label_color = 0x333333

        button = EventButton(
            callback,
            x=x,
            y=y,
            width=width,
            height=MENU_ITEM_HEIGHT,
            label=label,
            style=EventButton.RECT,
            fill_color=0xEEEEEE,
            outline_color=outline_color,
            label_color=label_color,
            selected_fill=0xBBBBBB,
            selected_label=selected_label,
            selected_outline=selected_outline,
        )
        return button


    def _toggle_submenu(self):
        self._menu_items_group.hidden = not self._menu_items_group.hidden

    def _render(self):
        # Redraw the menu
        # Remove all existing elements contained inside of this class
        while len(self) > 0:
            self.pop()

        # create a new root button
        self._root_button = self._create_button(
            self._toggle_submenu,
            self._label,
            self._button_width,
            self._xpos,
            self._ypos,
            True,
        )
        self.append(self._root_button)

        # Create the menu items group
        self._menu_items_group = Group()
        self._menu_items_group.hidden = True
        self.append(self._menu_items_group)

        # Add the background rectangle to the menu items group
        self._menu_items_group.append(
            Rect(self._xpos, self._ypos + self._root_button.height - 1, self._menu_width,
                len(self._menu_items) * MENU_ITEM_HEIGHT + 2,
                fill=0xEEEEEE,
                outline=0x333333
            )
        )

        # Add the menu items to the menu items group
        for index, item in enumerate(self._menu_items):
            button = self._create_button(
                item["function"],
                item["label"],
                self._menu_width - 2,
                self._xpos + 1,
                self._ypos + index * MENU_ITEM_HEIGHT + self._root_button.height,
                False,
                item["selected"],
            )
            self._menu_items_group.append(button)

    def hide(self):
        self._menu_items_group.hidden = True

    def handle_mouse(self, point, clicked):
        # Check if the point is in the root button
        if self._menu_items_group.hidden:
            if self._root_button.contains(point):
                self._root_button.selected = True
                if clicked:
                    self._root_button.click()
                    return True
            else:
                self._root_button.selected = False
        else:
            # Check if the point is in the menu items group
            for button in self._menu_items_group:
                if isinstance(button, EventButton):
                    if button.contains(point):
                        button.selected = True
                        if clicked:
                            button.click()
                            self._menu_items_group.hidden = True
                            return True
                    else:
                        button.selected = False
        return False

    @property
    def visible(self):
        return not self._menu_items_group.hidden

    @property
    def items_group(self):
        return self._menu_items_group

View on GitHub

Usage

To play Minesweeper is simply a matter of loading it up. The goal is to correctly identify all of the mines using a combination of logic and pattern recognition. When squares without mines are clicked with the mouse, they are revealed. If the square contains a mine, all mines will be shown, and the game is over. If there isn't a mine where you clicked, it will behave in a couple of different ways depending on whether it is next to a mine. If the square is next to a mine, it will have a number that indicates the number of mines adjacent to the square; that is any mines to the side of, above, below, or diagonally.

If the square is not next to a mine, it will appear as blank. If there are other blank squares next to the clicked square, they will also be revealed. The first click will never be a mine because the mine placements are not generated until after the first click.

You can also right-click the mouse on any square to flag it as a mine. Right clicking a second time will turn it into a question mark in case you aren't sure between a couple of different squares. Flagged mines don't respond to a normal click. Question marked mines still act like normal squares.

gaming_Gameplay

Strategies

The basic strategy is to start by clicking any square. Some people prefer to click near the edge and some people in the middle, however the middle tends to be the best place to start due to more possible openings.

Once you have clicked on the opening square, your next move will depend on what you clicked on. If you are lucky, a bunch of squares will be revealed, and you can continue with pattern recognition strategies. If not and the square is a number, you will likely need to take a guess as to the next square you click.

click_17

click_18

Flag mines that you are certain about. This prevents accidentally clicking and decrements the mine counter, so you know how many unmarked mines are remaining.

flag_19

If you are unsure between a couple of different spots, using the question mark will come in handy. It will not reduce the mine counter, and the square can still be clicked.

question_20

Save any squares you are uncertain about until the end. Watch the mine counter too because it can also help determine between an uncertain square based on how many are left.

save_21

Because Minesweeper has been around awhile, you can find many more strategies on the internet.

Mfr Part # 6267
METRO RP2350 WITH PSRAM
Adafruit Industries LLC
$230.08
View More Details
Mfr Part # 6055
ADAFRUIT RP2350 22-PIN FPC HSTX
Adafruit Industries LLC
Mfr Part # 6036
22-PIN 0.5MM PITCH FPC FLEX CABL
Adafruit Industries LLC
Mfr Part # 4449
CBL ASSY USB F RCPT TO CBL 0.98'
Adafruit Industries LLC
Mfr Part # 392
BRKWY 0.1" 36POS MALE 10PK
Adafruit Industries LLC
Mfr Part # 4474
CABLE A PLUG TO C PLUG 3'
Adafruit Industries LLC
Mfr Part # 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.