Micro:bit OLED Game
by SomeNewKid in Circuits > Microcontrollers
5309 Views, 9 Favorites, 0 Comments
Micro:bit OLED Game
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
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:
- A Micro:bit (ideally version 2, to include sound with our game without requiring additional components);
- A Micro:bit breakout board which exposes its Inter-Integrated Circuit (I²C) ports 19 and 20, such as the Keyestudio Prototype Breakout Board;
- A simple breadboard with power rails, such as the Kitronik Small Prototype Breadboard;
- A 1.3" 128x64 OLED display, supporting I²C, such as the Keyestudio OLED Display;
- Two buttons suitable for a printed circuit board (PCB), such as the Kitronik push switches; and
- Male-to-male breadboard jumper wires, such the Kitronik pack.
If you'd like to go beyond a breadboard prototype, and make a real handheld game, you'll also need the following:
- A simpler Micro:bit breakout board which exposes I²C ports 19 and 20, such as the Keyestudio Breakout Board Adapter;
- A printed circuit board (PCB) breadboard with power rails, such as the Adafruit Perma-Proto Breadboard;
- Optionally, a 3V button battery and holder, such as the Wurth battery holder; and
- Craft materials, such as cardboard, with which to create a case.
Getting Started With the Mu Editor
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
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
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
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
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
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
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
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
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
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()
Downloads
Optionally, Use a Printed Circuit Board
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
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
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.