The Scroll pHAT is a little 11x5 (55) white LED matrix pHAT which you can control easily from a Raspberry Pi. In this project we'll squeeze a tiny-yet-playable game of Tetris onto the Scroll pHAT.
Requirements | |||
---|---|---|---|
Raspberry Pi Zero / Zero W You don't need wireless for this project, but it won't hurt. | amazon | ||
ScrollpHAT | amazon | ||
Breadboard & Jumper wires | amazon |
Put your HAT on
To attach your Scroll pHAT to your Pi you first need to solder compatible headers on your Pi and your pHAT.
Add a female header to your ScrollpHAT
There is only one sensible way to do this, with a header on the underside of your ScrollpHAT — if you put it on the top you won't be able to see the LEDs once the Pi is clipped on. If you put a female header on the display (like the image below), you'll need a male one on the Pi.
Standard male header, minimal connections
If you have a standard male header on the Pi, clipping the Scroll pHAT
to the front is not an option — since we also need to be able to access GPIO pins for the buttons. However, the Scroll pHAT actually only needs pins 5V
, GND
, SCL
and SDA
(3, 4, 5, 6) connected to function correctly.
The simplest solution is just wire up these connections manually. You just need a set of female-male jumper leads.
Extra-long male and female header
If you have an extra-long header you have two choices for assembly:
- Add the extra long header to the Scroll pHAT, with the (male) pins pointing down, and the (female) socket up. You can clip the Scroll pHAT to the top of your Pi, and then wire additional connections in through the top of the pHAT.
- Add the extra long header to the Pi, with the (male) pins pointing up through the board. You can clip the Scroll pHAT to the top, and then make additional connections through the bottom of the Pi.
Whatever you choose, make sure you solder the GPIO header onto the correct side of the Pi. If you put it on the wrong way up (like I did) the connections to the Scroll pHAT will be transposed, and it won't work. Thankfully, it won't actually damage anything.
Testing your Scroll pHAT
These Scroll pHAT is an 11x5 array of (incredibly bright) white LEDs.
The communication with the pHAT is handled through a simple serial
interface. For most uses everything you need is provided through
by scrollphat
Python package.
You can install the Python package on your Pi using:
sudo pip3 install scrollphat
Once you've got this installed, you can test out your Scroll pHAT (and wiring) with the following commands:
import scrollphat
scrollphat.set_pixel(0,0)
scrollphat.update()
You should see the top-left pixel light up.
To support developers in [[ countryRegion ]] I give a [[ localizedDiscount[couponCode] ]]% discount on all books and courses.
[[ activeDiscount.description ]] I'm giving a [[ activeDiscount.discount ]]% discount on all books and courses.
The circuit
For a functional Tetris game we need a display for the blocks, and four inputs. One each for (a) move left, (b) move right, (c) rotate and (d) to drop the block. We can use these buttons for other functions such as restarting the game.
Since Scroll pHAT display interface only requires the SCL
and SDA
pins for communication, all other GPIO pins are fair game
for us to connect to. For this project we'll use GPIO26
, GPIO20
, GPIO6
and GPIO12
as our control inputs.
The wiring diagrams for a breadboard are given below. First, showing the wiring for a setup where you use extra-long through board GPIO pins (so can wire onto both sides).
The schematic shows the same circuit, excluding the connections for the Scroll pHAT.
Note that we wire the buttons to connect to ground when pressed. When we initalise these pins later we'll set them to be pulled high, using the Pi's internal pull-up resistors. This will hold these pins at 3.3V until the button is pressed and the circuit is grounded.
We also don't make use of debounce circuitry in this example, since we can use software debouncing via gpiozero
However, you
might want to extend the circuit to include this if you use a different library.
With the circuit set up we can set about writing our controller.
The code
Tetris is a pretty simple game. We have a board of X,Y pixels. We have a set of defined shapes. We have one live shape, and the static blocks which have been placed on the board. The main loop can be summarised as follows —
- If a shape isn't live add a random shape
- Rotate block based on input
- If a shape is live, move it down a line
- If a live shape hits the static blocks, freeze the live shape
- If a complete line is made, clear the line
- If there is no room to add a new shape, game over
The complete code
The complete code is given below and available for download. Individual parts are described in more detail in the following sections.
#!/usr/bin/env python3
from time import sleep
from random import choice, randint
import gpiozero
import scrollphat
scrollphat.set_brightness(5)
BOARD_COLS = 5
BOARD_ROWS = 11
# Define blocks as a list of lists of lists.
BLOCKS = [
# T
[[True, True, True],
[False, True, False]],
# S left
[[False, True, True],
[True, True, False]],
# S right
[[True, True, False],
[False, True, True]],
# L left
[[True, False, False],
[True, True, True]],
# L right
[[False, False, True],
[True, True, True]],
# Line
[[True,True,True,True]],
# Square
[[True, True],
[True, True]]
]
class Tetris(object):
def __init__(self):
"""
Initialize the game and set up Raspberry Pi GPIO interface for buttons.
"""
self.init_game()
self.init_board()
# Initialize the GPIO pins for our buttons.
self.btn_left = gpiozero.Button(26)
self.btn_right = gpiozero.Button(20)
self.btn_rotate = gpiozero.Button(6)
self.btn_drop = gpiozero.Button(12)
# Set up the handlers, to push events into the event queue.
self.btn_left.when_pressed = lambda: self.event_queue.append(self.move_left)
self.btn_right.when_pressed = lambda: self.event_queue.append(self.move_right)
self.btn_rotate.when_pressed = lambda: self.event_queue.append(self.rotate_block)
self.btn_drop.when_pressed = lambda: self.event_queue.append(self.drop_block)
def init_game(self):
"""
Initialize game.
Reset level, block and set initial positions. Unset the game over
flag and clear the event queue.
"""
self.level = 1.0
self.block = None
self.x = 0
self.y = 0
self.game_over = False
self.event_queue = []
def init_board(self):
"""
Initialize and reset the board.
"""
self.board = []
for n in range(BOARD_ROWS):
self.add_row()
def current_block_at(self, x, y):
"""
:param x:
:param y:
:return:
"""
if self.block is None:
return False
# If outside blocks dimensions, return False
if (x < self.x or
y < self.y or
x > self.x + len(self.block[0])-1 or
y > self.y + len(self.block)-1):
return False
# If we're here, we're inside our block
return self.block[y-self.y][x-self.x]
def update_board(self):
"""
Write the current state to the board.
Update the display pixels to match the current
state of the board, including the current active block.
Reverse x,y and flip the x position for the rotation
of the Scroll pHAT (portrait).
"""
scrollphat.set_pixels(lambda y,x: (
self.board[y][BOARD_COLS-x-1] or
self.current_block_at(BOARD_COLS-x-1,y) )
)
scrollphat.update()
def fill_board(self):
"""
Fill up the board for game over.
Set all pixels on from the bottom to the top,
one row at a time.
"""
for y in range(BOARD_ROWS-1,-1,-1):
for x in range(BOARD_COLS):
scrollphat.set_pixel(y,x,True)
scrollphat.update()
sleep(0.1)
def add_row(self):
"""
Add a new row to the top of the board.
"""
self.board.insert(0, [False for n in range(BOARD_COLS)])
def remove_lines(self):
"""
Check board for any full lines and remove remove them.
"""
complete_rows = [n for n, row in enumerate(self.board)
if sum(row) == BOARD_COLS]
for row in complete_rows:
del self.board[row]
self.add_row()
self.level += 0.1
def add_block(self):
"""
Add a new block to the board.
Selects new block at random from those in BLOCKS. Rotates it
a random number of times from 0-3. The block is placed in
the middle of the board, off the top.
The new block is checked for collision: a collision while placing
a block is the signal for game over.
:return: `bool` `True` if placed block collides.
"""
self.block = choice(BLOCKS)
# Rotate the block 0-3 times
for n in range(randint(0,3)):
self.rotate_block()
self.x = BOARD_COLS // 2 - len(self.block[0]) //2
self.y = -len(self.block)
return not self.check_collision(yo=1)
def rotate_block(self):
"""
Rotate the block (clockwise).
Rotated block is checked for collision, if there is
a collision following rotate, we roll it back.
"""
prev_block = self.block
self.block = [[ self.block[y][x]
for y in range(len(self.block)) ]
for x in range(len(self.block[0]) - 1, -1, -1) ]
if self.check_collision():
self.block = prev_block
def check_collision(self, xo=0, yo=0):
"""
Check for collision between the currently active block
and existing blocks on the board (or the
left/right/bottom of the board).
An optional x and y offset is used to check whether a
collision would occur when the block is shifted.
Returns `True` if a collision is found.
:param xo: `int` x-offset to check for collision.
:param yo: `int` y-offset to check for collision.
:return: `bool` `True` if collision found.
"""
if self.block is None:
# We can't collide if there is no block.
return False
if self.y+yo+len(self.block) > BOARD_ROWS:
# If the block is off the end of the board, always collides.
return True
if self.x+xo < 0 or self.x+xo+len(self.block[0]) > BOARD_COLS:
# If the block is off the left or right of the board, it collides.
return True
for y, row in enumerate(self.block):
for x, state in enumerate(row):
if (self.within_bounds(self.x+x+xo,self.y+y+yo) and
self.board[self.y+y+yo][self.x+x+xo] and
state):
return True
def within_bounds(self, x, y):
"""
Check if a particular x and y coordinate is within
the bounds of the board.
:param x: `int` x-coordinate
:param y: `int` y-coordinate
:return: `bool` `True` if within the bounds.
"""
return not( x < 0 or x > BOARD_COLS-1 or y < 0 or y > BOARD_ROWS -1)
def move_left(self):
"""
Move the active block left.
Move left, if the new position of the block does not
collide with the current board.
"""
if not self.check_collision(xo=-1):
self.x -= 1
def move_right(self):
"""
Move the active block right.
Move right, if the new position of the block does not
collide with the current board.
"""
if not self.check_collision(xo=+1):
self.x += 1
def move_down(self):
"""
Move the active block down.
Move left, if the new position of the block collides
with the current board, place the block (add to the board)
and set the block to `None`.
"""
if self.check_collision(yo=+1):
self.place_block()
self.block = None
else:
self.y += 1
def drop_block(self):
"""
Drop the block to the bottom of the board.
Moves the block down as far as it can fall without
hitting a collision.
"""
while self.block:
self.move_down()
def place_block(self):
"""
Transfer the current block to the board.
"""
for y, row in enumerate(self.block):
for x, state in enumerate(row):
if self.within_bounds(self.x+x,self.y+y):
self.board[ self.y + y ][ self.x + x ] |= state
def start(self):
"""
Start the game for the first time. Initialize the board,
game and then start the main loop.
"""
self.init_board()
self.init_game()
self.game()
def end_game(self):
"""
End game state.
Set the game over flag, clear the event queue and fill
the display board.
"""
self.game_over = True
self.event_queue = []
self.fill_board()
def handle_events(self):
"""
Handle events from the event queue.
Events are stored as methods which can be called to
handle the event. Iterate and fire of each event
in the order it was added to the queue.
"""
while self.event_queue:
fn = self.event_queue.pop()
fn()
def game(self):
"""
The main game loop.
Once initialized the game will remain in this loop until
exiting.
"""
while True:
if not self.game_over:
if not self.block:
# No current block, add one.
if self.add_block() == False:
# If we failed to add a block (board full)
# it's game over. Set param and restart the loop.
self.end_game()
continue
self.handle_events()
self.move_down()
self.check_collision()
self.remove_lines()
self.update_board()
else:
# Game over. We sit in here waiting
# for any event in the queue, which
# triggers a restart.
if self.event_queue:
self.init_board()
self.init_game()
# Sleep depending on current level
sleep(1.0/self.level)
if __name__ == '__main__':
tetris = Tetris()
tetris.start()
Constants
We define constants for the board dimensions and blocks. The blocks definitions are stored in a list, which will make its imple to randomly choose the next block.
Each block is defined as a list
of list
2-dimensionsal matrix. Filled positions are marked as True
, empty spaces as False
— this will become important later for
collision detection.
BOARD_COLS = 5
BOARD_ROWS = 11
# Define blocks as a list of lists of lists.
BLOCKS = [
# T
[[True, True, True],
[False, True, False]],
# S left
[[False, True, True],
[True, True, False]],
# S right
[[True, True, False],
[False, True, True]],
# L left
[[True, False, False],
[True, True, True]],
# L right
[[False, False, True],
[True, True, True]],
# Line
[[True,True,True,True]],
# Square
[[True, True],
[True, True]]
]
The board
The simplest way to represent the Tetris board in Python, without any
dependencies is to again use a list
of list
2D matrix structure.
Since we need to be able to work with lines as an entity (for removal) in Tetris, it makes sense to arrange the list
of lists
with the y
axis at the top level. This allows us to remove a line in a single operation.
def init_board(self):
"""
Initialize and reset the board.
"""
self.board = []
for n in range(BOARD_ROWS):
self.add_row()
def add_row(self):
"""
Add a new row to the top of the board.
"""
self.board.insert(0, [False for n in range(BOARD_COLS)])
GPIO setup
We're using the gpiozero
library which makes setting up GPIO pins
incredibly simple. To define a GPIO pin as a button, use gpiozero.Button
passing in the GPIO pin number. The gpiozero
library enables a pull-up resistor on button-pins by default.
self.btn_left = gpiozero.Button(26)
self.btn_right = gpiozero.Button(20)
self.btn_rotate = gpiozero.Button(13)
self.btn_drop = gpiozero.Button(12)
Event handling
We don't want to block execution of the game while we wait on inputs, so we bind input signals which will be triggered when an input is registered. However, we still want to take inputs as they occur, and handle them in order. To achieve this we respond to input signals by adding input events to a event queue.
We have a small number of events to deal with, and they are all handled the same way. So to keep things simple we can simply queue up the handler methods themselves.
# Set up the handlers, to push events into the event queue.
self.btn_left.when_pressed = lambda: self.event_queue.append(self.move_left)
self.btn_right.when_pressed = lambda: self.event_queue.append(self.move_right)
self.btn_rotate.when_pressed = lambda: self.event_queue.append(self.rotate_block)
self.btn_drop.when_pressed = lambda: self.event_queue.append(self.drop_block)
Note that we wrap the push inside a lambda
. There is nothing special about this. It's just a compact way to postpone the push until the
when_pressed
trigger is fired.
The actual handling of events is postponed to the handle_events()
function, ensuring they happen at a defined point in the main loop.
Inside handle_events
the methods we queued are pop
-ed off the event queue in turn and called.
def handle_events(self):
"""
Handle events from the event queue.
Events are stored as methods which can be called to
handle the event. Iterate and fire of each event
in the order it was added to the queue.
"""
while self.event_queue:
fn = self.event_queue.pop()
fn()
The Python GIL prevents new events being added while we're popping them off, so we won't get stuck in here if you mash the button.
Movement
The left/right handlers function the same way. When triggered the new position is checked for collision. If none is found the x position is incremented or decremented accordingly.
def move_left(self):
"""
Move the active block left.
Move left, if the new position of the block does not
collide with the current board.
"""
if not self.check_collision(xo=-1):
self.x -= 1
def move_down(self):
"""
Move the active block down.
Move left, if the new position of the block collides
with the current board, place the block (add to the board)
and set the block to `None`.
"""
if self.check_collision(yo=+1):
self.place_block()
self.block = None
else:
self.y += 1
The user action to drop the block makes use of this same move_down
method. Since move_down
places the block if it hits a collision, we can simply loop repeatedly, calling move_down
until the active
block is placed and cleared (after which self.block = None
).
def drop_block(self):
"""
Drop the block to the bottom of the board.
Moves the block down as far as it can fall without
hitting a collision.
"""
while self.block:
self.move_down()
Rotation
The clockwise rotation of the currently active block is performed by
rotating the 2D matrix we have stored in self.block
. The rotation
is performed by transposing the y
and the inverted x
axes.
The diagram below shows the steps taken, with the green and red
arrows showing the resulting positions of the original x
and y
axes at each step.
def rotate_block(self):
"""
Rotate the block (clockwise).
Rotated block is checked for collision, if there is
a collision following rotate, we roll it back.
"""
prev_block = self.block
self.block = [[ self.block[y][x]
for y in range(len(self.block)) ]
for x in range(len(self.block[0]) - 1, -1, -1) ]
if self.check_collision():
self.block = prev_block
It would also be nice the the block was centered as far as possible while rotating — this is particular noticeable on the 4-squares-in-a-line blocks.
Collision detection
In our Tetris game collision detections is fairly simplistic, since the active block can only move a whole block at a time.
There are a number of standard conditions where a collision is pre-determined:
- If there is not a block active, return
False
- If the block is off the bottom of the board, return
True
- If the block is off the left/right, return
True
If none of these predetermined checks pass, we next check the collision of the block against the current board.
Since are storing the current board state as a boolean 2D matrix,
with filled spaces set True
. Our active blocks are similarly stored as 2D matrices with filled spaces set True
, so we check for
a collision by checking whether board and block
are set for the given position.
The optional offset xo
and yo
parameters allow us to test a collision with the current block before a move is made.
def check_collision(self, xo=0, yo=0):
"""
Check for collision between the currently active block
and existing blocks on the board (or the
left/right/bottom of the board).
An optional x and y offset is used to check whether a
collision would occur when the block is shifted.
Returns `True` if a collision is found.
:param xo: `int` x-offset to check for collision.
:param yo: `int` y-offset to check for collision.
:return: `bool` `True` if collision found.
"""
if self.block is None:
# We can't collide if there is no block.
return False
if self.y+yo+len(self.block) > BOARD_ROWS:
# If the block is off the end of the board, always collides.
return True
if self.x < 0 or self.x+xo+len(self.block[0]) > BOARD_COLS:
# If the block is off the left or right of the board, it collides.
return True
for y, row in enumerate(self.block):
for x, state in enumerate(row):
if (self.within_bounds(self.x+x+xo,self.y+y+yo) and
self.board[self.y+y+yo][self.x+x+xo] and
state):
return True
Clearing complete lines
To remove complete lines from the board we iterate over the board, checking whether
sum(row) == BOARD_COLS
— making use of the fact that in Python True == 1
. The sum
of a complete row will equal the total BOARD_COLS
. We can't remove these rows from
the board as we iterate over it (well, we could be iterating over a range
but it's ugly) so we store them to remove on a second pass.
Removing rows by del
deletes row at the given index entirely from the board list
.
We immediately add another row to the top of the board (at index 0
) so the indices
are not affected.
def remove_lines(self):
"""
Check board for any full lines and remove remove them.
"""
complete_rows = [n for n, row in enumerate(self.board)
if sum(row) == BOARD_COLS]
for row in complete_rows:
del self.board[row]
self.add_row()
self.level += 0.1
Adding blocks
When no block is active, a new block must be added to the board. The block is
selected at random from BLOCKS
using choice
, then rotated 0-3 times (4 would
be back to where it started). The block is then placed in the middle of the board,
just off the top (at minus block-height pixels).
Finally, a pre-emptive check is made to see if the first move of the block will
result in a collision. If this is the case, add_block
will return False
to
indicate a failure to add a new live block.
def add_block(self):
"""
Add a new block to the board.
Selects new block at random from those in BLOCKS. Rotates it
a random number of times from 0-3. The block is placed in
the middle of the board, off the top.
The new block is checked for collision: a collision while placing
a block is the signal for game over.
:return: `bool` `True` if placed block collides.
"""
self.block = choice(BLOCKS)
# Rotate the block 0-3 times
for n in range(randint(0,3)):
self.rotate_block()
self.x = BOARD_COLS // 2 - len(self.block[0]) //2
self.y = -len(self.block)
return not self.check_collision(yo=1)
The main loop and end game
The control functions already described are triggered from a single main loop,
which is started up when the game is loaded. Execution continues within this
loop until you exit with Ctrl-C
— even the Game Over state is handled here.
Each loop we test to see whether the game is over. If not, we optionally add a new block if needed. If this fails, we trigger the game over state. If not, or a block is already active, we proceed with normal play. Each play tick we handle user input events, move the block down, check for collisions, remove complete lines, then update the board. This continues until the game over state is reached — there is no win game state, just increasing difficulty.
In the game over state we simply wait for any input (anything in the event queue) and, if present, trigger the re-init of the game board.
The sleep
at the end of the main loop controls speed of movement, based on the
self.level
variable which increments as complete lines are removed from the board.
This isn't a very nice way to control game speed, because the delay is independent
of how long the main loop takes to execute. You could try improve this by using a
wait_til
function instead and calculating the next tick each loop.
def game(self):
"""
The main game loop.
Once initialized the game will remain in this loop until
exiting.
"""
while True:
if not self.game_over:
if not self.block:
# No current block, add one.
if self.add_block() == False:
# If we failed to add a block (board full)
# it's game over. Set param and restart the loop.
self.end_game()
continue
self.handle_events()
self.move_down()
self.check_collision()
self.remove_lines()
self.update_board()
else:
# Game over. We sit in here waiting
# for any event in the queue, which
# triggers a restart.
if self.event_queue:
self.init_board()
self.init_game()
# Sleep depending on current level
sleep(1/self.level)
What next?
This is a very simple implementation of Tetris, with plenty of room for improvement. Some things you could try and implement are —
- Add a quick flash animation for clearing lines. How can you do this without blocking the main loop?
- Rotate counter-clockwise
- Add a tick-based main loop for more consistent speed (especially on higher levels)
- Rig up a mini speaker and get your Pi playing the Tetris tune
- Extend the code to work on the Scroll pHAT HD. The API is mostly the same, and also gets you brightness control over individual pixels. — I'll be doing this one, once mine arrives in the post!