CC Blog Projects Research & Design Hub

A Modern Take on a Classic Handheld Gaming Device

Using an RP2040 Chip, GPS, Music, and More

“Snake” was a 1970s arcade video game that was later used on a handheld device. In this article, two Cornell University undergrads tell how they put a modern spin on the old classic game and created PicoBoi, a stand-alone, handheld device using a Raspberry Pi RP2040 chip, a PCB, and adding a GPS system, original, retro-sounding songs, and other features that were not even imaginable 50 years ago.

  • What is a fun project I can build with an RP2040?
  • How can I build my own handheld gaming system?
  • What are cool features of the RP2040?
  • RP2040
  • GPS module
  • Adafruit 
  • Raspberry Pi

Drawing inspiration from the handheld gaming devices of our youth, we utilized the Raspberry Pi RP2040 chip to recreate that experience with some cool and unique additions. Our “PicoBoi” comes packed with a wide range of features, all available in a compact form factor. These features include a snake game with original music, accurate time, and GPS navigation. The PicoBoi culminates many ideas, tools, and protocols covered in our Cornell Electrical and Computer Engineering Microcontrollers course. We intended to create a stand-alone device and platform capable of hosting various applications. Through our implementation of “snake,” GPS navigation, and audio, our proof-of-concept feature set shows that with some creativity and ingenuity, the sky’s the limit for the PicoBoi.

HARDWARE

We designed the PicoBoi to be a standalone device that does not require a tethered computer or power source connection. As shown in the block diagram in Figure 1, PicoBoi is composed of a Raspberry Pi RP2040 microcontroller, a 240×320 TFT screen, a 3.7V lithium-ion polymer (lipo) battery with a charging system, and a USB-C lipo charger. We placed a switch between the Pico and the charger’s output, to allow the user to turn the system on and off. We also utilize a coin speaker attached directly to a GPIO output pin. We do not use an amplifier to drive our speaker, because the music we are playing does not need high resolution and volume. Each button is connected to its corresponding GPIO pin and the 3.3V power supply pin on the board to make it active high.

A Modularized Design: To decouple our subsystems, we divided the hardware into three components: the Control Pad, the Button Pad, and the Screen. We organized the jumper wires on the Button Pad and Screen together using tape, and put the pins on the Control Pad together to connect with them (Figure 2). This way, we can modularize the whole system by separating the pad with those organized pins and connectors

The Control Pad contains most of the electronics, including the charging system—consisting of a type-C charging board and a power switch. The traces are created using solder or linked by flying wires.

As shown in Figure 3, the Button Pad consists of a protoboard, on which we soldered six buttons. Each button has multiple functions, but generally, X is for confirming, Y is for exiting, and ABCD is for menu navigation and gameplay.

We developed several prototypes for the case, with the first few being too small, because our socket design took too much vertical space on the control board. We designed the case using the OnShape MCAD tool, and printed it with a resin 3D printer (Figure 4). The case consists of two parts that snap together. We left space for the Control Pad, the Screen, and the charging/debug port.

FIGURE1
Block diagram of the PicoBoi hardware system.
FIGURE1
Block diagram of the PicoBoi hardware system.
FIGURE 2
Close look of the Control Board, jumper wires are organized using tapes.
FIGURE 2
Close look of the Control Board, jumper wires are organized using tapes.
FIGURE 3
Close look of the Button Pad, being connected using the taped jumper wires.
FIGURE 3
Close look of the Button Pad, being connected using the taped jumper wires.
FIGURE 4
PicoBoi being housed in the resin, 3D-printed case.
FIGURE 4
PicoBoi being housed in the resin, 3D-printed case.

With all the components assembled, we could integrate the system such that the PicoBoi was finally in one piece!

PCB Is the New Home: Our next step was to turn our prototype into a more permanent design. Using a PCB allowed for sturdier connections between parts and a more compact form factor.

A challenge we encountered was finding the correct design files for our Pico and other boards. Unfortunately, often PCB design software tends to be more conducive for chip-level design and lacks compatibility for breakout board connection. However, we found that JLCPCB’s design software contained some of the design files for us to use. This way, we could put most of the components onto our schematics, except for the charging module and the screen module; for these, we created the corresponding parts by creating an array of pin holes.

As you can see in Figure 5, we planned the layout such that the USB connector of the charger and the Pi Pico could be reached easily.

FIGURE 5
PicoBoi finds its new home on a PCB.
FIGURE 5
PicoBoi finds its new home on a PCB.
SOFTWARE

