ECE 5730 Final Project:

Pico Barrier Blitz

by Sanath Kumar (sk2794), Chris Yang (cy524)

Introduction

For our project, we created a fun, one- or two-player, arcade-style game where players can compete to outlast one another, or simply try to set a high score. We replicated a game similar to Pico Park Level 12-3. The game offers the choice between a single-player mode or a two-player mode in which each player tries to complete an obstacle course. Similar to Flappy Bird, this game randomly spawns barriers that each player’s character must go through. Colliding with any of the barriers results in the player losing. As the game progresses, the level of difficulty increases: tunnels get narrower, the game speeds up, and so does the background music! The game keeps track of the player’s current score and highest score. In multiplayer mode, the game ends when either player fails to avoid a barrier.

High-Level Design

Rationale and Sources of Project Idea

When discussing ideas for this project, we both agreed that Lab 2 was the most interesting lab of the three labs we completed in class because of the interfacing with the VGA screen. We wanted to implement a two-player obstacle course game visualized on the VGA screen to see which player can get the highest score. We decided to incorporate into our project unique aspects from all three labs: the DAC to output sound from Lab 1, the displaying of movement from Lab 2, and the constant updating of the VGA screen from Lab 3.

Background Math

The main piece of math we had to understand was that the VGA screen displays pixels 640 horizontally x 480 vertically, with reference of (0,0) being the top left of the screen, meaning the positive x-direction moves left to right, and the positive y-direction moves downwards. This concept was used almost everywhere to draw barriers, and player models.

Logical Process: Thread

The game is all organized into our thread. The game starts by displaying a start menu, which is done by calling the StartGame() function, which allows for players to select a 1-player mode or 2-player mode. The program saves which mode is highlighted prior to the button push, then spawns the appropriate number of players. The program then enters a while(1) loop for the gameplay to begin. The program's loop calls UpdateBarriers(), which processes the spawning of barriers and moves them across the screen. The loop also calls MovePlayer1() and MovePlayer2() (if it is in 2-player mode) which processes player movements by polling the joystick's GPIO pins. Collision detection is processed in UpdateBarriers() and sends a flag to the thread if a collision occurs. If a collision is detected, the loop breaks, and the game ends. X's are drawn on the eyes of a player that died. The thread plays the death sound, and calls EndGame() to display the end game menu, and the program waits until the button is pressed to bring the game back to the start menu screen. See the Software Design section below for further details on each function.

Hardware Design

Figure 1: Pinout of the Raspberry Pi Pico

Figure 2: Real Breadboard

Figure 3: Breadboard Diagram

Table 1: Hardware Components

The main piece of hardware we used was the Raspberry Pi Pico, an RP2040-based microcontroller board which was used to interact with all other components described in Table 1. A breadboard was used to make these connections. Figures 2 and 3 show the connections, in a real picture of our board and in a diagram. Along with HSYNC, VSYNC, and ground, we connect the red, green, and blue VGA pins to the RP2040 through 330 Ohm resistors to create a voltage divider that sends a safe output voltage range to the VGA.

We connected a simple pushbutton that reads 1 when not pressed, and 0 when pressed. On the start screen, this button is used to select single player or two player mode, which starts the game. Upon a player's death, the button is used to return to the menu screen and select mode again.

We used the MCP4822 12-bit DAC with an audio jack and speaker to play our background music, as well as our death sound effect. Our code runs through a digital array of sounds, and the DAC transfers those values to the speaker as an audio output.

Finally, we used two digital joysticks to control the movements of players 1 and 2. The joysticks have 5 connections: down, up, left, right, and ground. For an easier connection, the player 2 joystick is plugged in backwards, so the connections are mirrored compared to player 1. The joysticks are orientated correctly when the wires are coming out of the left side.

Software Design


Start Menu Screen

The StartGame() function is responsible for displaying the start menu and allowing for the selection for 1-player mode or 2-player mode. The program draws an upscaled display of each player's character model and highlights to 1-player by default. If a down joystick movement is detected, the program draws a black box around the current highlight and draws white box around the other mode to switch the highlight. Similarly, this process is done to switch back to 1-player mode by moving the joystick up. Pressing and releasing the button would start the game in that specific mode. See Figure 4 below for a picture of the start menu screen.

Figure 4: Start Menu

Player Movement Processing: Joysticks

