Overview
Desktop Tetris Building
Play a game of Tetris on a custom 3D printed LED matrix inspired by the MIT Green Building hack. The project is powered by an Adafruit Feather RP2040 PropMaker running CircuitPython and a Mini Gamepad STEMMA QT.
It also features a 1200mAh lipo battery for portable gameplay.
The 9x13 grid is made using a strand of 117 NeoPixel LED pebbles which are fitted into a 3D printed grid and diffused with a piece of black LED acrylic.
Gameplay
The gamepad STEMMA QT features standard buttons and a joystick. Use the joystick to move the shapes (Tetrominoes) on the grid, A button to rotate and B button to drop to the bottom of the grid. The start button begins the game and pauses during gameplay.
Page last edited April 28, 2026
Text editor powered by tinymce.
Circuit Diagram
The diagram below provides a general visual reference for wiring of the components once you get to the Assembly page. This diagram was created using the software package Fritzing.
Adafruit Library for Fritzing
Adafruit uses the Adafruit Fritzing parts library to create circuit diagrams for projects. You can download the library or just grab individual parts. Get the library and parts from GitHub - Adafruit Fritzing Parts.
Wired Connections
The PropMaker Feather RP2040 is powered by a 3.7v 1200mAh Lithium Ion Polymer Battery. Wires connecting various components:
- DIN from NeoPixel strip to NeoPixel Input on Feather
- 5V from NeoPixel strip to 5V on Feather
- GND on NeoPixel strip to GND on Feather
- STEMMA JST connector from Gamepad connects to STEMMA JST connector on Feather
- GND from Feather to Pin 1 on Slide Switch
- EN from Feather to Pin 2 on Slide Switch
Page last edited April 28, 2026
Text editor powered by tinymce.
CAD Files
Building Assembly
The matrix is a 9x13 grid fitted into a building front panel with a piece of black LED acrylic layered in between.
The front panel features rails that allow it to slide into the main building.
The Feather and slide switch are secured to the bottom cover.
The bottom and top covers snap fit into the building.
Gamepad Assembly
The mini gamepad is fitted into the bottom half of the gamepad enclosure. The top snap fits over the bottom.
CAD Parts
Individual 3MF files for 3D printing are oriented and ready to print on FDM machines using PLA/PETG filament. Original design source files may be downloaded using the links below.
Build Volume
The parts require a 3D printer with a minimum build volume of 203 (X) x 99 (Y) x 248mm (Z).
Acrylic Template
A piece of acrylic will need to be cut down to size in order to fit into the front panel.
This SVG template is available to download and print on paper to help assist with measuring the acrylic.
Page last edited April 28, 2026
Text editor powered by tinymce.
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.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
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 RPI-RP2 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 RPI-RP2.
Drag the adafruit_circuitpython_etc.uf2 file to RPI-RP2.
The RPI-RP2 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
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.
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.
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 RPI-RP2. 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.
Page last edited April 28, 2026
Text editor powered by tinymce.
Code
Once you've finished setting up your RP2040 Prop-Maker Feather with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
# SPDX-License-Identifier: MIT
"""NeoPixel Tetris on vertical 9x13 grid
Uses the seesaw gamepad for control"""
import time
import random
import board
import neopixel
from displayio import Bitmap
from micropython import const
from digitalio import DigitalInOut, Direction
from adafruit_bitmap_font import bitmap_font
from adafruit_display_text.bitmap_label import Label
from adafruit_pixel_framebuf import PixelFramebuffer
from adafruit_seesaw.seesaw import Seesaw
from adafruit_ticks import ticks_ms, ticks_diff, ticks_add
# enable external power pin
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True
# NeoPixel grid setup with pixelframebuffer
NUMPIXELS = 117
BRIGHTNESS = 1
PIN = board.EXTERNAL_NEOPIXELS
ORDER = neopixel.BGR
pixels = neopixel.NeoPixel(PIN, NUMPIXELS, brightness=BRIGHTNESS,
auto_write=False, pixel_order=ORDER)
fb = PixelFramebuffer(
pixels,
width=9,
height=13,
orientation=0,
rotation=2,
alternating=True,
reverse_x=True
)
grid = [0] * (9 * 13)
# font for scrolling text
font = bitmap_font.load_font("tom-thumb.pcf", Bitmap)
# seesaw gamepad
BUTTON_X = const(6)
BUTTON_Y = const(2)
BUTTON_A = const(5)
BUTTON_B = const(1)
BUTTON_SELECT = const(0)
BUTTON_START = const(16)
button_mask = const(
(1 << BUTTON_X)
| (1 << BUTTON_Y)
| (1 << BUTTON_A)
| (1 << BUTTON_B)
| (1 << BUTTON_SELECT)
| (1 << BUTTON_START)
)
i2c_bus = board.STEMMA_I2C()
seesaw = Seesaw(i2c_bus, addr=0x50)
seesaw.pin_mode_bulk(button_mask, seesaw.INPUT_PULLUP)
JOY_CENTER = 512
JOY_EDGE = 200
# Tetrominoes
o_tetro = [
[0xFFFF00, 0xFFFF00],
[0xFFFF00, 0xFFFF00],
]
i_tetro = [
[0x00FFFF],
[0x00FFFF],
[0x00FFFF],
[0x00FFFF],
]
s_tetro = [
[0x000000, 0xFF0000, 0xFF0000],
[0xFF0000, 0xFF0000, 0x000000],
]
z_tetro = [
[0x00FF00, 0x00FF00, 0x000000],
[0x000000, 0x00FF00, 0x00FF00],
]
l_tetro = [
[0xFF5500, 0x000000],
[0xFF5500, 0x000000],
[0xFF5500, 0xFF5500],
]
j_tetro = [
[0x000000, 0xFF00FF],
[0x000000, 0xFF00FF],
[0xFF00FF, 0xFF00FF],
]
t_tetro = [
[0x5500FF, 0x5500FF, 0x5500FF],
[0x000000, 0x5500FF, 0x000000],
]
tetrominoes = [o_tetro, i_tetro, s_tetro, z_tetro,
l_tetro, j_tetro, t_tetro]
# sprite class to handle tetrominoes
class Sprite:
def __init__(self, data, transparent=None, rotation=0):
if isinstance(data[0], list):
self.height = len(data)
self.width = len(data[0])
self.data = [pixel for row in data for pixel in row]
else:
raise ValueError("Sprite data must be a 2D list")
self.x = 0
self.y = 0
self.transparent = transparent
self.rotation = rotation
def draw(self, framebuffer = fb, screen_w=9, screen_h=13):
for row in range(self.height):
for col in range(self.width):
color = self.data[row * self.width + col]
if color == self.transparent:
continue
if self.rotation == 0:
px, py = col, row
elif self.rotation == 1:
px, py = self.height - 1 - row, col
elif self.rotation == 2:
px, py = self.width - 1 - col, self.height - 1 - row
elif self.rotation == 3:
px, py = row, self.width - 1 - col
px += self.x
py += self.y
if 0 <= px < screen_w and 0 <= py < screen_h:
framebuffer.pixel(px, py, color)
def move(self, dx, dy):
self.x += dx
self.y += dy
def rotate(self, can_move_fn):
new_rot = (self.rotation + 1) % 4
# try rotation in place with no offset
if can_move_fn(self, 0, 0, rot=new_rot):
self.rotation = new_rot
return True
# calculate how far out of bounds the rotated piece would be
old_rot = self.rotation
self.rotation = new_rot
new_w = self.draw_width
new_h = self.draw_height
self.rotation = old_rot
kicks = []
# right edge
if self.x + new_w > 9:
kicks.append((9 - self.x - new_w, 0))
# left edge
if self.x < 0:
kicks.append((-self.x, 0))
# bottom edge
if self.y + new_h > 13:
kicks.append((0, 13 - self.y - new_h))
for dx, dy in kicks:
old_x, old_y = self.x, self.y
self.x += dx
self.y += dy
if can_move_fn(self, 0, 0, rot=new_rot):
self.rotation = new_rot
return True
self.x = old_x
self.y = old_y
return False
@property
def draw_width(self):
return self.width if self.rotation in (0, 2) else self.height
@property
def draw_height(self):
return self.height if self.rotation in (0, 2) else self.width
def grid_get(x, y):
return grid[y * 9 + x]
def grid_set(x, y, color):
grid[y * 9 + x] = color
def can_move(s, dx, dy, rot=None):
check_rot = rot if rot is not None else s.rotation
px = 0
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if check_rot == 0:
px, py = col, row
elif check_rot == 1:
px, py = s.height - 1 - row, col
elif check_rot == 2:
px, py = s.width - 1 - col, s.height - 1 - row
elif check_rot == 3:
px, py = row, s.width - 1 - col
nx = s.x + px + dx
ny = s.y + py + dy
if nx < 0 or nx >= 9:
return False
if ny >= 13:
return False
if ny >= 0 and grid_get(nx, ny) != 0:
return False
return True
def lock_sprite(s):
px = 0
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if s.rotation == 0:
px, py = col, row
elif s.rotation == 1:
px, py = s.height - 1 - row, col
elif s.rotation == 2:
px, py = s.width - 1 - col, s.height - 1 - row
elif s.rotation == 3:
px, py = row, s.width - 1 - col
grid_set(s.x + px, s.y + py, color)
def draw_grid(framebuffer):
for y in range(13):
for x in range(9):
color = grid_get(x, y)
if color != 0:
framebuffer.pixel(x, y, color)
def clear_grid():
for i in range(len(grid)):
grid[i] = 0
def spawn_piece():
seed = random.randint(0, 6)
piece = Sprite(tetrominoes[seed], transparent=0x000000)
max_x = 9 - piece.draw_width
piece.x = random.randint(0, max(0, max_x))
piece.y = -piece.draw_height # start above the board
return piece
def clear_rows(framebuffer):
rows_cleared = 0
y = 12
while y >= 0:
full = True
for x in range(9):
if grid_get(x, y) == 0:
full = False
break
if full:
rows_cleared += 1
# flash the row white
for x in range(9):
grid_set(x, y, 0xFFFFFF)
framebuffer.fill(0x000000)
draw_grid(framebuffer)
framebuffer.display()
time.sleep(0.3)
# shift everything down
for shift_y in range(y, 0, -1):
for x in range(9):
grid_set(x, shift_y, grid_get(x, shift_y - 1))
for x in range(9):
grid_set(x, 0, 0)
else:
y -= 1
return rows_cleared
def is_game_over(s):
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if s.rotation == 0:
py = row
elif s.rotation == 1:
py = col
elif s.rotation == 2:
py = s.height - 1 - row
elif s.rotation == 3:
py = s.width - 1 - col
if s.y + py < 0:
return True
return False
def scroll_text(the_bitmap, scroll_x, count = 0, counting = False, framebuffer = fb):
framebuffer.fill(0x000000)
for col in range(the_bitmap.width):
px = 9 - scroll_x + col
if 0 <= px < 9:
for row in range(the_bitmap.height):
if the_bitmap[col, row]:
framebuffer.pixel(px, ((13 - the_bitmap.height) // 2) + row, 0xFFFFFF)
framebuffer.display()
scroll_x = (scroll_x + 1) % (9 + the_bitmap.width)
if scroll_x == 0:
count += 1
if counting:
return scroll_x, count
else:
return scroll_x
# Input state
joy_moved = False
last_buttons = 0xFFFFFFFF
paused = False
gameplay = False
first_run = True
scroll_label = Label(text="IHTFP", font=font)
scroll_bmp = scroll_label.bitmap
scroll_count = 0
scroll_x_pos = 0
score = 0
sprite = 0
last_fall = ticks_ms()
timer = ticks_ms()
FALL_SPEED = int(0.5 * 1000)
while True:
if first_run:
while scroll_count < 2:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos, scroll_count = scroll_text(scroll_bmp, scroll_x_pos,
scroll_count, counting = True)
timer = ticks_add(timer, 150)
first_run = False
scroll_count = 0
scroll_label.text = "START?"
scroll_bmp = scroll_label.bitmap
now = ticks_ms()
# read inputs
x_joy = 1023 - seesaw.analog_read(14)
buttons = seesaw.digital_read_bulk(button_mask)
just_pressed = last_buttons & ~buttons
if not gameplay:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos = scroll_text(scroll_bmp, scroll_x_pos)
timer = ticks_add(timer, 150)
if just_pressed & (1 << BUTTON_START):
gameplay = True
clear_grid()
sprite = spawn_piece()
last_fall = ticks_ms()
scroll_x_pos = 0
last_buttons = buttons
continue
# start button toggles pause
if just_pressed & (1 << BUTTON_START):
paused = not paused
if paused:
timer = ticks_ms()
scroll_x_pos = 0
scroll_label.text = f"PAUSED - {score} ROWS"
scroll_bmp = scroll_label.bitmap
fb.fill(0x000000)
draw_grid(fb)
fb.display()
else:
last_fall = ticks_ms()
if paused:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos = scroll_text(scroll_bmp, scroll_x_pos)
timer = ticks_add(timer, 150)
last_buttons = buttons
continue
# rotate on A button press
if just_pressed & (1 << BUTTON_A):
sprite.rotate(can_move)
# joystick left/right
if x_joy < JOY_CENTER - JOY_EDGE:
if not joy_moved:
if can_move(sprite, -1, 0):
sprite.move(-1, 0)
joy_moved = True
elif x_joy > JOY_CENTER + JOY_EDGE:
if not joy_moved:
if can_move(sprite, 1, 0):
sprite.move(1, 0)
joy_moved = True
else:
joy_moved = False
# hard drop on b button
if just_pressed & (1 << BUTTON_B):
while can_move(sprite, 0, 1):
sprite.move(0, 1)
last_buttons = buttons
# gravity on timer
if ticks_diff(ticks_ms(), last_fall) >= FALL_SPEED:
if can_move(sprite, 0, 1):
sprite.move(0, 1)
else:
lock_sprite(sprite)
fb.fill(0x000000)
draw_grid(fb)
fb.display()
cleared = clear_rows(fb)
if cleared > 0:
last_fall = ticks_ms()
score += cleared
fb.fill(0x000000)
draw_grid(fb)
fb.display()
if is_game_over(sprite):
for _ in range(3):
fb.fill(0xFFFFFF)
fb.display()
time.sleep(0.5)
fb.fill(0x000000)
fb.display()
time.sleep(0.5)
clear_grid()
timer = ticks_ms()
scroll_x_pos = 0
scroll_label.text = f"{score} ROWS CLEARED!"
scroll_bmp = scroll_label.bitmap
while scroll_count < 2:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos, scroll_count = scroll_text(scroll_bmp, scroll_x_pos,
scroll_count, counting = True)
timer = ticks_add(timer, 150)
scroll_count = 0
score = 0
scroll_label.text = "START?"
scroll_bmp = scroll_label.bitmap
gameplay = False
sprite = spawn_piece()
last_fall = ticks_add(last_fall, FALL_SPEED)
# draw
fb.fill(0x000000)
draw_grid(fb)
sprite.draw(fb)
fb.display()
Upload the Code and Libraries to the RP2040 Prop-Maker Feather
After downloading the Project Bundle, plug your RP2040 Prop-Maker Feather into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the RP2040 Prop-Maker Feather's CIRCUITPY drive.
- lib folder
- code.py
- tom-thumb.pcf (bitmap font)
Your RP2040 Prop-Maker Feather CIRCUITPY drive should look like this after copying the lib folder, PCF font file, and code.py file:
How the CircuitPython Code Works
At the top of the code, the external power pin is enabled and set high so that the NeoPixel terminal block pin is enabled. Then, the NeoPixel object is created. This object is passed to the PixelFramebuffer object. This allows the NeoPixels to be arranged in a matrix that can take a display buffer.
# enable external power pin
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True
# NeoPixel grid setup with pixelframebuffer
NUMPIXELS = 117
BRIGHTNESS = 1
PIN = board.EXTERNAL_NEOPIXELS
ORDER = neopixel.BGR
pixels = neopixel.NeoPixel(PIN, NUMPIXELS, brightness=BRIGHTNESS,
auto_write=False, pixel_order=ORDER)
fb = PixelFramebuffer(
pixels,
width=9,
height=13,
orientation=0,
rotation=2,
alternating=True,
reverse_x=True
)
grid = [0] * (9 * 13)
# font for scrolling text
font = bitmap_font.load_font("tom-thumb.pcf", Bitmap)
Gamepad
The seesaw gamepad breakout is used to control the game. Its buttons are read via a button mask that is sent over I2C with the seesaw library.
# seesaw gamepad
BUTTON_X = const(6)
BUTTON_Y = const(2)
BUTTON_A = const(5)
BUTTON_B = const(1)
BUTTON_SELECT = const(0)
BUTTON_START = const(16)
button_mask = const(
(1 << BUTTON_X)
| (1 << BUTTON_Y)
| (1 << BUTTON_A)
| (1 << BUTTON_B)
| (1 << BUTTON_SELECT)
| (1 << BUTTON_START)
)
i2c_bus = board.STEMMA_I2C()
seesaw = Seesaw(i2c_bus, addr=0x50)
seesaw.pin_mode_bulk(button_mask, seesaw.INPUT_PULLUP)
JOY_CENTER = 512
JOY_EDGE = 200
Tetrominoes
The shapes used in Tetris are called Tetrominoes, which are geometric shapes composed of four squares. In the code, they are created with nested byte arrays. This allows for the code to be able to determine the width and height of the Tetrominoes automatically. Black is used as a transparent color (0x000000) later in the code.
# Tetrominoes
o_tetro = [
[0xFFFF00, 0xFFFF00],
[0xFFFF00, 0xFFFF00],
]
i_tetro = [
[0x00FFFF],
[0x00FFFF],
[0x00FFFF],
[0x00FFFF],
]
s_tetro = [
[0x000000, 0xFF0000, 0xFF0000],
[0xFF0000, 0xFF0000, 0x000000],
]
z_tetro = [
[0x00FF00, 0x00FF00, 0x000000],
[0x000000, 0x00FF00, 0x00FF00],
]
l_tetro = [
[0xFF5500, 0x000000],
[0xFF5500, 0x000000],
[0xFF5500, 0xFF5500],
]
j_tetro = [
[0x000000, 0xFF00FF],
[0x000000, 0xFF00FF],
[0xFF00FF, 0xFF00FF],
]
t_tetro = [
[0x5500FF, 0x5500FF, 0x5500FF],
[0x000000, 0x5500FF, 0x000000],
]
tetrominoes = [o_tetro, i_tetro, s_tetro, z_tetro,
l_tetro, j_tetro, t_tetro]
Sprites
The Sprite class is used to interface between the byte arrays and the PixelFramebuffer. The class supports drawing, moving and rotating the shapes. The rotate() method has logic to determine if a shape is close to the left or right edge so that it can still rotate in place.
class Sprite:
def __init__(self, data, transparent=None, rotation=0):
if isinstance(data[0], list):
self.height = len(data)
self.width = len(data[0])
self.data = [pixel for row in data for pixel in row]
else:
raise ValueError("Sprite data must be a 2D list")
self.x = 0
self.y = 0
self.transparent = transparent
self.rotation = rotation
Game Logic
There are a few functions that handle the gameplay logic. can_move() tracks a piece while its moving to see if there are any obstacles that would prevent it from moving. If an obstacle is detected, then the logic knows that the piece is landing at the bottom of the grid or that the game has ended.
def can_move(s, dx, dy, rot=None):
check_rot = rot if rot is not None else s.rotation
px = 0
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if check_rot == 0:
px, py = col, row
elif check_rot == 1:
px, py = s.height - 1 - row, col
elif check_rot == 2:
px, py = s.width - 1 - col, s.height - 1 - row
elif check_rot == 3:
px, py = row, s.width - 1 - col
nx = s.x + px + dx
ny = s.y + py + dy
if nx < 0 or nx >= 9:
return False
if ny >= 13:
return False
if ny >= 0 and grid_get(nx, ny) != 0:
return False
return True
The lock_sprites() function converts the Sprite to raw pixel data outside of the PixelFramebuffer after it stops moving. The bottom of the grid has no concept of the Tetromino shapes and instead is a grid of NeoPixel data. The Sprite is respawned with spawn_piece() for every turn.
def lock_sprite(s):
px = 0
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if s.rotation == 0:
px, py = col, row
elif s.rotation == 1:
px, py = s.height - 1 - row, col
elif s.rotation == 2:
px, py = s.width - 1 - col, s.height - 1 - row
elif s.rotation == 3:
px, py = row, s.width - 1 - col
grid_set(s.x + px, s.y + py, color)
def spawn_piece():
seed = random.randint(0, 6)
piece = Sprite(tetrominoes[seed], transparent=0x000000)
max_x = 9 - piece.draw_width
piece.x = random.randint(0, max(0, max_x))
piece.y = -piece.draw_height # start above the board
return piece
clear_rows() checks if a horizontal row is filled with colors. If it is, then that row is cleared by turning white and disappearing. The rows above then drop down. is_game_over() checks if any of the sprite shapes get stuck above the gameplay grid.
def clear_rows(framebuffer):
rows_cleared = 0
y = 12
while y >= 0:
full = True
for x in range(9):
if grid_get(x, y) == 0:
full = False
break
if full:
rows_cleared += 1
# flash the row white
for x in range(9):
grid_set(x, y, 0xFFFFFF)
framebuffer.fill(0x000000)
draw_grid(framebuffer)
framebuffer.display()
time.sleep(0.3)
# shift everything down
for shift_y in range(y, 0, -1):
for x in range(9):
grid_set(x, shift_y, grid_get(x, shift_y - 1))
for x in range(9):
grid_set(x, 0, 0)
else:
y -= 1
return rows_cleared
def is_game_over(s):
py = 0
for row in range(s.height):
for col in range(s.width):
color = s.data[row * s.width + col]
if color == s.transparent:
continue
if s.rotation == 0:
py = row
elif s.rotation == 1:
py = col
elif s.rotation == 2:
py = s.height - 1 - row
elif s.rotation == 3:
py = s.width - 1 - col
if s.y + py < 0:
return True
return False
Non-Blocking Text
The text scrolling is handled with the scroll_text() function. It can track the number of times that the text fully scrolls across the display. In the loop, it is wrapped in a ticks time tracker to be non-blocking.
def scroll_text(the_bitmap, scroll_x, count = 0, counting = False, framebuffer = fb):
framebuffer.fill(0x000000)
for col in range(the_bitmap.width):
px = 9 - scroll_x + col
if 0 <= px < 9:
for row in range(the_bitmap.height):
if the_bitmap[col, row]:
framebuffer.pixel(px, ((13 - the_bitmap.height) // 2) + row, 0xFFFFFF)
framebuffer.display()
scroll_x = (scroll_x + 1) % (9 + the_bitmap.width)
if scroll_x == 0:
count += 1
if counting:
return scroll_x, count
else:
return scroll_x
The Loop
In the loop, when the code first boots, the text "IHTFP", which stands for the MIT Interesting Hacks to Fascinate People, scrolls twice. Then, the text is updated with "START?", which scrolls until the start button on the gamepad is pressed.
if first_run:
while scroll_count < 2:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos, scroll_count = scroll_text(scroll_bmp, scroll_x_pos,
scroll_count, counting = True)
timer = ticks_add(timer, 150)
first_run = False
scroll_count = 0
scroll_label.text = "START?"
scroll_bmp = scroll_label.bitmap
if not gameplay:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos = scroll_text(scroll_bmp, scroll_x_pos)
timer = ticks_add(timer, 150)
if just_pressed & (1 << BUTTON_START):
gameplay = True
clear_grid()
sprite = spawn_piece()
last_fall = ticks_ms()
scroll_x_pos = 0
last_buttons = buttons
continue
Pause/Resume
After start is pressed, gameplay begins. If you press the start button while gameplay is in progress, then the game is paused. You'll see "PAUSED" scroll across the display, followed by the number of rows you have cleared so far in the game. Pressing start again resumes gameplay.
# start button toggles pause
if just_pressed & (1 << BUTTON_START):
paused = not paused
if paused:
timer = ticks_ms()
scroll_x_pos = 0
scroll_label.text = f"PAUSED - {score} ROWS"
scroll_bmp = scroll_label.bitmap
fb.fill(0x000000)
draw_grid(fb)
fb.display()
else:
last_fall = ticks_ms()
if paused:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos = scroll_text(scroll_bmp, scroll_x_pos)
timer = ticks_add(timer, 150)
last_buttons = buttons
continue
Game Control
The shapes are moved horizontally on the grid with the joystick. The A button rotates the shape and the B button drops the shape to the bottom of the grid.
# rotate on A button press
if just_pressed & (1 << BUTTON_A):
sprite.rotate(can_move)
# joystick left/right
if x_joy < JOY_CENTER - JOY_EDGE:
if not joy_moved:
if can_move(sprite, -1, 0):
sprite.move(-1, 0)
joy_moved = True
elif x_joy > JOY_CENTER + JOY_EDGE:
if not joy_moved:
if can_move(sprite, 1, 0):
sprite.move(1, 0)
joy_moved = True
else:
joy_moved = False
# hard drop on b button
if just_pressed & (1 << BUTTON_B):
while can_move(sprite, 0, 1):
sprite.move(0, 1)
last_buttons = buttons
Gameplay Ticks
The movement of the shapes during gameplay is tracked with ticks. Every time a row is cleared, the value of score is increased by the value of cleared. When the game is over, the NeoPixels flash white three times and then the score is scrolled across the display twice. The game then resets to play again.
# gravity on timer
if ticks_diff(ticks_ms(), last_fall) >= FALL_SPEED:
if can_move(sprite, 0, 1):
sprite.move(0, 1)
else:
lock_sprite(sprite)
fb.fill(0x000000)
draw_grid(fb)
fb.display()
cleared = clear_rows(fb)
if cleared > 0:
last_fall = ticks_ms()
score += cleared
fb.fill(0x000000)
draw_grid(fb)
fb.display()
if is_game_over(sprite):
for _ in range(3):
fb.fill(0xFFFFFF)
fb.display()
time.sleep(0.5)
fb.fill(0x000000)
fb.display()
time.sleep(0.5)
clear_grid()
timer = ticks_ms()
scroll_x_pos = 0
scroll_label.text = f"{score} ROWS CLEARED!"
scroll_bmp = scroll_label.bitmap
while scroll_count < 2:
if ticks_diff(ticks_ms(), timer) >= 150:
scroll_x_pos, scroll_count = scroll_text(scroll_bmp, scroll_x_pos,
scroll_count, counting = True)
timer = ticks_add(timer, 150)
scroll_count = 0
score = 0
scroll_label.text = "START?"
scroll_bmp = scroll_label.bitmap
gameplay = False
sprite = spawn_piece()
last_fall = ticks_add(last_fall, FALL_SPEED)
# draw
fb.fill(0x000000)
draw_grid(fb)
sprite.draw(fb)
fb.display()
Page last edited April 28, 2026
Text editor powered by tinymce.
Wiring
Slide Switch Wires
Prepare a set of two wires about 3.5inches (9cm) in length.
Using wire strippers, remove a bit of insulation from the tips of each wire.
Tin the exposed wire by applying a bit of solder to them.
Snip off one of the leads on the slide switch, either the far left or right but not the middle. Then trim the two remaining leads short, about half their length.
Solder Wires
Solder the two wires to each of the leads on the slide switch.
A pair of helping hands can assist while soldering wires in place.
Solder the two wires from the slide to the EN and GND pins on the top of the Feather.
Soldered Switch
Take a moment to ensure the wires from the slide switch have been properly soldered.
First NeoPixel
Locate the first NeoPixel in the strand. The connector should match the one in the photo. You'll need to replace it with the silicon ribbon cable so it can be connected to the screw-block terminal on the PropMaker Feather.
Create the Cable
Using the 26AWG silicone ribbon cable, create a 3-wire cable and cut it to a desired length. In this project, 12-inches (30cm) was sufficient.
Using wire strippers, remove a bit of insulation from each wire on both ends. Then, twist and tin the exposed wire using a bit of solder - This helps to prevent the wires from fraying when soldering to the NeoPixel strand.
Solder Wires
Cut the cable from the first NeoPixel in the strand and strip the three wires for power, data in, and ground.
The power wire is denoted with small white dot markings, followed by the data wire, and then the ground wire.
Cut three short pieces of heat shrink tubbing and slip them over the silicone ribbon wires.
Solder the three wires from the strand to the silicone ribbon wires.
Wired NeoPixel Strip
Double check that the solder joints are solid. Slip heat shrink over them and apply heat to the tubing to set them in place.
Page last edited April 28, 2026
Text editor powered by tinymce.
Assembly
Cut Acrylic
Use the template to cut the piece of acrylic. A scoring knife and safety ruler can help cut clean and straight edges.
Install LEDs
Orient the frame grid assembly so the bottom surface of the grids are facing up.
Starting with the first NeoPixel LED, firmly press it into pill shaped cutout in the lower right side of the grid.
The NeoPixel LED should sit just below the thickness of the grids surface. If it's pushed in too far, the light will create a sharp spotlight, whereas the ideal effect is for the light to evenly fill the full rectangular cell in the grid.
Continuing with the installation, going from bottom to top, press fit more NeoPixel LEDs up the column.
Take your time, making sure each LED is fitted relatively the same depth as the first.
If the LED doesn't quite fit, you can pinch and flex the wires to get the LED to insert, then pull the wire to seat the LED at the desired depth.
The NeoPixel LEDs are arranged in a zigzag pattern. On the 13th LED, go to the next column on the left, then continue with the installation going from top to bottom.
At the end of the column, at the 26th LED, go to the left column, then continue going from bottom to top.
Repeat the installation process for all 117 LEDs.
Install Acrylic
Orient the piece of acrylic so the shiny side faces up. Carefully fit the acrylic into the front panel.
Press the down on all the edges so the acrylic is fully seated into the front panel.
Install Matrix
Orient the matrix grid with the front panel noting the starting pixel starts where the front panel has a flush surface.
Press the matrix grid into the front panel so it sits flush with the piece of acrylic.
Double check the orientation of the matrix is installed correctly and that all of the NeoPixels remain seated.
Install Front Panel
Orient the front panel with the building enclosure noting the starting pixel is located near the bottom of the building.
Insert the cable from the starting pixel through the opening in the building.
Slide the front panel through the top of the building, making sure the sides catch into the mating rails.
Switch Holder
Insert the switch at an angle and push it into the recess so it sits flush with the holder.
The actuator should be accessible on the other side of the holder.
Secure Switch Holder
Use two M3 x 6mm long steel machine screws to secure the switch holder to the bottom cover.
Place the switch holder over the area with the two short stand offs so the mounting holes line up.
Insert and fasten the screws to secure the switch holder to the bottom cover.
Secure Feather
Use two M2.5 x 6mm long steel machine screws to secure the Feather to the bottom cover.
Place the Feather over the four standoffs with the mounting holes lined up.
Insert and fasten the screws to secure the Feather to the bottom cover.
Battery Holder
Plug in the 1200mAh lipo battery into the Feather.
Fit the battery in-between the extending walls on the bottom cover.
Slide the battery clip over the walls so the rails mate with the nubs.
Secure NeoPixel Cable
Get the cable from the NeoPixel strand ready to install onto the Feather.
Insert the three wires into their corresponding pins on the Feather screw-block terminals.
Use a small flat head screw driver to secure the wires to the Feather screw-block terminals.
Secure Gamepad
Get the two halves of the gamepad case ready.
Insert the gamepad PCB into the bottom half of the case at an angle to fit the corners underneath the clips.
Slightly flex the bottom cover to allow the gamepad PCB to fit under the remaining set of corner clips.
Assemble Gamepad Case
Orient the top half of the gamepad case with the bottom.
Fit the top over the gamepad PCB and press down to snap fit the two halves together.
Connect Gamepad
Plug the long STEMMA QT cable into the gamepad PCB.
Connect the other end of the cable to the STEMMA QT port on the Feather.
Power Test
Use the slide switch to power on the Feather.
Use the gamepad to run a quick play test to ensure everything is working properly.
Power off the Feather when ready to proceed.
Install Bottom
Orient the bottom cover with the building enclosure.
Fit the STEMMA QT cable through the notch near the lower front of the building.
Press the bottom cover into the building so it snap fits closed.
Make sure none of the wires and cables are being pinched.
Install Rooftop
The top cover and rooftop are joined together with double-sided tape or adhesives.
Apply the tape or adhesive to the flat surface of the top cover, then attach the two parts together.
Install Top
Orient the top cover with the building enclosure.
Press the top cover into the building so it snap fits closed.
Page last edited April 28, 2026
Text editor powered by tinymce.
Usage
Intro Text
On boot, the text IHTFP scrolls across the display, paying tribute to the "Interesting Hacks To Fascinate People" website that features MIT gallery of hacks. Then the word START? scrolls and loops until gameplay has begun using the start button.
Game Controls
- Start button - Starts the game. Pauses the game while play is in progress.
- Joystick - Moves the Tetrominoes horizontally on the grid
- A button - Rotates the Tetrominoes
- B button - Drops the Tetrominoes to the bottom of the grid
Page last edited April 28, 2026
Text editor powered by tinymce.