Micro:bit OLED Game

by SomeNewKid in Circuits > Microcontrollers

5037 Views, 8 Favorites, 0 Comments

Micro:bit OLED Game

Hero-04.jpg

There was a time when every kid wanted a Nintendo Game & Watch handheld electronic game. This combined a very simple LCD screen with a very simple game mechanic. An early example was the Fire game, where you moved the firemen’s safety net left or right to bounce residents from a burning building on the left into a waiting ambulance on the right.

Let's add an OLED screen to the BBC Micro:bit to create an homage to these games. We'll need to use a breakout board and a breadboard to connect the Micro:bit to a 128x64 OLED display, and we'll need to write the game in Python. We’ll call the game Post, and bounce letters from left to right using a paddle.

Supplies

Supplies-01.jpg

Because we're using an OLED screen, we'll need to create the real thing rather than use Tinkercad. We'll need at least the following:

If you'd like to go beyond a breadboard prototype, and make a real handheld game, you'll also need the following:

Getting Started With the Mu Editor

Mu-01.png

The Mu editor is the go-to editor for the Micro:bit when you’re ready to use Python (it’s actually MicroPython on the Micro:bit). After installing Mu, open the editor, and then connect a Micro:bit to your computer. At the bottom-left of the Mu editor’s window, in its status bar, you should see confirmation that it “detected new BBC micro:bit device”.

Next we want to ensure that our Python code can be flashed onto the Micro:bit. In the Mu editor, click the New button to create a new Python file, and type the following simple code:

from microbit import *

display.scroll("Hello, World!")

Click on the Flash button in the Mu editor. The yellow LED on the Micro:bit should start flashing to indicate that a file operation is occurring. When it finishes, your Micro:bit should scroll “Hello, World!” on its 5x5 LED display.

Installing the OLED Python Libraries

Mu-02.png

To have the Micro:bit send instructions to the OLED display, we need to install some Python libraries on our Micro:bit. The Mu editor makes this really easy.

First, find your home directory for Mu.

Second, copy all the Python files (all files with the .py extension) from fizban’s Github repository, and place the copies in your Mu home directory.

In the Mu editor, click on the Files button. This will open two panes at the bottom of the editor. On the left are the files on the Micro:bit—you might have one or more, depending on what you’ve done previously with your Micro:bit. On the right are the .py files in your home directory. You should see the ssd1306 files which you’ve copied from fizban’s repository.

Next, simply drag and drop the following three files from the right pane (your home directory) to the left pane (the Micro:bit), which installs these libraries on the Micro:bit.

  • ssd1306.py
  • ssd1306_px.py
  • ssd1306_text.py

For this project, we only need those three files. Yet keep them all available in your home directory, as you can use them for other projects using the OLED display.

Click the Files button again to remove the bottom panes, and to make the Flash button available again.

To learn whether everything is still working okay, we can update our Hello program to import functions from these Python libraries, even if we’re not yet using the functions.

We should update the message to check that it’s our updated program running on the Micro:bit.

from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import *

display.scroll("Import Successful!")

Flash this program onto the Micro:bit. If “Import Successful!” scrolls across the 5x5 LED screen, we’re okay to proceed to the next step.

Attaching the Breakout Board

Breakout-01.jpg

At the bottom of the Micro:bit are 25 pins on its edge connector (the gold strip). Five of these are large enough to use with alligator clips or banana plugs. To make use of the other 20 pins, we need to insert the Micro:bit into a “breakout board”. For our OLED project, we need a breakout board which provides access to pins 19 and 20, as these are the pins which implement the I²C communication protocol.

We can use a simple Python program to test whether the breakout board is working. Here’s the code, which merely displays whether button A or button B has been pressed on the Micro:bit.

from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import *

while True:
    display.show('.')
    if button_a.was_pressed():
        display.show('A')
        sleep(500)
    elif button_b.was_pressed():
        display.show('B')
        sleep(500)

Flash this program to the Micro:bit, before connecting it to the breakout board. Confirm that pressing button A shows “A” on the 5x5 LED display, and pressing button B shows “B” on the LED display.

