Tiny Tetris for Microbit

Tetris is one of those addictive games that seems to have been ported to nearly every hardware platform. I haven't been able to find a version for the microbit so thought I would give it a go.

Now the first thing you notice about the microbit is the 5x5 led screen which stops us having different shape pieces which you might argue is one of the fundamental hallmarks of tetris as a game. Let's simplify the game and just use pixels for blocks so there is no rotation of blocks or complex fitting of blocks together. Hopefully there will be enough of a game left to make this worthwile.

microbit tetris

First, we need two classes, one to model the current falling block and one to model the "dead" blocks lying on the floor, waiting to be cleared away when we fill up a row. We also need a game loop that can show the board and the block.

First

So here, our first attempt with an empty board and a single block floating at the start position.


# Tiny Tetris clone for Microbit

from microbit import *

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = 0
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)


# Start Game

block = Block()
board = Board()

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

I have given the "live" and "dead" blocks different LED intensities since we can't have different colours.

Drop

Next, we should be able to let the block drop from the top of the screen to the bottom. To be able to animate the falling block, we need to be able to erase the block at its current position, update the y value, the redraw at the new position. We also need to be able to check that there is space below the current position for the block to drop. We ask the board if the pixel below us is empty. We also want to be sure we haven't dropped off the bottom of the screen.


# Tiny Tetris clone for Microbit

from microbit import *


# board dimensions
top_row = 0
bottom_row = 4

left_column = 0
right_column = 4

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = top_row
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)

    def hide(self, screen):
        screen.set_pixel(self.x, self.y, off_pixel_intensity)

    def can_drop(self, board):
        if self.y >= bottom_row:
            return False

        return board.has_block_at(self.x, self.y + 1)

    def drop(self):
        self.y += 1


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)

    def has_block_at(self, x, y):
        pixel = self.bitmap.get_pixel(x, y)
        return pixel == off_pixel_intensity


# Start Game

block = Block()
board = Board()

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

    block.hide(display)

    if block.can_drop(board):
        block.drop()

Buttons

So our block drops but then just sits there at the bottom of the screen.

Perhaps the next thing to address is moving the block left and right to be able to fill up a whole row of pixels. Again we have to be careful we don't accidentally drift off the sides of the screen by checking before we change the x value of the block. Once a block can't move any further down the screen, either because it has hit the bottom or landed on another block, we need to convert that "live" block to a "dead" one, transfer ownership to the board and create a new block back up at the top of the screen.


# Tiny Tetris clone for Microbit

from microbit import *


# board dimensions
top_row = 0
bottom_row = 4

left_column = 0
right_column = 4

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = top_row
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)

    def hide(self, screen):
        screen.set_pixel(self.x, self.y, off_pixel_intensity)

    def can_drop(self, board):
        if self.y >= bottom_row:
            return False

        return board.has_block_at(self.x, self.y + 1)

    def drop(self):
        self.y += 1

    def move_left(self):
        if self.x > left_column:
            self.x -= 1

    def move_right(self):
        if self.x < right_column:
            self.x += 1


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)

    def has_block_at(self, x, y):
        pixel = self.bitmap.get_pixel(x, y)
        return pixel == off_pixel_intensity

    def accept_block(self, block):
        self.bitmap.set_pixel(block.x, block.y, self.intensity)


# Start Game

block = Block()
board = Board()

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

    block.hide(display)

    if (button_a.was_pressed()):
        block.move_left()
    elif (button_b.was_pressed()):
        block.move_right()

    if block.can_drop(board):
        block.drop()
    else:
        board.accept_block(block)
        block = Block()

Clearing

Another characteristic of Tetris we would like to preserve is scoring points when you fill up a complete row of pixels. To know if we should clear a row, we need to make sure that all pixels in that row are non-zero, any zeroes tell us there is a hole and we shouldn't remove it. If we do find a full row, we remove it and add to the score for this game.


# Tiny Tetris clone for Microbit

from microbit import *


# board dimensions
top_row = 0
bottom_row = 4

left_column = 0
right_column = 4

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = top_row
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)

    def hide(self, screen):
        screen.set_pixel(self.x, self.y, off_pixel_intensity)

    def can_drop(self, board):
        if self.y >= bottom_row:
            return False

        return board.has_block_at(self.x, self.y + 1)

    def drop(self):
        self.y += 1

    def move_left(self):
        if self.x > left_column:
            self.x -= 1

    def move_right(self):
        if self.x < right_column:
            self.x += 1


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)

    def has_block_at(self, x, y):
        pixel = self.bitmap.get_pixel(x, y)
        return pixel == off_pixel_intensity

    def accept_block(self, block):
        self.bitmap.set_pixel(block.x, block.y, self.intensity)

    def is_row_filled(self, row):
        for column in range(left_column, right_column + 1):
            pixel = self.bitmap.get_pixel(column, row)
            if pixel != self.intensity:
                return False

        return True

    def clear_row(self, row):
        for column in range(left_column, right_column + 1):
            self.bitmap.set_pixel(column, row, off_pixel_intensity)

    def clear_rows(self, screen):
        rows_cleared = 0

        # go from bottom to top
        for row in range(bottom_row, top_row - 1, -1):
            if self.is_row_filled(row):
                self.clear_row(row)
                self.draw(screen)
                sleep(Frame_Rate_In_Milliseconds)
                rows_cleared += 1
                
        return rows_cleared


# Start Game

block = Block()
board = Board()

score = 0

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

    block.hide(display)

    if (button_a.was_pressed()):
        block.move_left()
    elif (button_b.was_pressed()):
        block.move_right()

    if block.can_drop(board):
        block.drop()
    else:
        board.accept_block(block)
        score += board.clear_rows()

        block = Block()

Collapse