We wanted a framework capable of running multiple applications without mutual interference. To facilitate this, we chose to use the threading library Protothreads [1]. Thus, each app, including the menu, is contained in different threads. It works similarly to a small operating system, in that we have an overall resource management system underneath. This way, we can add or replace applications without worrying about other existing applications.

System Overview: Code for this project is available on the Circuit Cellar Article Code and Files webpage. As shown in the flow chart in Figure 6, the main() function is responsible for initializing the system, which includes setting up GPIO functions/interrupts, the repeating timer, and the threads. We have five threads running on core 0—one for each application and an additional thread for the menu. Alongside the threads, we also have three interrupt handlers: the RX interrupt handler, the repeating timer interrupt, and the button press interrupt.

FIGURE 6
Flow chart of the PicoBoi software system.
FIGURE 6
Flow chart of the PicoBoi software system.

Even though the RP2040 is a dual-core microcontroller, there are few processes that we need to run at the same time; therefore, we utilize only one core.

The State Machine and Variables: A global variable is declared to keep track of the system’s current state; for example, we use -1 to indicate the system is in the menu, 0 for in app0, 1 for in app1, and so on. Each thread first checks if the current state is the application to which this thread corresponds. If not, the program will ignore this thread.

For each application, we have declared a binary initialization variable. This variable indicates whether it is the first time we are executing this thread, and the thread runs some initial setups based on the value of these variables.

Buttons: The buttons on the Control Pad are the primary way users interact with the system. That’s why it was so important to ensure that the buttons behave as the user expects.

The Debouncing Algorithm: We used a debouncing algorithm to ensure the button presses reflect the actual state of the button. This prevents a single press from being detected multiple times. We achieved this behavior using a repeat timer interrupt triggered every millisecond. In addition, we declared a cool-down variable for each of our six buttons. The button is invalid if the cool-down variable is larger than 0. Every valid press of the button will reset this cool-down variable to a predetermined value.

The Button Callback Function: All six-button GPIOs share a single callback function that triggers on the rising edge of each button press. This callback function carries the corresponding GPIO number, which triggers it and checks the cool-down variable to validate the button press. The response to the button press depends on the current state of the system, that is, the current application, with the exception of button Y, which serves as an exit button and is valid throughout the system. When the Y button is pressed, it resets its cool-down variable, sets all initialization variables to 1 to exit all apps, and turns off any playing music.

MENU

The menu is the central hub of the PicoBoi system, allowing users to navigate between different applications (Figure 7). To create this menu, we developed a library based on the graphics library screen we are using [2]. Using this library, we could draw outlines and logos for each application and create selectors (the blue outline) for user navigation.

FIGURE 7
The Menu screen.
FIGURE 7
The Menu screen.

To create logos of the correct size, we used Adobe Photoshop. We then converted each pixel to a byte value, and formulated the entire image into a byte array using an online tool [3]. Finally, we stored these byte arrays in our header file.

Menu Thread: The menu thread is responsible for drawing the menu and responding to user input. When the system enters the menu state, the thread initializes by drawing a black background and the outlines and logos for each application. It also sets the currently selected app to app0.

During each execution of the thread, it checks if the currently selected app matches the app requested by the user. If they are different, it calls functions from the library to remove the previous selector, draw the new one, and update the currently selected application. The button callback function handles user input and updates the app selection accordingly, using a semaphore approach to avoid interrupt delays. The Y button exits the current application and returns to the menu.

THE SNAKE GAME

“Snake” was a classic arcade video game created in the 1970s [4]. In it, a player maneuvered the end of a growing line (resembling a snake) trying to pick up food, while avoiding contact with the edges of the playing area or its own tail.

To create the game logic for our snake game, we started by writing a library that contained all the necessary functions and structures. Our snake type includes several arrays and variables to keep track of important information during the game, such as the x and y coordinates of the snake body, the direction of the snake head, the length of the snake, and the location of the food.

To ensure that the game runs smoothly and with consistent timing, we implemented a cool-down variable decremented in the timer callback function, controlling the game’s frame rate.

The Snake Thread: When the snake thread starts, it first checks if it is the first time it starts; if so, it does initialization, such as drawing the arena boundary and black background and setting the initial values, and the initial body and head of the snake is drawn.

During gameplay, a countdown timer is used to control the length of each frame of the game. If this timer counts to zero, the next frame is ready to be executed and the tail of the snake is removed. Then we check the location of the snake to see If the game continues; if so, we draw the snake and reset the frame countdown variable. Otherwise, we switch to the game over state.

