> ## Content Index
> Fetch the complete content index at: https://mfitzp.ghost.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Scroll pHAT Tetris
- URL: https://mfitzp.ghost.io/making-tetris-on-a-scrollphat/
- Published: 2017-05-05T00:00:00.000Z
- Updated: 2026-09-09T12:26:13.000Z
- Author: Martin Fitzpatrick
- Tags: Electronics, Raspberry Pi, #Import 2026-08-24 14:26

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.

0:00 

/1:25 

1× 

| Requirements                                                                            |  |
| --------------------------------------------------------------------------------------- |  |
| Raspberry Pi Zero / Zero W You don't need wireless for this project, but it won't hurt. |  |
| ScrollpHAT                                                                              |  |
| Breadboard & Jumper wires                                                               |  |

## 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.](https://storage.ghost.io/c/50/3e/503e92e5-9187-4058-a6fd-d37b3deb22ec/content/images/2026/09/scrollphat.jpg)

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

## 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](https://blog.martinfitzpatrick.com/static/images/circuits/tetris-breadboard.png)

The schematic shows the same circuit, excluding the connections for the ScrollpHAT.

![](https://storage.ghost.io/c/50/3e/503e92e5-9187-4058-a6fd-d37b3deb22ec/content/images/2026/09/tetris-schematic.png)

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](https://downloads.martinfitzpatrick.com/scrollphat-tetris.py?ref=mfitzp.ghost.io). Individual parts are described in more detail below.

```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](https://blog.martinfitzpatrick.com/static/images/builds/scrollphat-tetris-rotate.png)

```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's 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!