Now we insert our Micro:bit into the breakout board. Depending on the board, an LED may light to show that it’s both connected and drawing power from the Micro:bit.

If we look at a schematic of the Micro:bit pins, we can see that pin 5 is shared with button A on the Micro:bit, and that pin 11 is shared with button B. To test that the breakout board is working, insert one jumper wire into pin 5, one jumper wire into pin 11, and one jumper wire into GND. If you now touch the pin 5 and GND wires, you will see “A” show on the LED display, because pin 5 has been "pulled down to GND”, which is the same as pressing button A. Touching the pin 11 and GND wires will show “B” on the LED display. This proves that the Micro:bit and the breakout board are properly connected.

Attaching the Breadboard

Breadboard-01.jpg

While this project could be completed without a breadboard, let’s make our lives easier by using one. If your breakout board (previous step) does not include a breadboard, simply place a separate breadboard in front of the breakout board.

If you’ve never before used a breadboard, you may wish to read how to use breadboards. To test your understanding, look at the three wires we connected to the breakout board in the previous step. Can you connect the other ends of the wires to the breadboard so that the Micro:bit display indicates that button A was pressed? And then change the wires on the breadboard so that the Micro:bit indicates that button B was pressed?

Powering the Breadboard

Battery-01.jpg

For most Micro:bit projects, power is provided either by the USB connection to a computer, or alternatively by connecting a 3V battery to the JST connector.

For a project where the Micro:bit is working with external components, the breakout board may not provide enough 3V and GND connections to power all external components. We can solve this by connecting a 3V pin and a ground (GND) pin to the power rails of the breadboard. This is simple to do:

  • Use a jumper wire to connect 3V on the breakout board to the + power rail on the breadboard; and
  • Use a jumper wire to connect GND on the breakout board to the – power rail on the breadboard.

Warning: if you’re experimenting, don’t ever connect 3V directly to GND, even through breakout boards and breadboards. There must always be current-using components between 3V and GND. If there’s not and you connect 3V directly to GND, you may short the circuit and risk damaging your Micro:bit.

Don’t let this warning deter you. If you’re new to electronics, just don’t don't experiment with power connections. For everything else in this tutorial, experiment as much as you like—the worst that would happen is things stop working until you fix them.

Connecting the OLED

OLED-01.jpg

Now we have a breakout board and a breadboard to allow us to connect electronic components to the Micro:bit, let’s connect our OLED display.

First, place the OLED display on the breadboard so that each of its pins is connected to a separate numbered row. For example, the GND pin is connected to row 10, while the adjacent VCC pin is connected to row 11, and so on. If the OLED’s pins are all in the same numbered row, the OLED is inserted incorrectly and needs to be turned through 90 degrees.

We then need to use our jumper wires to connect each OLED pin to specific Micro:bit pins on its breakout board:

  • OLED GND to Micro:bit GND
  • OLED VCC to Micro:bit 3V
  • OLED SCL to Micro:bit pin 19
  • OLED SDA to Micro:bit pin 20

We can flash the following Python program onto the Micro:bit. If all components and all connections are working, we will see “Hello!” on the OLED display.

from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import *

initialize()
clear_oled()
add_text(0, 0, "Hello!")

Next we can write a little program to test displaying pixels one by one. While the OLED has a display resolution of 128 pixels by 64 pixels, each pixel is tiny, so the ssd1306 Python library uses four pixels each time it’s instructed to draw a “pixel” on the display, meaning we only have a 64x32 grid with which to work. This is why our game has such rudimentary graphics. (Still, much better than the 5x5 LED we have with the Micro:bit on its own.)

from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import *

initialize()
clear_oled()
for y in range(31):
    for x in range(63):
        set_px(x, y, 1, 0)
        draw_screen()

Connecting the Buttons

Buttons-01.jpg

If you were unable to obtain small push buttons suitable for use on a breadboard, that’s okay, you can skip this step and just use the buttons on the Micro:bit. Yet if small buttons are available, they’ll make the game more natural to play because we can place the buttons either side of the OLED display.