Notice that the incomplete rows don't "fall" down into the newly emptied rows so we need to tackle that next.


# Tiny Tetris clone for Microbit

from microbit import *


# board dimensions
top_row = 0
bottom_row = 4

left_column = 0
right_column = 4

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = top_row
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)

    def hide(self, screen):
        screen.set_pixel(self.x, self.y, off_pixel_intensity)

    def can_drop(self, board):
        if self.y >= bottom_row:
            return False

        return board.has_block_at(self.x, self.y + 1)

    def drop(self):
        self.y += 1

    def move_left(self):
        if self.x > left_column:
            self.x -= 1

    def move_right(self):
        if self.x < right_column:
            self.x += 1


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)

    def has_block_at(self, x, y):
        pixel = self.bitmap.get_pixel(x, y)
        return pixel == off_pixel_intensity

    def accept_block(self, block):
        self.bitmap.set_pixel(block.x, block.y, self.intensity)

    def is_row_filled(self, row):
        for column in range(left_column, right_column + 1):
            pixel = self.bitmap.get_pixel(column, row)
            if pixel != self.intensity:
                return False

        return True

    def clear_row(self, row):
        # flash the row first ?
        # clear it
        for column in range(left_column, right_column + 1):
            self.bitmap.set_pixel(column, row, off_pixel_intensity)

    def collapse_rows_above(self, row):
        for r in range(row - 1, top_row - 1, -1):
            for column in range(left_column, right_column + 1):
                pixel = self.bitmap.get_pixel(column, r)
                self.bitmap.set_pixel(column, r + 1, pixel)

    def clear_rows(self, screen):
        rows_cleared = 0

        # go from bottom to top
        for row in range(bottom_row, top_row - 1, -1):
            if self.is_row_filled(row):
                self.clear_row(row)
                self.draw(screen)
                sleep(Frame_Rate_In_Milliseconds)
                self.collapse_rows_above(row)
                rows_cleared += 1

        return rows_cleared


# Start Game

block = Block()
board = Board()

score = 0

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

    block.hide(display)

    if (button_a.was_pressed()):
        block.move_left()
    elif (button_b.was_pressed()):
        block.move_right()

    if block.can_drop(board):
        block.drop()
    else:
        board.accept_block(block)
        score += board.clear_rows(display)

        block = Block()

Finished

Finally, we need a way to get out of the game and show the score at the end. Here is the finished code:


# Tiny Tetris clone for Microbit

from microbit import *


# board dimensions
top_row = 0
bottom_row = 4

left_column = 0
right_column = 4

max_pixel_intensity = 9
mid_pixel_intensity = 5
off_pixel_intensity = 0

Frame_Rate_In_Milliseconds = 500


class Block:

    def __init__(self):
        self.x = 2
        self.y = top_row
        self.intensity = max_pixel_intensity

    def draw(self, screen):
        screen.set_pixel(self.x, self.y, self.intensity)

    def hide(self, screen):
        screen.set_pixel(self.x, self.y, off_pixel_intensity)

    def can_drop(self, board):
        if self.y >= bottom_row:
            return False

        return board.has_block_at(self.x, self.y + 1)

    def drop(self):
        self.y += 1

    def move_left(self):
        if self.x > left_column:
            self.x -= 1

    def move_right(self):
        if self.x < right_column:
            self.x += 1


class Board:

    def __init__(self):
        self.bitmap = Image("00000:" * 5)
        self.intensity = mid_pixel_intensity

    def draw(self, screen):
        screen.show(self.bitmap)

    def has_block_at(self, x, y):
        pixel = self.bitmap.get_pixel(x, y)
        return pixel == off_pixel_intensity

    def accept_block(self, block):
        self.bitmap.set_pixel(block.x, block.y, self.intensity)

    def is_row_filled(self, row):
        for column in range(left_column, right_column + 1):
            pixel = self.bitmap.get_pixel(column, row)
            if pixel != self.intensity:
                return False

        return True

    def clear_row(self, row):
        # flash the row first ?
        # clear it
        for column in range(left_column, right_column + 1):
            self.bitmap.set_pixel(column, row, off_pixel_intensity)

    def collapse_rows_above(self, row):
        for r in range(row - 1, top_row - 1, -1):
            for column in range(left_column, right_column + 1):
                pixel = self.bitmap.get_pixel(column, r)
                self.bitmap.set_pixel(column, r + 1, pixel)

    def clear_rows(self, screen):
        rows_cleared = 0

        # go from bottom to top
        for row in range(bottom_row, top_row - 1, -1):
            if self.is_row_filled(row):
                self.clear_row(row)
                self.draw(screen)
                sleep(Frame_Rate_In_Milliseconds)
                self.collapse_rows_above(row)
                self.draw(screen)
                sleep(Frame_Rate_In_Milliseconds)
                rows_cleared += 1

        return rows_cleared


# Start Game
display.scroll("Tiny Tetris") 


block = Block()
board = Board()

score = 0

# Game loop
while True:

    board.draw(display)
    block.draw(display)

    sleep(Frame_Rate_In_Milliseconds)

    block.hide(display)

    if (button_a.was_pressed()):
        block.move_left()
    elif (button_b.was_pressed()):
        block.move_right()

    if block.can_drop(board):
        block.drop()
    else:
        if block.y == top_row:
            break

        board.accept_block(block)
        score += board.clear_rows(display)

        block = Block()

display.scroll("Game Over!") 
display.scroll("You scored ")       
display.scroll(str(score)) 

Improvements

I have given some thought to creating a couple of different shape blocks to make it a little more challenging - a 2x2 block or a 2x1 plank say - but finding a method to trigger rotation is the problem with only two buttons. Perhaps building a set of controls wired to the digital IO would be a solution.