Overview
While there are several guides on making some basic games in CircuitPython, they are meant to get you started with writing your own games. In this guide, I'll go over making a more complex tile-based game that had originally been available for the Lynx and Microsoft Windows 3.1 as part of the Microsoft Entertainment Pack 4. The version that this code is based on is the Microsoft version.
Instead of building upon displayio for the most part, this game takes a more traditional approach and uses CircuitPython's bitmaptools module to draw the graphics directly to the screen buffer. This includes some more unique features, such as keyboard input from the keyboard buffer. This allows input from either the serial terminal or using an attached keyboard for a standalone setup.
The code is mostly based onĀ Pocket Chips Challenge, which I had originally written for the Pocket PC using C++ in the early 2000s, and Tile World, which was written in C. Pocket Chips Challenge was never completed, and rewriting it in CircuitPython allowed me to finish the game. I could reuse most of the graphics I had made at the time, which were written for a 240x320 display. This includes a custom set of 24x24 pixel tiles, which I had redrawn based on the original 32x32 pixel tiles. The only graphics from the original game were the digits displayed on the right side.
Because of the limitations of writing it for a microcontroller, I had to find some creative ways to get the game to operate as quickly as possible. Originally I had written the game to use Double Buffering, and although the graphics were smoother, it just took too long to write to 2 buffers per frame and the game felt laggy. I ended up writing the game to keep track of tiles that changed and only performed partial screen updates.
The most challenging aspect of writing the game in CircuitPython was the lack of prebuilt dialogs and I ended up making these myself. I leaned fairly heavily on the adafruit_display_text library for displaying the text. It has come a long way since it was originally written, including bitmap labels which allow putting the text right onto a bitmap and text boxes which improve upon that by allowing horizontal alignment of the bitmap labels.
There are many more techniques that I used to make this, including working with paletted bitmaps, which had challenges of their own. This and many more techniques will be covered in more detail. This ended up being one of the largest pieces of code written specifically for CircuitPython that I'm aware of, so the code will not be listed in its entirety, but there are many great techniques to cover.
Parts
Because the size of this game is so large, you will need the version of the Metro RP2350 with 8MB of PSRAM or a Fruit Jam. If you already have the version of the Metro RP2350 without the PSRAM and are comfortable with surface mount soldering, it is possible to solder on a PSRAM chip yourself to upgrade it.
If using a Fruit Jam, you will need:
or if using a Metro RP2350:
For either:
Optional Parts
You will need a display with an HDMI input capable of displaying resolutions as low as 640x480.
or
If you would like to save the state of your game, you will need a microSD card.
Audio Output Parts
If you are using the Metro RP2350, to add Audio Output, you will need a Digital-to-Analog-Converter and some extra parts to connect it
Additional Parts for Standalone Project
To make this project a standalone system, you will need the following additional parts.
For the Metro RP2350, you'll need the following to add a USB port:
For both, you'll need a keyboard and power supply:
or
Page last edited August 22, 2025
Text editor powered by tinymce.
Game Structure
The game structure mainly consists of code.py, game.py, gamelogic.py, and level.py. Here is the import structure of just the game files to help you navigate the code.
Code.py is at the root of the tree, and everything else is based on what is being imported. You may have noticed there are some duplicates, such as definitions.py and point.py, because they are reused several times.
Page last edited August 22, 2025
Text editor powered by tinymce.
256-Color Graphics
With this game, I decided to go with 256-color graphics for a couple of reasons. Mostly that the graphics I was using had very few colors, and that it would conserve memory. The first thing I needed to do was make sure that all of the graphics used the same palette. I used Photoshop for all images and used the following steps:
I set the image mode to use indexed color and then changed the Palette Setting to "System (Windows)".
It's important to change this from the default setting of Exact, which means that it only uses the exact colors in that particular image. Since CircuitPython only allows a single palette to be active at a time, if another image has different colors, the palettes will be different and likely won't display correctly together. For more details on how palettes work in CircuitPython, check out the Bitmap and Palette section of the CircuitPython Display Support Using displayio guide which does an excellent job explaining the graphics.
For the purposes of the game programming, you have to keep in mind that a palette is a list of the colors that are used and the bitmap is a grid of indexes that refer to this palette. The palette is sometimes referred to as the shader, which is an umbrella term that encompasses palettes as well as Color Converters used with higher resolution graphics. One of the challenges is to specify a color to use, rather than assigning the color directly, the color index needs to be used. To do that, I wrote a simple function that would scan through the palette and return the color index if found:
def get_color_index(self, color, shader=None):
if shader is None:
shader = self._shader
for index, palette_color in enumerate(shader):
if palette_color == color:
return index
return None
Another challenge was working with the labels. They return a 2-color palette with 0 being the background color and 1 being the foreground color. To get the text to show the correct color within the palette, the foreground and background indices need to be reassigned. A new 256-color bitmap is created because displayio does not provide a mechanism to change the number of available colors in a bitmap, which is likely due to memory reallocation. Then the bitmap is scanned pixel by pixel and the new bitmap has the index set to match the correct color index.
def reassign_indices(self, bitmap, foreground_color_index, background_color_index):
# This will reassign the indices in the bitmap to match the palette
new_bitmap = displayio.Bitmap(bitmap.width, bitmap.height, len(self.shader))
if background_color_index is not None:
for x in range(bitmap.width):
for y in range(bitmap.height):
if bitmap[(x, y)] == 0:
new_bitmap[(x, y)] = background_color_index
if foreground_color_index is not None:
for x in range(bitmap.width):
for y in range(bitmap.height):
if bitmap[(x, y)] == 1:
new_bitmap[(x, y)] = foreground_color_index
return new_bitmap
With those 2 challenges solved, the rest of the graphics work was fairly straightforward. The only other place where the color index was used was in specifying the key color for bitmaptools blit function, which it avoids drawing. This allows 2 different tiles to be drawn on top of each other.
This spritesheet includes all of the tiles used as well as a duplicate set of keyed tiles (the ones with a light green background) for the creatures that need to be drawn on top of different backgrounds. The reason for having 2 different sets is for speed because most of the time, the creatures are drawn on top of empty floor, but occasionally (such as sliding on ice), the creature needs to be drawn on top of empty floor. This is to speed up the game, as most of the time, only one sprite needs to be drawn instead of 2.
Page last edited August 22, 2025
Text editor powered by tinymce.
Partial Screen Updating
In order to speed up the game, I ended up only redrawing the display where necessary. This meant keeping track of several screen locations. To do this, I wrote a special data buffer class. It works similar to a dictionary with a few changes. It keeps track of a default set of data and allows resetting either all of the data or selected keys to the default value, which makes updating certain area or resetting the level much easier.
# SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries
#
# SPDX-License-Identifier: MIT
class DataBuffer:
def __init__(self):
self._dataset = {}
self._default_data = {}
def set_data_structure(self, data_structure):
self._default_data = data_structure
self.reset()
def reset(self, field=None):
# Copy the default data to the dataset
if field is not None:
if not isinstance(field, (tuple, list)):
field = [field]
for item in field:
self._dataset[item] = self.deepcopy(self._default_data[item])
else:
self._dataset = self.deepcopy(self._default_data)
def deepcopy(self, data):
# Iterate through the data and copy each element
new_data = {}
if isinstance(data, (dict)):
for key, value in data.items():
if isinstance(value, (dict, list)):
new_data[key] = self.deepcopy(value)
else:
new_data[key] = value
elif isinstance(data, (list)):
for idx, item in enumerate(data):
if isinstance(item, (dict, list)):
new_data[idx] = self.deepcopy(item)
else:
new_data[idx] = item
return new_data
@property
def dataset(self):
return self._dataset
One of the places that partial updating excels is when there are a lot of the same tiles on the screen. This is because as the viewport changes, the tile that needs to be displayed may not actually need to be redrawn such as with empty floor tiles. If the game were using displayio alone, the entire viewport would be redrawn because it only knows that a new values were written in a location and marks the areas as dirty.
However, when the game has a lot of different tiles, you may notice more lag because it has to redraw more tiles in the viewport as you move around. However, with the game as optimized as it is, the increased lag is barely perceptible.
Another strategy that was used was using displayio for the dialogs. By keeping all of the dialogs in a separate layer and everything else in either of the 2 main areas of the game (the tiles or the info box), the background is rarely redrawn for the entirety of the gameplay.
Page last edited August 22, 2025
Text editor powered by tinymce.
Keyboard Input
One of the biggest challenges with this game was getting the keyboard input correct. Normally, the way a game handles keyboard input is by keeping track of which keys are pressed by monitoring Key Down and Key Up events. The way that CircuitPython handles the game is by allowing only a single keypress at a time and if you continue holding the key, it waits about half a second to send additional keypresses. Because of this limitation, you can move a bit faster and more accurately by rapidly tapping the arrow key that you want to use.
Additionally, for many of the keys such as the arrows, it adds a multibyte key sequence to the keyboard buffer. I wrote a Keyboard Input class that allows a set of valid key sequences to be set and it will only return the key once it has a full packet and it will discard anything else. It works quite well and includes the ability to even work with control characters. In order to keep the game simple, I decided to only use hotkeys instead of a menu system, so all input is done using the keyboard.
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import sys
import supervisor
class KeyboardBuffer:
def __init__(self, valid_sequences):
self.key_buffer = ""
self._valid_sequences = valid_sequences
def update(self):
while supervisor.runtime.serial_bytes_available:
self.key_buffer += sys.stdin.read(1)
def print(self):
print("buffer", end=": ")
for key in self.key_buffer:
print(hex(ord(key)), end=" ")
def set_valid_sequences(self, valid_sequences):
self._valid_sequences = valid_sequences
def clear(self):
self.key_buffer = ""
def get_key(self):
"""
Check for keyboard input and return the first valid key sequence.
"""
# Check if serial data is available
self.update()
if self.key_buffer:
for sequence in self._valid_sequences:
if self.key_buffer.startswith(sequence):
key = sequence
self.key_buffer = self.key_buffer[len(sequence):]
return key
# Remove first character
self.key_buffer = self.key_buffer[1:]
return None
I created 3 sets of valid inputs which map the key sequence to the return value and the game switches between them as needed. These are found in the definitions.py file andĀ include:Ā
- GAMEPLAY_COMMANDS: Used for normal gameplay and includes the arrow keys as well as any hotkeys.
- MESSAGE_COMMANDS: Only allows either the enter key or spacebar and is used to dismiss informational dialogs.
- PASSWORD_COMMANDS: Used for typing in the level number or password into the the password boxes.
# Command Constants
UP = const(0)
LEFT = const(1)
DOWN = const(2)
RIGHT = const(3)
NEXT_LEVEL = const(4)
PREVIOUS_LEVEL = const(5)
RESTART_LEVEL = const(6)
GOTO_LEVEL = const(7)
PAUSE = const(8)
QUIT = const(9)
OK = const(10)
CANCEL = const(11)
CHANGE_FIELDS = const(12)
DELCHAR = const(13)
# Keycode Constants
UP_ARROW = const("\x1b[A")
DOWN_ARROW = const("\x1b[B")
RIGHT_ARROW = const("\x1b[C")
LEFT_ARROW = const("\x1b[D")
SPACE = const(" ")
CTRL_G = const("\x07") # Ctrl+G
CTRL_N = const("\x0E") # Ctrl+N
CTRL_P = const("\x10") # Ctrl+P
CTRL_Q = const("\x11") # Ctrl+Q
CTRL_R = const("\x12") # Ctrl+R
BACKSPACE = const("\x08")
TAB = const("\x09")
ENTER = const("\n")
ESC = const("\x1b")
# Mapping Buttons to Commands for different modes
GAMEPLAY_COMMANDS = {
UP_ARROW: UP,
LEFT_ARROW: LEFT,
DOWN_ARROW: DOWN,
RIGHT_ARROW: RIGHT,
SPACE: PAUSE,
CTRL_G: GOTO_LEVEL,
CTRL_N: NEXT_LEVEL,
CTRL_P: PREVIOUS_LEVEL,
CTRL_Q: QUIT,
CTRL_R: RESTART_LEVEL,
}
MESSAGE_COMMANDS = {
ENTER: OK,
SPACE: OK,
}
# Password commands include only letters, enter, tab, and backspace
PASSWORD_COMMANDS = {
ESC: CANCEL,
TAB: CHANGE_FIELDS,
ENTER: OK,
BACKSPACE: DELCHAR,
}
# The rest are input characters
for i in range(65, 91):
PASSWORD_COMMANDS[chr(i)] = chr(i)
for i in range(97, 123):
PASSWORD_COMMANDS[chr(i)] = chr(i)
for i in range(48, 58):
PASSWORD_COMMANDS[chr(i)] = chr(i)
Page last edited August 22, 2025
Text editor powered by tinymce.
Drawing with bitmaptools
For drawing the graphics for the game itself, I decided to use bitmaptools to make the code easier to convert. The code I was porting from used a bitblt function (pronounced bit blit and is short for BIT BLock Transfer), and the equivalent bitmaptools function is blit(). Essentially, this function copies all or part of a bitmap and draws it onto another, kind of like copying and pasting. The reason I went with this is that it was much easier to optimize and ran faster.
For the winning animation, I also used rotozoom(). This function allows scaling and rotating the image in a single operation. However, for this game, only the scaling aspect was used. To learn more about how this was used, be sure to check out the Animations page of this guide.
bitmaptools also has quite a few additional functions that can be useful in creating games.Ā For the rest of the game, I ended up going with displayio to display the background, messages, and loading info box. This was because it allowed me to easily dismiss the dialogs without needing to redraw the display.
Page last edited August 22, 2025
Text editor powered by tinymce.
Dynamically Loading Data
One of the most important features I wanted to include with this game was the ability to load level set data files. As the name implies, a data file represents a set of levels. The reason I wanted to include this is because since the game's release, a community of people have created hundreds of custom level sets that can be found using a web search. These were made by using some custom level editors written by other community members.
The level file can be changed in code.py by altering the DATA_FILE setting. Although this should load most custom levels, some of the ones using more advanced coding techniques that take advantage of glitches in the Microsoft version of the game, though this functionality could probably be added without too much trouble.
To achieve loading the data file dynamically, the file is parsed byte by byte and loaded into a useable data structure for the game. An explanation of the file structure is explained in Chips Challenge File Layout and was the basis of how the dynamic loading took place. If you would like to take a closer look at the code, it is contained in the level.py file.
The load() function reads the binary file byte by byte to extract the necessary data. It starts by validating the file header, finding the starting position of the level data, and then extracting it out to instance variables that can be read by the game logic.
def load(self, level_number):
#pylint: disable=too-many-branches, too-many-locals
# Reset the data prior to loading
self._reset_data()
# Read the file and fill in the variables
with open(self._data_file, "rb") as file:
# Read the first 4 bytes in little endian format
if read_int(file, 4) not in (0x0002AAAC, 0x0102AAAC):
raise ValueError("Not a CHIP file")
self.last_level = read_int(file, 2)
if not 0 < level_number <= self.last_level:
raise ValueError("Invalid level number")
self.level_number = level_number
# Seek to the start of the level data for the specified level
while True:
level_bytes = read_int(file, 2)
if read_int(file, 2) == level_number:
break
# Go to next level
file.seek(level_bytes - 2, 1)
# Read the level data
self.time_limit = read_int(file, 2)
self.chips_required = read_int(file, 2)
compression = read_int(file, 2)
if compression == COMPRESSED:
raise ValueError("Compressed levels not supported")
# Process the top map data
layer_bytes = read_int(file, 2)
map_data = file.read(layer_bytes)
self._process_map_data(map_data, "top")
# Process the bottom map data
layer_bytes = read_int(file, 2)
map_data = file.read(layer_bytes)
self._process_map_data(map_data, "bottom")
remaining_bytes = read_int(file, 2)
while remaining_bytes > 0:
field_type = read_int(file, 1)
field_size = read_int(file, 1)
remaining_bytes -= (2 + field_size)
if field_type == FIELD_TITLE:
self.title = file.read(field_size).decode("utf-8").replace("\x00", "")
elif field_type == FIELD_HINT:
self.hint = file.read(field_size).decode("utf-8").replace("\x00", "")
elif field_type == FIELD_PASSWORD:
self.password = (
"".join([chr(c ^ 0x99) for c in file.read(field_size)]).replace("\x99", "")
)
elif field_type == FIELD_BEAR_TRAPS:
trap_count = field_size // 10
for _ in range(trap_count):
button = Point(read_int(file, 2), read_int(file, 2))
device = Point(read_int(file, 2), read_int(file, 2))
self.traps.append(Device(button, device))
file.seek(2, 1)
elif field_type == FIELD_CLONING_MACHINES:
cloner_count = field_size // 8
for _ in range(cloner_count):
button = Point(read_int(file, 2), read_int(file, 2))
device = Point(read_int(file, 2), read_int(file, 2))
self.cloners.append(Device(button, device))
elif field_type == FIELD_MOVING_CREATURES:
creature_count = field_size // 2
for _ in range(creature_count):
self.creatures.append(Point(
read_int(file, 1),
read_int(file, 1)
))
# Load passwords if not already loaded
if len(self.passwords) == 0:
self._load_passwords(file)
TheĀ read_int()Ā function is a helper function which reads a certain number of inĀ little endianĀ format and converts them to an integer. Little endian means a multi-byte value has the least significant bytes written first in the file.
def read_int(file, byte_count):
return int.from_bytes(file.read(byte_count), "little")
At the end of loading the level file, on the first time the file is loaded, the level passwords are also extracted out. This is so the game can check if a particular level password is correct without discarding the current level. This is done by movingĀ the pointer back to the beginning of the file and then going level by level to extract each password and decode it.
def _load_passwords(self, file):
file.seek(6) # Skip the file header
while True:
file.seek(2, 1)
level_number = read_int(file, 2)
file.seek(6, 1)
layer_bytes = read_int(file, 2) # Number of bytes in the top layer
file.seek(layer_bytes, 1) # Skip top layer
layer_bytes = read_int(file, 2) # Number of bytes in the top layer
file.seek(layer_bytes, 1) # Skip bottom layer
remaining_bytes = read_int(file, 2)
while remaining_bytes > 0:
field_type = read_int(file, 1)
field_size = read_int(file, 1)
remaining_bytes -= (2 + field_size)
if field_type == FIELD_PASSWORD:
password = file.read(field_size)
self.passwords[level_number] = (
"".join([chr(c ^ 0x99) for c in password]).replace("\x99", "")
)
file.seek(remaining_bytes, 1)
break
file.seek(field_size, 1)
if len(self.passwords) == self.last_level:
break
Page last edited August 22, 2025
Text editor powered by tinymce.
Save States
Progress of the game is automatically saved as you complete or unlock each level. This includes the password for the level, the score, and the amount of time left when you complete the level.Ā The amount of space required is too much to be stored in nvram, so an SD card is used. It is automatically remounted as read/write when the game is loaded. You can still play the game without an SD card, but the state is not saved between loads of the game.
The purposes of the SaveState class include:
- Handling automatically mounting the SD Card and marking it as unavailable if it fails to load
- Handling loading the data and saving it as it is updated
- Keeping track of the passwords and scores as the game is played
By default, the save file is named chips.json, but can be renamed. If you do not have an SD card, that is detected when the game is first loaded and the game will continue to function like normal for the duration of the session. The only difference is that progress with regards to scoring and unlocking levels is not saved. You can tell if the SD Card has successfully loaded by watching the serial output.
If it loads successfully, you should see "SD Card detected" and if not, you should see "SD Card not detected. Level data will NOT be saved."
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
from math import floor
import json
import board
from microcontroller import nvm
from digitalio import DigitalInOut, Pull
import busio
import sdcardio
import storage
SAVESTATE_FILE = "chips.json"
class SaveState:
def __init__(self):
self._levels = {}
self._has_sdcard = self._mount_sd_card()
if self._has_sdcard:
print("SD Card detected")
else:
print("SD Card not detected. Level data will NOT be saved.")
self.load()
self._sdcard = None
def _mount_sd_card(self):
# Check if the SD card is already mounted
try:
storage.getmount("/sd")
return True
except OSError:
pass
try:
self._card_detect = DigitalInOut(board.SD_CARD_DETECT)
except ValueError:
return False
self._card_detect.switch_to_input(pull=Pull.UP)
if self._card_detect.value:
return False
# Attempt to unmount the SD card
try:
storage.umount("/sd")
except OSError:
pass
spi = busio.SPI(board.SD_SCK, MOSI=board.SD_MOSI, MISO=board.SD_MISO)
try:
sdcard = sdcardio.SDCard(spi, board.SD_CS, baudrate=20_000_000)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
except OSError:
return False
return True
def save(self):
if not self._has_sdcard:
return
with open("/sd/" + SAVESTATE_FILE, "w") as f:
json.dump({"levels": self._levels}, f)
def load(self):
if not self._has_sdcard:
return
# Use try in case the file doesn't exist
try:
with open("/sd/" + SAVESTATE_FILE, "r") as f:
data = json.load(f)
self._levels = data["levels"]
except (OSError, ValueError):
pass
def set_level_score(self, level, score, time_left):
level_key = f"level{level}"
new_high_score = False
lower_time = False
if level_key not in self._levels:
self._levels[level_key] = {}
if score > self._levels[level_key].get("score", 0):
new_high_score = True
self._levels[level_key]["score"] = score
if time_left > self._levels[level_key].get("time_left", 0):
lower_time = True
self._levels[level_key]["time_left"] = time_left
self.save()
return new_high_score, lower_time
def add_level_password(self, level, password):
nvm[0] = level
for byte, char in enumerate(password):
nvm[1 + byte] = ord(char)
level_key = f"level{level}"
if level_key not in self._levels:
self._levels[level_key] = {}
self._levels[level_key]["password"] = password.upper()
self.save()
def find_unlocked_level(self, level_or_password):
if isinstance(level_or_password, int):
level_key = f"level{level_or_password}"
password = None
else:
level_key = None
password = level_or_password
# Look for level by number
if level_key in self._levels:
return level_or_password
for key, data in self._levels.items():
if "password" in data and data["password"] == password:
return int(key[5:])
return None
def calculate_score(self, level, time_left, deaths):
time_bonus = time_left * 10
level_bonus = floor(level * 500 * 0.8**deaths)
level_score = time_bonus + level_bonus
total_score = self.total_score
return time_bonus, level_bonus, level_score, total_score
def has_password(self, level, password):
level_key = f"level{level}"
if level_key in self._levels:
return self._levels[level_key]["password"] == password.upper()
return False
def level_score(self, level):
level_key = f"level{level}"
if (level_key in self._levels and "score" in self._levels[level_key] and
"time_left" in self._levels[level_key]):
return self._levels[level_key]["score"], self._levels[level_key]["time_left"]
return 0, 0
def is_level_unlocked(self, level):
level_key = f"level{level}"
if level_key in self._levels and "password" in self._levels[level_key]:
return True
return False
@property
def has_sdcard(self):
return self._has_sdcard
@property
def total_score(self):
total_score = 0
for data in self._levels.values():
if "score" in data:
total_score += data["score"]
return total_score
@property
def total_completed_levels(self):
completed_levels = 0
for data in self._levels.values():
if "score" in data:
completed_levels += 1
return completed_levels
Page last edited August 22, 2025
Text editor powered by tinymce.
Dialogs
There are three kinds of dialogs used in Chips Challenge. These are the simple dialog, the message dialog, and the password dialogs.
Simple Dialog
The simple dialog just contains text and no buttons. It is used to display the level title, the hint, and the pause screen overlay. The hint and title are shown automatically and dismissed automatically based on the gameplay and the pause overlay is shown or hidden when either the game is paused or the user tries to go to a level that has not been unlocked yet.
The simple dialog, such as the hint dialog, is easily hidden within the user interface and only shows a message.
Message Dialog
The message dialog is basically the same as the simple dialog, except it has a button drawn below the text. Input from the keyboard is read to dismiss the dialog. This is used to display messages such as why Chip died or to display a summary of the level upon winning. Every 10 levels or so, an additional message (known as a decade message) will be added to the summary as well and at the end of the game some additional messages are shown.
The buttons are merely aesthetic and you can't actually click them because there is no touch input being utilized in the game. Instead, the clicking is simulated with keypresses. Either the spacebar or Enter key can be pressed to dismiss the dialog.
The Message dialog is handled by the show_message() function inside of game.py. It handles the drawing of the dialog, listening to keyboard input, and settings/unsetting the valid keyboard command sets.
Password Dialogs
The password dialogs are used when you need to go to a specific level or just type in a password. This is the most complex type because the input fields need to be kept track of so that they can be updated if there is any input and the fields redrawn.
When they are displayed, input from the keyboard is read and the controls are updated accordingly. The fields have a type that allows for just alphabetic characters, numeric characters, or anything. This allows for easier filtering of the keys. This way you aren't able to type a letter for a level number. It also has a maximum length property in order to limit the number of characters and having it go off the screen.
Just like with the message dialog, there are some purely visual buttons at the bottom. These include OK and Cancel buttons, which are selected by pressing the Enter and Escape keys respectively.
The Password dialog is handled by the request_password() function in game.py. It handles the drawing of the dialog, listening to keyboard input, adjusting the field parameters, and settings/unsetting the valid keyboard command sets. It also handles what to do when the user selects either OK or CANCEL.
The password dialog is to let the user input a level number and password (or in some cases, just the password). The Tab key allows switching to the next dialog and the darker border indicates the active field. Both fields allow a maximum of 9 characters, though this could be set to a smaller value. The number field only allows numerical values to be entered, whereas the password field allows alphanumeric values.
Partial updating is used to only redraw the active field when a value is changed.
Stacking Dialogs
Dialogs are kept in their own layer using a displayio group. They are able to be stacked by adding dialogs to the group and removing them as they are dismissed.
Page last edited August 22, 2025
Text editor powered by tinymce.
Animations
While most of the gameplay is a matter of moving the different tiles around and would not really be considered animation, the end game includes an animation. This is done by defining the animation sequences and then running them in a loop. The animations can have operations done such as zooming in on them or moving them around.
The animation implemented in this game very closely matches the original game. Because of the way it is drawn, with Chip enlarging, each new frame just covers the existing graphics below it, so there is no need to deal with the previously drawn graphics. As such, I ended up just usingĀ bitmaptools to draw the animations on top of the game layer. You can check out theĀ Blinka Jump PyBadge Game and Halloween Countdown Display Matrix learn guides to see examples of animation using displayio, which does a great job handling automatically erasing the old graphics.
The animation sequence shown above is handled by the _show_winning_sequence() inside of game.py. It starts off by performing some calculations to get the screen coordinates necessary for drawing. After that frames are defined with the top tile and bottom tile. Then the sequences are created using theĀ get_frame_image() sub-function to hold the bitmaps to be drawn.
In the first for loop, the zoom sequence is played while it is enlarged using bitmaptools.rotozoom() in 32 steps and if the scaled image would go outside of the viewport, it is moved back inside. This is in case the exit is at the edge of the screen.
Finally, the cheer sequence is played a random number of times between 16-20 times with a random delay between 0.25 and 0.75 seconds. Without actually seeing the source code of the original game, this appears to be almost indistinguishable from the original sequence.
Finally, and ending bitmap is displayed along with a message.
def _show_winning_sequence(self):
#pylint: disable=too-many-locals
self._gamelogic.set_game_mode(GM_GAMEWON)
def get_frame_image(frame):
# Create a tile sized bitmap
tile_buffer = displayio.Bitmap(self._tile_size, self._tile_size, 256)
self._draw_tile(tile_buffer, 0, 0, frame[0], frame[1])
return tile_buffer
# Get chips coordinates
chip = self._gamelogic.get_chip_coords_in_viewport()
viewport_size = self._tile_size * 9
# Get centered screen coordinates of chip
chip_position = Point(
VIEWPORT_OFFSET[0] + chip.x * self._tile_size + self._tile_size // 2,
VIEWPORT_OFFSET[1] + chip.y * self._tile_size + self._tile_size // 2
)
viewport_center = Point(
VIEWPORT_OFFSET[0] + viewport_size // 2 - 1,
VIEWPORT_OFFSET[1] + viewport_size // 2 - 1
)
# Chip Frames
frames = {
"cheering": (TYPE_EXITED_CHIP, TYPE_EMPTY),
"standing_1": (TYPE_CHIP + DOWN, TYPE_EXIT),
"standing_2": (TYPE_CHIP + DOWN, TYPE_EXIT_EXTRA_1),
"standing_3": (TYPE_CHIP + DOWN, TYPE_EXIT_EXTRA_2),
}
# Chip Sequences
zoom_sequence = (
get_frame_image(frames["standing_1"]),
get_frame_image(frames["standing_2"]),
get_frame_image(frames["standing_3"]),
)
cheer_sequence = (
get_frame_image(frames["cheering"]),
get_frame_image(frames["standing_1"]),
)
viewport_upper_left = Point(
VIEWPORT_OFFSET[0],
VIEWPORT_OFFSET[1]
)
viewport_lower_right = Point(
VIEWPORT_OFFSET[0] + viewport_size,
VIEWPORT_OFFSET[1] + viewport_size
)
for i in range(32):
source_bmp = zoom_sequence[i % len(zoom_sequence)]
scale = 1 + ((i + 1) / 32) * 8
scaled_tile_size = math.ceil(self._tile_size * scale)
x = chip_position.x
y = chip_position.y
# Make sure the scaled tile is within the viewport
scaled_tile_upper_left = Point(
x - scaled_tile_size // 2,
y - scaled_tile_size // 2
)
scaled_tile_lower_right = Point(
x + scaled_tile_size // 2,
y + scaled_tile_size // 2
)
if scaled_tile_upper_left.y < viewport_upper_left.y:
y += viewport_upper_left.y - scaled_tile_upper_left.y
elif scaled_tile_lower_right.y > viewport_lower_right.y:
y -= scaled_tile_lower_right.y - viewport_lower_right.y
if scaled_tile_upper_left.x < viewport_upper_left.x:
x += viewport_upper_left.x - scaled_tile_upper_left.x
elif scaled_tile_lower_right.x > viewport_lower_right.x:
x -= scaled_tile_lower_right.x - viewport_lower_right.x
bitmaptools.rotozoom(self._buffers["main"], source_bmp, ox=x, oy=y, scale=scale)
sleep(0.1)
for i in range(randint(16, 20)):
source_bmp = cheer_sequence[i % len(cheer_sequence)]
bitmaptools.rotozoom(
self._buffers["main"],
source_bmp,
ox=viewport_center.x,
oy=viewport_center.y,
scale=9
)
sleep(random() * 0.5 + 0.25) # Sleep for a random time between 0.25 and 0.75 seconds
bitmaptools.blit(
self._buffers["main"],
self._images["chipend"],
VIEWPORT_OFFSET[0],
VIEWPORT_OFFSET[1],
)
self.show_message("Great Job Chip! You did it! You finished the challenge!")
Page last edited August 22, 2025
Text editor powered by tinymce.
Audio
Getting the audio to work in such a large game had a few challenges. Most of the challenges was centered around getting the audio module to load in memory before the rest of the game. This is accomplished by initializing the audio first and attempting to play a sound. If this was not done, the video would briefly cut out while the audio was playing.
The audio board this project is centered around is theĀ TLV320DAC3100 breakout board, but you can use any I2S DAC that is supported by CircuitPython. For more information, check out the Wiring page. The audio device is initialized in code.py and is passed in to the Audio class along with a dictionary of sound effects. The sound effects are provided in code.py so that you can customize them with your own sounds.
The Audio class is pretty simple and involved initializing the class and a single public method to play a sound by passing a key from the SOUND_EFFECTS dictionary.
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import audiocore
from definitions import PLAY_SOUNDS
class Audio:
def __init__(self, audio_bus, sounds):
self._audio = audio_bus
self._wav_files = {}
for sound_name, file in sounds.items():
self._add_sound(sound_name, file)
# Play the first sound in the list to initialize the audio system
self.play(tuple(self._wav_files.keys())[0], wait=True)
def play(self, sound_name, wait=False):
if not PLAY_SOUNDS or self._audio is None:
return
if sound_name in self._wav_files:
with open(self._wav_files[sound_name], "rb") as wave_file:
wav = audiocore.WaveFile(wave_file)
self._audio.play(wav)
if wait:
while self._audio.playing:
pass
def _add_sound(self, sound_name, file):
self._wav_files[sound_name] = file
Page last edited August 22, 2025
Text editor powered by tinymce.
Preparing the Metro RP2350
The USB Host port is the only part of this project that required soldering and only if you use standard header pins. If you use the Solderless Press-Fit Male Pin Header in the Parts list, soldering may not be required if they are installed correctly.
The USB Host pin connections are highlighted on the Metro image to the left. You will need a small piece of 0.1 inch male header, with 4 pins, to fit the holes.
You can cut header with diagonal cutters or break them with pliers or even your fingers. Just be sure to wear eye protection as they can fly when cut.Ā
Put the short end of the header into the holes in the Metro marked USB Host.Ā
If you are using solderless header then they are press fit into the holes. You will need some pressure to get them in if they are the Press-Fit version, pliers will be required. While they are designed to make electrical contact, you might want to solder them to be sure.
If using standard header, secure them with putty, blutack, tape, etc.
Turn the Metro over and you should see the header barely poking out of the bottom of the board. If the pins stick through a great deal you may have the header pins upside down, double check the short end is sticking into the board.
Solder the 4 pin "nubbins" to the board.
Turn the board over and remove the material securing the pins. Now there is a new 4-pin header.Ā
Get the USB Host cable and wire as follows:
GRD to Black
D+ to Green
D- to White
5V to Red
Get the HSTX cable. Any length Adafruit sells is fine. CAREFULLY lift the dark grey bar up on the Metro, insert the cable silver side down, blue side up, then put the bar CAREFULLY down, ensuring it locks. If it feels like it doesn't want to go, do not force it.
Do the same with the other end and the DVI breakout. Note that the DVI breakout will be inverted/upside down when compared to the Metro - this is normal for these boards and the Adafruit cables.
Page last edited August 22, 2025
Text editor powered by tinymce.
Wiring the Audio
Most of the wiring involves the audio output. For this project, I chose the TLV320DAC3100, but other I2S Digital Audio Converters such as the PCM5100 should work fine. The main difference is you will need to update the initialization code in code.py.
For the output, you can connect the headphone jack to a pair of headphones or if you have a monitor with audio input, you could connect a 3.5mm stereo audio cable between the two. Additionally, if you have a speaker, you could either connect it to the JST connector or the speaker pins on the breakout.
Page last edited August 22, 2025
Text editor powered by tinymce.
CircuitPython for the Metro RP2350
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above).Ā Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Ā
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited August 22, 2025
Text editor powered by tinymce.
CircuitPython for the Fruit Jam
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above).Ā Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Ā
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited August 22, 2025
Text editor powered by tinymce.
Create Your settings.toml File
CircuitPython works with WiFi-capable boards to enable you to make projects that have network connectivity. This means working with various passwords and API keys. As of CircuitPython 8, there is support for a settings.toml file. This is a file that is stored on your CIRCUITPY drive, that contains all of your secret network information, such as your SSID, SSID password and any API keys for IoT services. It is designed to separate your sensitive information from your code.py file so you are able to share your code without sharing your credentials.
CircuitPython previously used a secrets.py file for this purpose. The settings.toml file is quite similar.
CircuitPython settings.toml File
This section will provide a couple of examples of what your settings.toml file should look like, specifically for CircuitPython WiFi projects in general.
The most minimal settings.toml file must contain your WiFi SSID and password, as that is the minimum required to connect to WiFi. Copy this example, paste it into your settings.toml, and update:
your_wifi_ssidyour_wifi_password
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"
Many CircuitPython network-connected projects on the Adafruit Learn System involve using Adafruit IO. For these projects, you must also include your Adafruit IO username and key. Copy the following example, paste it into your settings.toml file, and update:
your_wifi_ssidyour_wifi_passwordyour_aio_usernameyour_aio_key
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password" ADAFRUIT_AIO_USERNAME = "your_aio_username" ADAFRUIT_AIO_KEY = "your_aio_key"
Some projects use different variable names for the entries in the settings.toml file. For example, a project might use ADAFRUIT_AIO_ID in the place of ADAFRUIT_AIO_USERNAME. If you run into connectivity issues, one of the first things to check is that the names in the settings.toml file match the names in the code.
Here is an example settings.toml file.
# Comments are supported CIRCUITPY_WIFI_SSID = "guest wifi" CIRCUITPY_WIFI_PASSWORD = "guessable" CIRCUITPY_WEB_API_PORT = 80 CIRCUITPY_WEB_API_PASSWORD = "passw0rd" test_variable = "this is a test" thumbs_up = "\U0001f44d"
In a settings.toml file, it's important to keep these factors in mind:
- Strings are wrapped in double quotes; ex:
"your-string-here" - Integers are not quoted and may be written in decimal with optional sign (
+1,-1,1000) or hexadecimal (0xabcd).- Floats (decimal numbers), octal (
0o567) and binary (0b11011) are not supported.
- Floats (decimal numbers), octal (
- Use
\uescapes for weird characters,\xand\oooescapes are not available in .toml files- Example:
\U0001f44dfor š (thumbs up emoji) and\u20acfor ⬠(EUR sign)
- Example:
- Unicode emoji, and non-ASCII characters, stand for themselves as long as you're careful to save in "UTF-8 without BOM" format
Ā
Ā
When yourĀ settings.tomlĀ file is ready, you can save it in your text editor with the .tomlĀ extension.
In your code.py file, you'll need to import the os library to access the settings.toml file. Your settings are accessed with the os.getenv() function. You'll pass your settings entry to the function to import it into the code.py file.
import os
print(os.getenv("test_variable"))
In the upcoming CircuitPython WiFi examples, you'll see how the settings.tomlĀ file is used for connecting to your SSID and accessing your API keys.
Page last edited August 22, 2025
Text editor powered by tinymce.
Software Setup
CircuitPython Usage
To use the game, you need to add theĀ game program files to theĀ CIRCUITPY drive. The game consists of a handful of Python files, graphics, sounds, and fonts.
Thankfully, installing everything be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and game filesĀ in a zip file.
Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.
Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the entire contents of the appropriate CircuitPython folder to your CIRCUITPY drive. The program should self start.
The settings.toml File
Included in the game files is a settings.toml file. If you already have a settings.toml file that you want to keep, be sure to skip that file and add the following line:
CIRCUITPY_PYSTACK_SIZE = 2400
This increases the depth of the CircuitPython Stack, which is necessary to run this large game.
Drive Structure
After copying the files, your drive should look like the listing below. It can contain other files as well, but must contain these at a minimum.
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams
#
# SPDX-License-Identifier: MIT
import json
import time
import board
import supervisor
import audiobusio
from audio import Audio
import adafruit_pathlib as pathlib
import adafruit_fruitjam.Peripherals
from game import Game
from definitions import SECOND_LENGTH, TICKS_PER_SECOND
# Disable auto-reload to prevent the game from restarting
#import supervisor
#supervisor.runtime.autoreload = False
# Change this to use a different data file
DATA_FILE = "CHIPS.DAT"
SOUND_EFFECTS = {
"BUTTON_PUSHED": "sounds/pop2.wav",
"DOOR_OPENED": "sounds/door.wav",
"ITEM_COLLECTED": "sounds/blip2.wav",
"BOOTS_STOLEN": "sounds/strike.wav",
"WATER_SPLASH": "sounds/water2.wav",
"TELEPORT": "sounds/teleport.wav",
"CANT_MOVE": "sounds/oof3.wav",
"CHIP_LOSES": "sounds/bummer.wav",
"LEVEL_COMPLETE": "sounds/ditty1.wav",
"IC_COLLECTED": "sounds/click3.wav",
"BOMB_EXPLOSION": "sounds/hit3.wav",
"SOCKET_SOUND": "sounds/chimes.wav",
"TIME_LOW_TICK": "sounds/click1.wav",
"TIME_UP": "sounds/bell.wav"
}
# optional configuration file for speaker/headphone setting
launcher_config = {}
for directory in ("/", "/sd/", "/saves/"):
launcher_config_path = directory + "launcher.conf.json"
if pathlib.Path(launcher_config_path).exists():
with open(launcher_config_path, "r") as f:
launcher_config = launcher_config | json.load(f)
if "audio" not in launcher_config:
launcher_config["audio"] = {}
fjPeriphs = adafruit_fruitjam.Peripherals.Peripherals(
audio_output=launcher_config["audio"].get("output", "headphone"),
safe_volume_limit=launcher_config["audio"].get("volume_override_danger",.75),
sample_rate=44100,
bit_depth=16,
i2c=board.I2C()
)
if not hasattr(board, "I2S_BCLK") and \
hasattr(board, "D9") and hasattr(board, "D10") and hasattr(board, "D11"):
fjPeriphs.audio = audiobusio.I2SOut(board.D9, board.D10, board.D11)
# If volume was specified use it, otherwise use the fruitjam library default
if "volume" in launcher_config["audio"]:
fjPeriphs.volume = launcher_config["audio"]["volume"] # FruitJam vol 0.0-1.0
if fjPeriphs.audio is not None:
audio = Audio(fjPeriphs.audio, SOUND_EFFECTS)
else:
audio = None
adafruit_fruitjam.Peripherals.request_display_config(320, 240, 8)
game = Game(supervisor.runtime.display, DATA_FILE, audio)
tick_length = SECOND_LENGTH / 1000 / TICKS_PER_SECOND
while True:
start = time.monotonic()
game.tick()
while time.monotonic() - start < tick_length:
pass
Page last edited August 22, 2025
Text editor powered by tinymce.
Playing the Game
The goal of Chip's Challenge is to collect enough chips to make it to the exit at the end. This involves collecting various keys and boots to allow you to get past obstacles and monsters. Most of the keys are 1-time use except for the green key and boots stay in your inventory unless you step on the thief tile, in which case all collected boots are removed. In the default level set, there are 144 regular levels along with 5 bonus levels that you can only play by using the "Go to level" function along with the appropriate password.
Customizing the Game
This game can be customized in quite a few ways with most of the settings inside ofĀ code.py. Below are some fun places to customize the game along with the locations of where to change them.
Changing the Data File
You can choose a different level set by changing the DATA_FILE variable inside of code.py to point to the file location. This is so that you can have multiple files on the CIRCUITPY drive without needing to overwrite a single file.
Disabling Auto-reload
If you would like to play while having the board connected to a computer, some Operating Systems such as MacOS may periodically write to the drive and reset the game. By disabling Auto-reload, it will prevent the game from resetting. If you do edit any files with this setting active and want to reload, just unplug and replug in the board. You can do this by uncommenting the last two lines in following code inside ofĀ code.py. This is done by removing the # from import supervisor and the line after that.
# Disable auto-reload to prevent the game from restarting #import supervisor #supervisor.runtime.autoreload = False
Additionally, you may just be able to just eject the drive if you don't need to modify files.
Sound Effects
The included sound effects are the same sounds inside of the original game. If you would like to change these to you own, just place you files onto the CIRCUITPY drive and edit the SOUND_EFFECTS dictionary inside of code.py. Just be sure if you change the keys, to update them in gamelogic.py or the game will not run properly.
Playing Without Sound
If you would prefer the sounds don't play, you can change the PLAY_SOUNDS variable inside of definitions.py to False.
Timing Constants
If you would like to change the timing constants for the game, which control the speed of the game, you can change the TICKS_PER_SECOND and SECOND_LENGTH variables. Because of the limitations of the microcontroller, attempting to speed up the game likely won't have much effect, but slowing it down would in case you need to get past a section with tight timing.
Savestate File
If you would like to change the filename of the savestate file, you can change the SAVESTATE_FILE variable inside of savestate.py. By default, this is chips.json, but could be renamed to something else to allow switching between multiple savestate files. If you would like to edit or backup the savestate file, you can place the SD card into a computer and open the file for editing. However, be certain you are using valid JSON or the file will be overwritten.
Hotkeys
The game is played using the arrow keys as well as a few other hotkeys. A hotkey is just a keyboard shortcut to perform an action. Here are the ones used during normal gameplay:
- Ctrl+R: Restart Level
- Ctrl+N: Next Level
- Ctrl+P: Previous Level
- Ctrl+Q: Quit
- Ctrl+G: Go to Specific Level
- Spacebar: Pause/Unpause Game
When typing in passwords to go to a specific level, you can press the TAB key to go to the next field.
Passwords
Levels can be unlocked with passwords. If you would like to jump to a specific level, you can find lists of passwords around the web.
Custom Data Files
If you would like to play levels beyond the included ones, a web search will yield many options. To play these custom level, just copy the files to the CIRCUITPY drive and edit the DATA_FILE variable inĀ code.py to point to the correct file location.
Editing Data Files
If you would like to create or modify your own levels, there are a number of editors available that allow you to make your own level sets or modify any existing ones.
Possible Issues
It's possible you may encounter a PYSTACK exhausted error and the solution to that is to go into the settings.toml file and increase the value of CIRCUITPY_PYSTACK_SIZE. I tried setting it at a reasonable value, but increasing this value too much could result in having too little RAM to run the game.
If you find a bug and would like to submit a fix, the files can be found inside of the Adafruit_Learning_System_Guides repository on GitHub.
Improvements
This game could likely be further optimized just by using displayio as many of the things handled manually could be implemented with displayio such as the partial screen updates and better use of controls such as buttons and text boxes. This was left out due to time constraints as well as needing some additional features that were not currently available in the libraries such as the dialogs themselves.
Page last edited August 22, 2025
Text editor powered by tinymce.