Position each button on the breadboard so that two legs are below the middle divider, and two legs are above the middle divider. The legs below the middle divider have no electrical purpose—they’re just so that the button stands neatly on the board. The legs above the middle divider have an electrical purpose, and we wire them up as follows:

  • For the left button, connect one leg to pin 5, and the other leg to GND;
  • For the right button, connect one leg to pin 11, and the other leg to GND.

If the left button is pressed, it closes the connection between pin 5 and GND, which signals to the Micro:bit that button A has been pressed. If the right button is pressed, it closes the connection between pin 11 and GND, which signals to the Micro:bit that button B has been pressed.

We can use the following code to write text to the OLED display, indicating whether the external left or right button was pressed.

from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import *

initialize()

while True:
    clear_oled()
    add_text(0, 0, "...")
    if button_a.was_pressed():
        clear_oled()
        add_text(0, 0, "LEFT button")
        add_text(0, 1, "was pressed")
    elif button_b.was_pressed():
        clear_oled()
        add_text(0, 0, "RIGHT button")
        add_text(0, 1, "was pressed")
    sleep(1000)

Planning the Game

Game-01.jpg

The original Nintendo Game & Watch handhelds used LCD screens, where character positions were defined in advance. We can take a similar approach with our Post game, where each letter follows the same “bounces”—using the variable trajectory to define an array of [x,y] co-ordinates forming these bounces.

We also want up to three letters to be on the screen at any time, so we use the variable letters to define an array comprising three integers. A value of -1 means the letter is not yet visible, a value of 0 means the letter is at the first x,y position of the trajectory, a value of 1 means the letter is at the second x,y position of the trajectory, and so on. If the player misses a letter and it falls to the ground, the value will be increased by 100. So a value of 108 would indicate the player missed the letter at position 8 in the trajectory, and we know to show a “splat” graphic.

