How it works
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
BOARD_ROLE set to "left" or "right" in settings.toml.ball_position.-
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.
-
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.
File Frames Animation ball-idle.bmp 6 Slow bob + rotation ball-idle-flipped.bmp 6 Mirror of idle (corner-wait state) ball-kick.bmp 32 Rolling exit right + bounce physics ball-kick-left.bmp 32 Rolling exit left ball-enter-left.bmp 32 Entry from left, decaying bounce ball-enter-right.bmp 32 Entry 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: continuebefore writing. -
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
-
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. -
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)
-
Step 6 — Setup: settings.toml
Each MatrixPortal S3 needs a
settings.tomlin its CIRCUITPY root. The only difference between the two boards is theBOARD_ROLEvalue.# 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
-
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.tomlso the game scales to each player.