Snake Game Logic—The Real Random Number Generator: We intended to spawn food in our snake game randomly. However, if we were to call the random function, it would give the same number every time the board boots up because the numbers are calculated with the same seed every time. To make our food spawning truly random, we used the RP2040’s ring oscillators, an odd-numbered series of NOT gates. By reading the ring oscillator’s output, we get a result that is not predictable.

By using this feature, we were able to create a function with an input of integers ranging from 0 to 32, and the function outputs a fix15 ranging from 0-216 (if unsigned int the range would be 0-232). ROSC_BASE + ROSC_RANDOMBIT_OFFSET are the register addresses for the random bit value for the ring oscillator, and we can either include the corresponding header file, or just define them ourselves. Thus we declared a pointer to this register and made it volatile. Depending on the value of the random number we want, we run a for loop, in which we read the value of that random bit and shift that bit into the random number we want to output (see Listing 1). This operation should be fairly efficient, since the shift operation is very cheap.

LISTING 1
True Random number generator using RP2040 ring oscilators.

#define ROSC_RANDOMBIT_OFFSET _u(0x0000001c)#define ROSC_BASE _u(0x40060000)// return random number by ROSC between 0-2^N// N should be in [0,32]unsigned int ROrand(int N){  static volatile uint32_t *randbit_reg = (uint32_t *)(ROSC_BASE + ROSC_RANDOMBIT_OFFSET);  unsigned int random = 0;  for (int i = 0; i < N; i++)  {    unsigned int random_bit = 0x1 & (*randbit_reg);    random = random << 1 | random_bit;  }  return random;}

Snake Mechanics: To calculate the new location of the snake’s head, we consider the direction in which it’s moving. Then, we use a temporary location array to set the first element as the new head, and move the previous locations to the rest of the array. This allows us to calculate the position array for the next frame. We also use this new head location to check whether the game is over if the snake hits a wall or its own body or its length exceeds the maximum allowed length.

We generate the location of the food using a random number generator. If the snake’s head hits the food, we remove the old food, copy the temporary array to the snake’s field, and increase its length. If it doesn’t hit the food, we don’t keep the tail, and the length is unchanged.

We change the current state to the “game-over” state to handle game-over scenarios. This causes the game-over screen to be displayed, along with the score showing the snake’s length before its untimely demise (Figure 8).

FIGURE 8
Snake Gameover demonstration.
FIGURE 8
Snake Gameover demonstration.

Snake Settings: The difficulty of the snake game depends on the frame update speed; the faster the snake, the harder the game is. The selection of the difficulty UI reuses the menu graphic library. It changes the frame cool-down variable that resets.

GLOBAL POSITIONING SYSTEM (GPS)

To communicate with the GPS module, we wrote our own GPS library. The existing Adafruit library depended on the Arduino filesystem and was written in C++. The GPS module constantly sends GPS messages through the UART channel. We have a UART RX interrupt handler for the event of receiving characters from the UART channel. For the GPS struct, we have a buffer array called watch_dog to store the latest data from the GPS, a character array stores the Coordinated Universal Time (UTC), three integer variables store the current hour, minute, and second in Eastern Standard Time (EST), and two float variables store latitude and longitude information.

In the RX interrupt handler, we call buffer_shift(), a function that detaches the oldest char in watch_dog and attaches the new incoming one. This way, the buffer constantly contains the latest GPS messages. For each batch of GPS messages, there are many National Marine Electronics Association (NEMA) sentences, and each gives different information. The one we care about starts with GNRMC, which contains the UTC, fix status, and location information.

We check the first five characters in watch_dog; if that is GNRMC, we extract the time, location, and fix status by parsing the characters at different intervals with different rules. With the Real-Time Clock (RTC) battery, the GPS module always sends the valid time if it at least got a fix before. As the interrupt function runs, the field in the GPS struct keeps updating itself, and we can get that information as needed.

The SatClock and SatMap: Two applications use the GPS functionality. The SatClock app has an indicator showing whether the device got a fix and a timer showing the EST. We also took advantage of a built-in function in the screen library that allows us to specify the background color and update the string without the flicker effect.

The SatMap app displays a map of the Cornell campus as the background, and shows the user’s current location as a blue dot if there is a fix (Figure 9). We chose not to remove the dot so the application will display the trace of the user. We have a conversion function hardcoded for the offline projection calculation. To create the bitmap, we took a screenshot of a piece of the Cornell campus map from Google Maps and converted it to a byte array, as we did for the logos. Then, we repeatedly poll the current latitude and longitude information from the GPS struct, convert it to the corresponding pixel values on the screen, and draw it with a blue dot.

