Art Lishchenko · Gallaugher BZAN 8175 · 2026

Soccer Ball Pass — Accessible HUB75 Game

Boston College · Adapted from Prof. John Gallaugher's ghost-catch project · BC Campus School
Project Overview

A two-screen LED pass game for accessible single-switch input

Two MatrixPortal S3 boards driving 64×32 HUB75 panels pass a pixel-art soccer ball back and forth over MQTT. One click kicks the ball to the other screen; two clicks within the catch window intercepts and returns it. Miss the window and the ball idles patiently in your corner until you kick.

2
MatrixPortal S3
64×32
LED panel each
6
Sprite sheets
MQTT
Cross-board sync

How it works

👆
1 click = kick
Ball animates rolling off the edge of your screen toward the other player's display.
✌️
2 clicks = catch
Within the catch window, two quick clicks intercepts the ball and shoots it back immediately.
⏱️
Missed catch
Ball idles at the corner of your screen — bouncing in place — until you kick it again.
Accessibility design rationale

The 1-click / 2-click interaction was chosen so it can be operated with a single large button, a foot switch, or any HID device capable of producing one or two presses. The 64×32 LED panel is visible from several meters away, making the game playable by participants with limited mobility who cannot sit close to a screen. The indefinite corner-wait state was specifically requested to remove time pressure.

Materials

Adafruit MatrixPortal S3
Two boards. BOARD_ROLE set to "left" or "right" in settings.toml.
64×32 HUB75 panel (×2)
P3 or P4 pitch. The MatrixPortal S3 drives them natively.
Large momentary button (×2)
Wired to the MatrixPortal's built-in button A pin. Or use a USB HID switch.
Adafruit IO account
Free tier sufficient. One MQTT feed: ball_position.
6 BMP sprite sheets
64×32 px frames, 4-color indexed palette. Generated via Python/Pillow.
5V/4A power supply (×2)
One per panel. The MatrixPortal S3 passes power through to the HUB75 connector.
  1. Step 1 — Origin: Gallaugher ghost-catch

    Prof. John Gallaugher's open-source accessible-catch-with-hub75-displays project (GitHub) uses two MatrixPortal S3 boards to pass a ghost sprite between screens via MQTT. The ghost enters, idles, and waits for a button catch. This project is a drop-in reskin: all variable names, MQTT feed names, timing constants, and game logic are preserved for full backward compatibility — only the BMP sprite sheets and code comments are replaced. Anyone who completed Gallaugher's original tutorial can swap in the new BMPs and run this version with no further changes.

  2. Step 2 — Sprite sheet design

    Six BMP files use a 4-color indexed palette: black background (transparent), white hexagon fill, dark pentagon patches, and outline ring. Frame dimensions are exactly 64×32 pixels per frame, tiled horizontally into a strip.

    FileFramesAnimation
    ball-idle.bmp6Slow bob + rotation
    ball-idle-flipped.bmp6Mirror of idle (corner-wait state)
    ball-kick.bmp32Rolling exit right + bounce physics
    ball-kick-left.bmp32Rolling exit left
    ball-enter-left.bmp32Entry from left, decaying bounce
    ball-enter-right.bmp32Entry from right, decaying bounce
    Rendering bug (caught & fixed)

    An initial Pillow script bug caused ball pixels to wrap across frame boundaries when the ball's center approached x=0 or x=64. Fix: strict per-frame pixel clipping — every draw call now checks if px < frame_x_offset or px >= frame_x_offset + 64: continue before writing.

  3. Step 3 — Interaction state machine

    The board lives in one of four states. Transitions are driven by button clicks, MQTT messages from the partner board, and a catch-window timer.

    # States: IDLE | KICKING | ARRIVING | CORNER_WAIT
    
    if click_count == 1 and state == "IDLE":
        state = "KICKING"
        play_animation("ball-kick")
        mqtt.publish(ball_position_feed, other_board)
    
    elif click_count == 2 and state == "ARRIVING":
        state = "KICKING"  # catch → instant return
        play_animation("ball-kick")
        mqtt.publish(ball_position_feed, sender_board)
    
    elif catch_window_expired and state == "ARRIVING":
        state = "CORNER_WAIT"
        play_animation("ball-idle-flipped")
        # wait indefinitely for next 1-click kick
  4. Step 4 — MQTT cross-board communication

    Both boards subscribe to the same Adafruit IO feed (ball_position). When the left board publishes the string "right", the right board's MQTT callback fires and begins the entry animation. The feed carries only the destination string — sprite files are stored locally on each board's CIRCUITPY drive, so no large image data crosses the network.

  5. Step 5 — Sprite generation (Python / Pillow)

    Run the generator script once on a desktop machine. It produces all six BMP files. Then copy them to both boards' CIRCUITPY drives.

    # Core drawing function — strict per-frame clipping
    def draw_ball(img, frame_idx, cx, cy, rotation, bounce_y):
        draw = ImageDraw.Draw(img)
        frame_x_offset = frame_idx * 64
        for dy in range(-BALL_R, BALL_R + 1):
            for dx in range(-BALL_R, BALL_R + 1):
                px = frame_x_offset + cx + dx
                py = cy + bounce_y + dy
                if px < frame_x_offset or px >= frame_x_offset + 64:
                    continue  # prevent wrap into next frame
                if dx*dx + dy*dy <= BALL_R*BALL_R:
                    color = get_soccer_color(dx, dy, rotation)
                    draw.point((px, py), fill=color)
  6. Step 6 — Setup: settings.toml

    Each MatrixPortal S3 needs a settings.toml in its CIRCUITPY root. The only difference between the two boards is the BOARD_ROLE value.

    # settings.toml — LEFT board
    CIRCUITPY_WIFI_SSID = "YourNetwork"
    CIRCUITPY_WIFI_PASSWORD = "YourPassword"
    ADAFRUIT_AIO_USERNAME = "your_username"
    ADAFRUIT_AIO_KEY = "your_aio_key"
    BOARD_ROLE = "left"   # change to "right" on the second board
  7. Step 7 — Field notes from BC Campus School

    The project was demoed at BC Campus School as an assistive-technology example showing that interactive games can be built around minimal, customizable input requirements. The 1-click / 2-click interaction maps naturally to standard AAC switch interfaces. Students with limited fine-motor control were able to pass the ball independently between screens. The indefinite corner-wait behavior — ball waits forever for the next kick — was specifically requested to remove time pressure for users who need longer response windows. Future iteration: add a per-student "catch window" length stored in settings.toml so the game scales to each player.