projects raspberry-pi

Scroll pHAT Tetris

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.

ScrollpHAT 5x11 matrix of white LEDs. Mount a female header on the underside.

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:

  1. 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.
  2. 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:

bash
sudo pip3 install scrollphat

Once you've got this installed, you can test out your Scroll pHAT (and wiring) with the following commands:

python
import scrollphat
scrollphat.set_pixel(0,0)
scrollphat.update()

You should see the top-left pixel light up.

Tiny games for your BBC micro:bit.

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

Tetris breadboard

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 —

  1. If a shape isn't live add a random shape
  2. Rotate block based on input
  3. If a shape is live, move it down a line
  4. If a live shape hits the static blocks, freeze the live shape
  5. If a complete line is made, clear the line
  6. 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.

python
#!/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.

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

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

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

python
# 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.

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

python
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).

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

Rotation

python
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:

  1. If there is not a block active, return False
  2. If the block is off the bottom of the board, return True
  3. 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.

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

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

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

python
    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 —

  1. Add a quick flash animation for clearing lines. How can you do this without blocking the main loop?
  2. Rotate counter-clockwise
  3. Add a tick-based main loop for more consistent speed (especially on higher levels)
  4. Rig up a mini speaker and get your Pi playing the Tetris tune
  5. 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!