As mentioned previously, the joysticks contains 5 pins, 4 of them to move the player up, down, left, right, and one connected to ground. These pins are active-low, meaning a joystick movement in one direction closes a switch and pulls it to ground. Therefore, we know if the player is moving up if (gpio_get(11) == 0). The player's positions are stored in structs, as the boids were in Lab 2. Each struct contained variables for their x and y positions, as seen in Figure 5 below.

Figure 5: Player Struct

Given the pin layout, there are 8 possible ways for players to move: straight up, up left, straight left, down left, straight down, down right, straight right, or up right. Therefore in MovePlayer1/2() functions we account for all ways for the player to move, and update their x and y positions by 10 accordingly. For example, if a player is moving straight up, we would move the player's y-position by -10, and if they were moving down right, we would move the player's x and y-positions by +10. We also did this to move the player's eyes, adding an offset of +/-3 pixels depending on the direction the player is moving so that the player's eyes looked where the player was moving.

Spawning Barriers

When the game starts, either 1 or 2 players are spawned, along with the first barrier. Each barrier has 6 main parameters: xcoord, barrier_length, tunnel_height, top_height, bottom_height, and gap_length. xcoord traverses the VGA screen right to left, from 640 to 0, drawing the current state of the barrier. barrier_length, the length of one barrier, is a randomized value between 100 and 500 pixels, calculated by finding a random number between 0 and 400, and adding 100 to it. tunnel_height, the vertical distance between the upper and lower barriers, starts at 200 pixels at spawn, but gradually decreases. top_height, the vertical height of the top barrier, is a random value between 0 and 480-tunnel_height. Since the VGA screen has 480 pixels on the y axis, there are 480-tunnel_height pixels left for barriers. After top_height is decided, bottom_height is calculated by (480-tunnel_height) - top_height. Finally, we decided gap_length, the distance between two barriers, would be a random number between 150 and 500 pixels, calculated similarly to barrier_length. Taking the smallest possible values for lengths, with a 100 length barrier followed by a 150 length gap, the most barriers that can appear at once on the VGA screen is 3. Therefore, we stored all these parameters in arrays of length 3, one for each barrier. They are controlled by a different array of length 3, active_barriers, which starts at {1,0,0}, since only the first barrier is active at spawn.

Updating Barriers

After the declarations, the rest of the updating and drawing is done in a for() loop which goes through all 3 potential barriers, and enters the loop if active_barriers[i] = 1. The loop begins by erasing the previously drawn barriers by drawing over them in black. xcoord then travels right to left by a parameter speed, which starts at 5 pixels, but increases as the game progresses. After each movement, the new barriers are drawn in white. Figure 6 shows a frame during gameplay between two barriers.

Once xcoord reaches 0, we can't continue to draw at a negative pixel coordinate, so instead we start decreasing barrier_length[i], so the barrier appears to continue moving right to left, but really it is shrinking. As the barrier fully reaches the left side of the screen, all parameters are reset back to their default values. When the right edge of a barrier has moved gap_length pixels away from the right side of the screen, the next barrier is activated. Every 3 barriers passed, tunnel_height decreases by 10, and every 5 barriers passed, the game speed increases by 1.

Figure 6: Gameplay

Collision Detection

Collision detection works slightly differently for 1 player vs 2 player mode. For single player, if the player's x position is past the right edge of the barrier, the counter barriers_passed is incremented once. For two player, if either player's x position passes the barrier, the counter is incremented. barriers_passed also serves as the current score displayed in the top right. The game detects a collision when a player's x position is within the length of the barrier, and their y position is within either the top or bottom barrier. To account for quick transitions or edge cases, these are implemented with ≤ and ≥ instead of just = statements. In 1 player mode, the endgame flag is set to 1, and the game checks if the current score is higher than the high score, and updates it if necessary. In 2 player mode, along with those two steps, the game also sets a player1win or player2win depending on which player died. These flags are used again in the EndGame() function.

End Game

When the player dies, the death sound is played and we draw X's over the player's eyes to indicate that they died. The EndGame() function is also called in the thread. Here, a black rectangle is drawn in the center of the screen and text is written to display which player died in 2 player mode, or just "you died" in 1 player mode. It also tells the players to push the button to return to the main menu. See Figure 7 below to see the end game display.

Figure 7: Death Screen

Audio