At each game turn, if a letter is currently “not in play” because its value is -1, we decide whether to “launch” the letter. We first use simple logic to determine if it’s viable to launch a letter right now:

  • Are the other two letters either not in play themselves, or at least 8 steps away in the trajectory? (If fewer than 8 steps, the two letters will overlap on the screen.)
  • If another letter is at least 8 steps away, is not at the top of the trajectory? (If letter #1 is at the top of the second bounce, we don’t want to launch letter #2 at the top of the first bounce, because both letters would reach the bottom of the bounces at the same time, and the player would have no chance of catching both letters.)

If it’s viable to launch a letter now, we then pick a random number between 1 and, to start with, 40. If the randomly-chosen number is 2, we launch the letter. The range starts reducing from 40 so that, soon the chance of launching the letter is 1/30, then 1/20, then 1/10, and finally 1/3. In this way, the game starts getting more difficult, as it becomes more likely that the player must manage two or three letters on screen at the same time.

We also start decreasing the delay between game loops, so that the game starts getting faster.

Finally, if the player misses three letters, the game is over.

Those are the basic mechanics of the game. All of the code, except the functions related to graphics, discussed next, is simply the implementation of the above game mechanics.

Planning the Graphics

Sprite-01.png

Originally, it was intended for the game to have much richer graphics. Whatever object would be bounced from left to right across the screen would change shape at each point in the trajectory—just like the original Nintendo Game & Watch games.

In anticipation of needing many different sprites, a short-hand way of describing each sprite was sought. Since we need 1s and 0s to specify whether each pixel should be on or off, we can use a hexadecimal value to denote four binary values. Hopefully the graphic explains how this works.

In the end, the game code started getting a bit too long for an Instructables article, so only a single sprite was defined this way. Nonetheless, this explains why a single string of hexidecimal values was used to define the letter sprite. Also, the hex_to_bits function is available if you want to change the graphics in this game, or take a similar approach to sprites in a different game.

The Game Code

Game-02.jpg

Here is the complete game code:

import math
import music
import random
from ssd1306 import initialize, clear_oled, draw_screen
from ssd1306_px import set_px
from ssd1306_text import add_text
from microbit import reset, button_a, button_b, display, sleep, i2c, Image

version = 0     # set to positive to show on title screen
limitX = 63     # the last visible pixel location at the right
limitY = 31     # the last visible pixel location at the bottom
letterW = 12    # the width of the letter sprite
letterH = 8     # the height of the letter sprite
delay = 150     # the delay after each play loop ... slowly decreases
min_delay = 40  # the minimum delay after each play loop
paddle = 2      # the position of the paddle, either 0, 1, or 2
fails = 0       # the number of times the letter fell to the floor
score = 0       # the number of times the paddle hit a letter
chance = 40     # the 'range' of chances to launch a letter
traj = []       # the list of trajectory positions
bottoms = []    # the index of each bottom-of-trajectory positions
letter = ""     # will hold the binary values for the letter sprite
lett_pos = []   # the list of positions of the three letters
titleI = 0      # Y position index of bouncing letter on title screen

# Expands each hex value into a single array of bits.
# Example: expand "909" into array of 100100001001
def hex_to_bits(definition):
    bits = []
    for line in definition:
        for hex in line:
            for char in '{:0>{w}}'.format(bin(int(hex, 16))[2:], w=4):
                bit = 1 if char == '1' else 0
                bits.append( bit )
    return bits

# Shows or hides the letter sprite.
def update_sprite(sprite, x, y, width, height, visible):
    for offy in range(height):
        for offx in range(width):
            if sprite[offy*letterW + offx] == 1:
                if x+offx <= limitX and y+offy <= limitY:
                    set_px(x+offx, y+offy, visible, 0)

# Shows or hides the paddle.
def update_paddle(index, visible):
    x = [ 8, 28, 48 ][index]
    y = limitY - 6
    for offx in range(letterW+4):
        set_px(x + offx, y, visible, 0)
        set_px(x + offx, y+1, visible, 0)

# Builds the trajectory of bounces, and records bottom positions
# (where the player's paddle will need to be).
def build_traj():
    global traj, bottoms, traj_count
    append_bounce(-4)
    bottoms.append(len(traj)-1)
    append_bounce(6)
    bottoms.append(len(traj)-1)
    append_bounce(16)
    bottoms.append(len(traj)-1)
    append_bounce(26)
    traj_count = len(traj)

# Adds an up and down arc to the trajectory.
def append_bounce(x):
    if x >= 0:
        traj.extend( [ [x, 10], [x+1, 6], [x+2, 2] ] )
    traj.extend( [ [x+4, 0], [x+6, 2], [x+7, 6], [x+8, 10], [x+9, 16] ] )

# To launch a new letter, the following must be true:
#    1) The letters at pos1 and pos2 are either:
#         a) not launched, or
#         b) are at least 8 steps away, and not at the top of the arc
#            (so no two letters at the same pos in the arc)
#    2) The number 2 is chosen randomly from within a range.
#       The range decreases from 40 to 3, so the game starts launching
#       letters slowly, a later launches letters more often.
def can_launch(pos1, pos2):
    global chance
    if is_away(pos1) and is_away(pos2):
        if chance > 3.2:
            chance = chance - .2
        return random.randrange(0, math.floor(chance)) == 2
    return False

# Determines if the position means another letter can be launched.
def is_away(pos):
    if pos < 0:
        return True
    if pos >= 100:
        return False
    if pos > 8:
        return traj[pos][1] != 0 # not at top of bounce
    return False

# Shows or hides the title screen
def set_title(state):
    title = "POST"
    left = 2
    if version > 0:
        title = title + " v." + str(version)
        left = 0
    add_text(left, 2, title)
    update_paddle(2, 1)

def update_title(y):
    bounce = [16,10,6,2,0,2,6,10]
    last = y-1
    if last < 0:
        last = len(bounce) - 1
    update_sprite(letter, 50, bounce[last], letterW, letterH, 0)
    update_sprite(letter, 50, bounce[y], letterW, letterH, 1)
    next = y + 1
    if next > len(bounce) - 1:
        return 0
    return next

# Shows or hides the message to "Get ready".
def set_ready(state):
    if state == 1:
        add_text(2, 0, "Get ready")
    else:
        for x in range(64):
            for y in range(10):
                set_px(x, y, 0, 0)

# Moves the player's paddle either left or right.
def move_paddle(offset):
    global paddle
    update_paddle(paddle, 0)
    paddle = paddle + offset
    update_paddle(paddle, 1)

# Called when the player misses a letter.
def on_fail():
    global fails
    music.pitch(200, 500, wait=False)
    fails = fails + 1
    show_fails(fails)
    if fails >= 3:
        game_over()
        return True
    return False

# Called when the player has missed three letters.
def game_over():
    clear_oled()
    add_text(2, 0, "GAME OVER!")
    add_text(2, 2, "Score: " + str(score))

# Called when a letter hits the player's paddle.
def on_success():
    global score, delay
    score = score + 1
    if delay > min_delay:
        delay = delay - 2
    music.stop()
    music.pitch(600, 1)

# Uses Microbit's LED to show the number of fails.
def show_fails(count):
    leds = "00000:00000:0"
    for i in range (count):
        leds = leds + "9"
    for i in range (count, 3):
        leds = leds + "0"
    leds = leds + "0:00000:00000"
    display.show(Image(leds))

# Shows a dotted line when the player misses a letter
def show_splat(pos):
    x = traj[pos-100][0] * 2
    for i in range (0, letterW, 2):
        set_px(x+i, limitY, 1, 0)

# Removes any dotted lines indicating a missed letter
def hide_splats():
    for i in range (64):
        set_px(i, limitY, 0, 0)

# Moves the letter to the next position in the trajectory
def move_letter(index):
    global lett_pos
    index1 = get_index(index, -1)
    index2 = get_index(index, 1)
    if lett_pos[index] >= 0 and lett_pos[index] < 100:
        lett_pos[index] = get_next(lett_pos[index])

# Gets the next position in the trajectory.
def get_next(t):
    if t >= 0 and t < traj_count-1:
        return t + 1
    return -1

# If the letter is not in a trajectory, it may be launched
def launch_letter(index):
    global lett_pos, started
    index1 = get_index(index, -1)
    index2 = get_index(index, 1)
    if lett_pos[index] < 0 or lett_pos[index] >= 100:
        if can_launch(lett_pos[index1], lett_pos[index2]):
            lett_pos[index] = 0
            if not started:
                set_ready(0)
                started = True

# Gets the index of the previous or next letter.
def get_index(index, offset):
    index = index + offset
    if index < 0:
        return 2
    if index > 2:
        return 0
    return index

# Restarts the program, returning to the title screen
def restart():
    a = button_a.was_pressed()
    b = button_b.was_pressed()
    sleep(200)
    reset()

# Initialize the game
letter = hex_to_bits( "fff:a05:909:891:861:801:801:fff".split(':'))
lett_pos = [ -1, -1, -1 ]
build_traj()
initialize()
clear_oled()
set_title(1)
opened = False        # has the user pressed a button on the title screen?
started = False       # has the game started? (used to clear "Get ready" prompt)
playing = True        # is the game underway; if not, the game has finished
move_letters = False  # do we move the letters in the current game turn?

# Show the title screen
while not opened:
    titleI = update_title(titleI)
    draw_screen()
    sleep(delay)
    if button_a.was_pressed() or button_b.was_pressed():
        opened = True
clear_oled()
set_ready(1)
update_paddle(paddle, 1)
draw_screen()

# Start playing the game, where the player can move the paddle each turn,
# whereas the letters move every second turn.  (The paddle must move twice
# as fast as the letters, so the player can save every letter.)
while playing:
    refresh = False # whether a screen redraw is needed

    if button_a.was_pressed():
        if paddle > 0:
            move_paddle(-1)
            refresh = True;
    elif button_b.was_pressed():
        if paddle < 2:
            move_paddle(1)
            refresh = True;

    if move_letters:
        for i in range(len(lett_pos)):
            pos = lett_pos[i]
            if pos >= 100: # indicates that the letter was missed by the player
                lett_pos[i] = -1 # available to be launched again later
                update_sprite(letter, traj[pos-100][0] * 2, traj[pos-100][1], letterW, letterH, 0)
                refresh = True
                if on_fail():
                    playing = False
                    refresh = False
                else:
                    show_splat(pos)
            else:
                if pos > 0:  # the sprite needs to be 'removed' from its last position
                    update_sprite(letter, traj[pos-1][0] * 2, traj[pos-1][1], letterW, letterH, 0)
                    refresh = True
                if pos >= 0: # the sprite needs to be drawn on the screen
                    refresh = True
                    update_sprite(letter, traj[pos][0] * 2, traj[pos][1], letterW, letterH, 1)
                    if pos in bottoms: # is the letter at the bottom?
                        if bottoms.index(pos) == paddle: # is the paddle at the same position?
                            on_success()
                        else:
                            lett_pos[i] = 100 + pos # player missed the letter

    if playing:

        if refresh:
            draw_screen()

        if move_letters:
            move_letter(0)
            move_letter(1)
            move_letter(2)
            launch_letter(0)
            launch_letter(1)
            launch_letter(2)

        sleep(delay)

        if not move_letters:
            hide_splats()
            draw_screen()

        move_letters = move_letters == False

    else:

        while not button_a.was_pressed() and not button_b.was_pressed():
            sleep(150)
        restart()

Optionally, Use a Printed Circuit Board

PCB-01.jpg

A breadboard makes it very easy for us to plan and test a circuit, yet is not ideal for a finished product which should last a while. To produce a smaller form factor with more permanence, we can recreate our circuit on a printed circuit board (PCB) breadboard.

This requires soldering the components and wires onto the board. The image above shows my original breadboard version on the left, and my recreated PCB version on the right. You may not be surprised to learn that this was my first ever attempt to use a PCB, and my first ever attempt to solder components and wires. So please accept the above image as merely an example, and certainly not a template to follow.

While we could produce a smaller circuit, the size of the Micro:bit and its breakout board means our final size will never be tiny. We also can’t aim for a too-small form factor if we want to place buttons on either side of the OLED display.

Optionally, Create a Casing

Final-01.jpg

Whenever we create an electronic device, we can choose to leave it in its “raw” form with exposed components and wires. Or, we can choose to create a final “product” where we endeavor to expose only those components needed by the user—in a handheld game, that’s typically the screens and the buttons.

The above image shows a basic effort to create a cardboard product case. While we could extend the cardboard case to hide the Micro:bit except for its LED, doing so would make it difficult to update the game, or indeed to extract the Micro:bit for use in another project. So we can choose to hide the PCB and wiring, yet show the Micro:bit.

Future Improvements

For this Instructable, we are keen to not hide the Micro:bit, so we use its LED display to show how many letters the player has missed. A future improvement would be to hide the Micro:bit, and add LED lights to the PCB. (Or add the number of misses to the OLED, though space is already very cramped.)

The main avenue for improvement is the game itself. For this Instructable, the game was kept simple and its code comparatively short. Yet if we took the time to change the single letter sprite into multiple animated sprites, the game would be far more charming.

And we could change the game altogether. We could add more buttons to allow the player to move up and down, or to add actions such as hitting or shooting. The greatest constraint is the effective screen resolution of 64 pixels by 32 pixels (remembering that the driver software doubles pixels to fill the actual screen resolution of 128x64 pixels).

Conclusion

Play-01.jpg

If you have made it this far and have created this game, you should be very proud of your efforts. We’ve had to use a breakout board and a breadboard to support the OLED and button components we needed, and wire the components together. We’ve had to install third-party Python libraries, and work with those libraries. And we’ve had to write the Python code necessary to display graphics and manage game play.

It is a great demonstration of the utility of the Micro:bit as a microcontroller, particularly when connected to a breakout board.

If you’ve created this game, what score could you reach? The highest score I achieved was 122.