FIGURE 9
The Cornmap app, which shows a part of the Cornell Campus, displays the user as a blue dot if the GPS can find a fix.
FIGURE 9
The Cornmap app, which shows a part of the Cornell Campus, displays the user as a blue dot if the GPS can find a fix.

Equirectangular Projection: The latitude and longitude information we have describes a point on a sphere; however, to display that on a 2D flat surface (our screen), we need to project it onto a plane. The area we’re going to display on the screen is fairly small (the Cornell Campus), so we used a simple projection called the “equirectangular projection.” Let us use R to denote the radius of the earth in meters, λ as the longitude of the point we want to project, φ0 as the latitude of the point, and we collect a center point from the map, where φ0 is the latitude of the center point and λ0 is the longitude of the center point, we can have projection:

We can calculate the x,y value of the top-left and bottom-right points of our map, then do a two-point linear regression to convert the x,y value to pixel locations on the screen.

AUDIO

Our goal of creating retro-sounding music enabled us to forgo using a digital-to-analog converter and complex music synthesis. We instead created a simplified music library that generates sound using square wave outputs from a GPIO port configured as a PWM channel. Using a repeating timer interrupt and a repeating timer callback function, we can easily configure note duration and facilitate PWM frequency transitions so that it creates music. We decomposed all the notes we intended to use into their respective frequencies, which were played through our speaker by adjusting the wrap values of the PWM channel, by dividing the number of clock cycles per second by our desired frequency in Hertz.

We manipulated volume by changing the PWM channel level. Each note’s duration is determined by the number of interrupts, where one interrupt corresponds to a quarter note, two corresponds to a half note, and so on. We created a pause between notes by cycling through a state machine. The first state is responsible for playing notes, and the second is the pause.

With this implementation, we constructed songs using an array consisting of all the notes we want to play in the appropriate order, and a second array of equal-length storing values that indicate the duration of each of those notes. This allowed us to iterate through the arrays and change notes by resetting the wrap value and duration by adjusting the number of interrupts before the next change.

MOVING FORWARD

In our design of the PicoBoi, we were methodical in our design such that it is capable of hosting a wide range of applications with various features. This is evident in how we chose to multithread our applications such that they can run in isolation and lack dependencies on other modules. In the future, we hope to design and construct a simple framework that will allow applications to be developed and loaded on the PicoBoi. This will enable users to build applications and explore the PicoBoi’s capabilities in a manner in which the only limit is their creativity. 

— ADVERTISMENT—

Advertise Here

REFERENCES
[1] Protothreads.  https://dunkels.com/adam/pt/
[2] TFT Screen lib modified by Syed:
http://tahmidmc.blogspot.com/2014/10/interfacing-color-tft-display-with.html
 [3] img to byte array: https://www.mischianti.org/images-to-byte-array-online-converter-cpp-arduino/
[4] Snake (Video Game Genre). https://en.wikipedia.org/wiki/Snake_(video_game_genre)

SOURCES
Land & Adams, “ECE 4760 Course Webpage,” https://ece4760.github.io/, 2022
 Pi Pico Pinout and Datasheet: https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html#raspberry-pi-pico

RESOURCES
Adafruit | www.adafruit.com
Raspberry Pi | www.raspberrypi.com

Code and Supporting Files

PUBLISHED IN CIRCUIT CELLAR MAGAZINE • DECEMBER 2023 #401 – Get a PDF of the issue

Keep up-to-date with our FREE Weekly Newsletter!

Don't miss out on upcoming issues of Circuit Cellar.


Note: We’ve made the Dec 2022 issue of Circuit Cellar available as a free sample issue. In it, you’ll find a rich variety of the kinds of articles and information that exemplify a typical issue of the current magazine.

Would you like to write for Circuit Cellar? We are always accepting articles/posts from the technical community. Get in touch with us and let's discuss your ideas.

Sponsor this Article
+ posts

Deemo (Yizhou) Chen (yc938@cornell.edu, DMO.page) is a senior at Cornell University studying Electrical and Computer Engineering. He is expected to finish his degree in May of 2024. He is interested in low-power devices and signal/system analysis. He is an Altitude Control System Engineer at Cornell Space System Design Studio Research Lab, involving the Alpha Cubesat project.

Sabian Grier (srg293@cornell.edu) is a senior at Cornell University studying Electrical and Computer Engineering. He is expected to graduate in May 2024. He is interested in embedded systems design for accessible technologies.

Supporting Companies

Upcoming Events


Copyright © KCK Media Corp.
All Rights Reserved

Copyright © 2024 KCK Media Corp.

A Modern Take on a Classic Handheld Gaming Device

by Deemo Chen and Sabian Grier time to read: 14 min