Configuring the audio first required getting a background music to play during a game. After choosing music we liked (see Appendix C), we had to shrink the audio into 25 seconds so it would not be too long, since we only have 2MB of flash on the Raspberry Pi Pico board. The MP3 file was then converted into a .wav file so that we could run a Python script to convert it to a C array, using this code here: GitHub - protongamer/WAVC_Converter: Convert WAV file in C array. Originally, this script would not run because the sound was recorded in stereo mode, and the Python file only works with a mono format. Therefore, after doing a bit of searching, we were able to use a free audio editing software, Audacity, to convert this audio piece into mono mode while also compressing it to 8-bit audio at a sampling rate of 16kHz, so that it would just fit in flash. See Figure 8 below for a screenshot.

Figure 8: Screenshot of Audacity Configuration

Next, we ran the Python script so that the sound could be stored as a C array. Since the DAC accepts 12-bit inputs, we padded an extra 4-bits as LSBs (adding a 0x0 at after each byte) to change it to 12-bit audio. We then added 4 more configuration bits as the most significant bits to tie it to Channel A of the DAC, making the file into an array of uint16_t's. This was used for the game's background music as well as the game-ending death sound. We then used Hunter's DMA example to place the audio array into a DMA channel that shuffles each element of the array into the SPI channel connected to the DAC, playing to the speaker. We configured the audio speed using the dma_timer_set_fraction() function, which allowed us to change how often the DMA channel will trigger a transaction. We started the game with a 16kHz frequency, which matched the sampling frequency the audio was recorded in, and sped it up to 18kHz after 15 barriers were passed and 20kHz after 30 barriers were passed. This produced a noticeably faster sounding background music after 15 and 30 barriers passed, denoting that the game was going to speed up and get more difficult. The background game music would start playing once the game begun (once the button is pushed from the start menu) and would continue playing until a player died. When the game ends, a crashing death sound is played to denote that a player crashed into the barriers. This was accomplished by aborting the currently playing DMA channel once a collision was detected and configuring a new DMA channel pointing to samples from the death sound's array. Since we only wanted the death sound to play once, we would start the DMA channel, yield the thread for the duration that the sound is played (375ms), then abort the channel.

Results

Overall, our game works flawlessly. Our algorithm perfectly spawns barriers in random positions and moves them across the screen fluidly. This is because we maintained a frame rate of 30 FPS, yielding for extra spare time in each frame (each run of the thread) as we did in Lab 2. Any player colliding with any of the barriers resulted in instant death. There are no noticeable delays between audio starting and stopping either. The speed of our game is also well-controlled as the beginning is relatively easy, which allows the player to get used to the movements and barriers spawning, while the game gets progressively harder for as each barrier is passed. However, if you do look closely, you may notice some flickering of each barrier and player model on the screen, which occurs naturally because we are erasing and drawing new barriers and player positions each frame. And because they take up such a large space on the screen, it takes time to do all of this every frame and it most likely does not happen fast enough that we notice flickering. As previously said, the human eye cannot see the drawing and erasing of barriers and player models of each frame. But interestingly, if you pause the video recording of our game, you will notice that a new barrier has been drawn before the current one is erased in a frame. See Figure 9 below for an example.

There were no safety concerns for this project, other than not shorting circuits, which can potentially make things blow up. We made sure all our wiring was discrete so that this would not happen. See Figure 2 for a picture of our circuit. Once set up (plugging in VGA pins, joystick pins, and speaker), our game is very simple and easy to play! One thing we had in mind is that this game should be color-blind friendly as the 2 players' colors are well contrasted.

Figure 9: Paused frame of video recording

Conclusion

To conclude, the end result was better than we had envisioned. We weren't sure how much we would be able to accomplish in just over a month, but we impressed ourselves with our final product. We originally started with getting the gameplay to work, like spawning players and barriers, and detecting unit collision. We then added the start/end menu screens and getting the transitions to work with a button press. Next, we spent time on incorporating sound in our game. Lastly, we spent the last week polishing our game by developing character sprite models, adding death visuals and a death sound, and fixing remaining bugs with our game.

If we had more time with this project, we would look into making the barriers look nicer, but this was not achievable in our time frame because of its given complexity. We also could have added more audio, such as sound effects for player movements or passing barriers. This may have been achievable if we had more flash memory (we only had 2MB of flash on the Pico). One way to get around this is to use two Pico boards.

All equipment we used for this project was allocated for this class in Phillips 238 Lab. We did not have to make any purchases for this project. We got the idea to use joysticks for our controllers from a previous project, as referenced in Appendix C.

See below for our demo explaining the final version!

Appendices

Appendix A: Permissions

Appendix B: Program Code

Final Project Code

Appendix C: References