Game of 24

Sean Zhang (swz9), Ray Chang (cc2246), Shizhe Shen (ss4335)

Demonstration Video

Project Introduction

Our digital recreation of the Game of 24 brings the classic arcade experience with a custom interface that blends nostalgic gameplay with a custom built, user-experience focused design.

The Game of 24 is a classic mathematical puzzle where players must use four given numbers and basic arithmetic operations (+, -, *, /) to form an expression that equals 24. Traditionally the game is played with a deck of cards, where each turn the players will draw four random cards and compete for the fastest speed to calculate to 24. Instead of racing to solve a single round, our implementation tracks the number of collections of cards solved within a configurable time interval.

The arcade experience is curated with a custom built joystick-based controller, retro-style graphics with the VGA screen, and a persistent leaderboard stored in non-volatile memory. The game is player vs player similar to the versus style arcade games. Difficulty can be adjusted at run-time and a group of 4 numbers of the suitable difficulty are calculated at runtime. Card dealing, flipping animations and sound feedback contribute to the immersive experience.

High Level Design

Rationale Behind Implementing the Game of 24

We chose the Game of 24 because it’s a fun, classic puzzle that we personally enjoy, and it offered a great mix of hardware and software challenges. It allowed us to go beyond a basic game, turning it into a feature-rich product with a custom interface, optimized performance, and room for additional features. This project gave us the freedom to transform a simple concept into a polished, well-developed game.

Hardware/Software Tradeoffs

Our implementation of the Game of 24 uses both cores on the microcontroller, with each core dedicated to managing the gameplay experience for each player.

On the hardware side, each core of the microcontroller is tasked with managing the joystick input, rendering animations, and updating the VGA display for its assigned player. This parallel processing ensures that each player’s actions are processed without interference, providing a smooth and responsive experience. The use of multiple cores for real-time input and display updates significantly reduces latency and allows both players to interact simultaneously without any noticeable delay. However, this multi-core approach introduces some software complexity. For shared memory, concurrency protection mechanisms such as locks and semaphores are used to prevent any undefined behavior.

Software Design

Algorithm for Generating Solutions

The core idea behind the 24-point solver is to exhaustively evaluate all valid arithmetic expressions formed from a given set of four integers. The algorithm applies the basic operations +, -, *, and /, and explores all permutations of the numbers (4! = 24), operator combinations (4³ = 64), and six distinct parenthesis patterns that define evaluation order. Each configuration is numerically evaluated to check whether it equals 24 (within a small epsilon tolerance), ensuring that all mathematically valid solutions are captured through brute-force enumeration. To implement this algorithm, we began with an open-source solver, which determines whether a solution exists for a given set of four numbers. I then extended the code to output all valid expressions rather than just confirming existence. Additional improvements included filtering out duplicate solutions, avoiding intermediate negative values, and counting the number of unique solutions. I also introduced a difficulty rating system (easy, medium, hard) based on the number and complexity of solutions, and added functionality to generate random problem sets matching a specified difficulty. These enhancements made the solver both comprehensive and adaptable for dynamic puzzle generation within the game.

mac_address
6 different parentheses groupings

Efficiently Drawing Images to the VGA Screen

Images are needed for displaying cards and the 24-logo.

A Python script that converts an image into a 2D C array suitable for VGA rendering. This script uses the Python Imaging Library (PIL) to open the image, resize it according to specified width and height ratios, and then iterates over each pixel. For each pixel, it maps its RGB values to a corresponding 4-bit color value. The resulting 2D array stores these color values, and the script outputs the array in a format directly compatible with C, making it ready for use in VGA rendering.

Initially, we displayed assets on the VGA screen by iterating through each 2D C array, calculating, and setting each pixel individually by setting the upper 4 or lower 4 bits of the vga_data_array. However, this approach proved too slow when sliding or flipping the image.

To optimize, we leveraged the fact that each row of the 2D array is memory-aligned. By using memcpy, we can copy entire rows at once, achieving 32-bit transfers—effectively drawing 8 pixels at a time. This method provides an 8x speed improvement.

Given a desired x, y position and the current row i of the asset we are trying to copy, the index in the vga_data_array to begin copying to is calculated as vga_index = ((y + i) * 640 + x) >> 1. The (y + i) * 640 calculates the starting index of the ith row for a 640-pixel wide screen. The addition of x shifts the position from the left edge of the screen to the desired x-coordinate. The right shift by 1 operation is required because each byte in the vga_data_array can store two pixels (4-bit color). The right shift by 1 effectively divides the index by 2, converting the pixel index to a byte index.

Flip Animation

The flip animation is based off of the following image. During the first half of the flip, the image will be stretched to a trapezoid shape with the left edge as the longer base and the right edge as the shorter base. The height of the trapezoid will decrease as the flip progress approaches the halfway point. The trapezoid will then be inversed and the right edge will become the longer base and the left edge will become the shorter base. The height of the trapezoid will increase as the flip progresses to the end of the animation.

Flip Animation
Flip Animation

The trapezoid is split into the upper right triangle, lower right triangle, and the central rectangle. Parameters such as the height of the left and right bases and the width of the trapezoid, are mapped to values based on the progress of the flip. The top triangle is drawn with the below code. The drawHLineCustom(..) function is based off of the drawHLine(..) function in the VGA library. Instead, it draws a custom asset instead of a solid color. The triangle is drawn starting from the top left of the triangle. As the loop iterates, horizontal lines are drawn from the left edge to the right edge of the triangle. The length of the line drawn is dependent on the accumulator variable. The accumulator is accumulated by triangleStep, which is the the quotient between the trapezoid height and the triangle base. This is effectively the slope of the triangle's hypotenuse. The x_offset addition is used to shift the triangle to the right as the trapezoid's height decreases.

mac_address
Drawing Top Triangle and Flip Parameters

The middle rectangle is drawn with the line pasteImage(top_image, image_height, trapezoid_height / 2, x + x_offset, y, bg). top_image is a pointer to the asset. The image_height is the height of the asset. The x + x_offset and y arguments are the top left x and y coordinates of the image to paste.

The bottom triangle is drawn with similar logic to the top triangle. Since we still draw from the top left of the triangle, the accumulator starts at the full trapezoid_height and decreases.

Drawing Bottom Triangle Code

During the second half of the flip, the same logic largely applies. The right base of the trapezoid grows while the left base shrinks. The trapezoid's height increases instead of decreasing.

Sound Effects

We incorporated sound effects into the 24-point card game, improving input feedback and immersion. Key moments were mapped to distinct audio cues such as start menu and card selections, card dealing, and flipping.

While the DDS-based method in that file worked for simple tones, it was unsuitable for realistic sound effects. Instead, .wav files—originally sourced as .mp3, trimmed for timing, were converted into C arrays for inclusion in header files. These assets could then be triggered from the animation logic.

The initial playback method used a timer interrupt with SPI, which worked well when tested in isolation through serial-triggered playback. However, once integrated with the main animation code, the high interrupt frequency (8–44 kHz) conflicted with timing functions like sleep_us(), resulting in frame delays and visual stuttering. Additionally, SPI transfers suffered from jitter under load, causing popping artifacts and inconsistent playback.

After consulting with our instructor, Hunter Adams, a DMA-based method using Lab 1’s e_DMA_demo as a starting point. This resolved the timing issues by offloading audio streaming from the CPU. During early testing, I encountered silence due to a mismatch in audio output channels—beep_beep.c used channel B, while dma_demo.c defaulted to channel A. Once corrected, the DMA method produced clean, uninterrupted audio.

Moreover, DMA enabled a solution to another limitation: memory overflow when embedding long sound clips. By splitting .wav files into smaller fragments and using chained DMA descriptors, I could loop short buffers to simulate longer sounds without exceeding memory constraints—something timer-based methods couldn’t handle.

Finally, sound was integrated into the core gameplay logic. Some animations, like card dealing, were implemented within infinite loops and used flag-based control. Naïvely calling sound triggers within these loops caused repeated DMA reconfigurations. To prevent this, we added logic to play each sound once at the start of an animation sequence, suppressing repeated triggers until the animation completed. Additionally, to preserve sound quality, we adjusted the DMA sampling rate for each clip based on its original audio. For example, the flipping sound required ~11.025 kHz instead of the default ~44 kHz..

Game Code Structure

The game software is split across two cores. Within both cores, two threads handle inputs and VGA animations. On core 0, an additional thread waits for a semaphore signal to begin a serial listener for leaderboard inputs. On core 1, a timer callback ISR executes every second to decrease the seconds remaining during the main gameplay loop.

mac_address
Code Organization

The input thread in each core is responsible for reading the joystick inputs and button presses. Debouncing is implemented to make button presses and joystick inputs more reliable. Based on the current gameState, it will execute different actions based on the joystick and button inputs. For instance, if the game is in the START_MENU state, joystick and button inputs will set the game settings, or start the game based on the current selection of curCol or curRow. In the GAME_PLAYING state, inputs signal arithmetic operations, reset requests, or redealing a new set of cards. Inputs in the GAME_OVER state determine whether the player wants to save a score to the leaderboard.

The animation thread in each core is responsible for rendering the game graphics on the VGA screen. Based on shared game flags and the gameState, the animation thread will draw the start menu, main game screen, or game over screens. Animations such as flipping cards and sliding cards are also handled within this thread.

START_MENU screen
GAME_PLAYING screen
GAME_OVER screen
Leaderboard screen

The leaderboard input thread within core 0 is essentially a serial listener that waits for input. Based on the player input, it will write a player name and score to EEPROM via I2C. This thread waits for a semaphore signal from the input handler thread to begin. The semaphore is signaled when the game is in the GAME_OVER state and the player has selected to save their score.

The timer callback ISR in core 1 is responsible for updating the game timer. It executes every second and decrements the seconds_remaining variable. This variable is used to determine when the game ends and the gameState changes to GAME_OVER.

Hardware Design

This section details the hardware design of our Game of 24 system, which brings together multiple elements to support real-time gameplay and user interaction. The Raspberry Pi Pico serves as the central controller, interfacing with external peripherals including a DAC for audio output, a VGA display for graphics, an ADS1115 ADC for reading joystick input, and an EEPROM chip for persistent leaderboard storage. A custom game controller PCB connects to the system via GPIO, providing buttons and a joystick for player input. A full schematic of the hardware setup is shown below for reference.

Hardware Schematics

Custom Game Controller PCB Design

The custom game controller PCB includes three push buttons and a two-axis analog joystick, providing interactive input for in-game control and selection. Each push button is implemented as a Single-Pole Single-Throw (SPST) switch. One leg of the switch is connected to a Raspberry Pi Pico GPIO pin, while the other is connected to ground. A 10 kΩ pull-up resistor is connected between each GPIO pin and the 3.3 V supply, ensuring that the pin reads a logic high when the button is unpressed. When the button is pressed, the pin is pulled to ground through a low resistance path, allowing the software to detect a logic low. Transitions from high to low are used to trigger actions such as confirming a selection or cycling through menus. For Player 1, the Reset, Enter, and Skip buttons are connected to GPIO 2, GPIO 10, and GPIO 8, respectively. For Player 2, the same functions are wired to GPIO 3, GPIO 11, and GPIO 9. The custom game controller PCB schematics and layout are shown below.

Custom Game Controller PCB Schematics
Custom Game Controller PCB Layout

Each joystick used in the game controller consists of two internal potentiometers that detect movement along the X and Y axes. Hardware wise, each potentiometer acts as a variable resistor wired as a voltage divider. One end of the potentiometer connects to 3.3 V (VDD), the other to ground (GND), and the center pin providing an output voltage that varies based on the joystick’s position. As the user tilts the joystick, the resistance between the center pin and each rail changes, resulting in a varying output voltage. This analog voltage is fed into an external ADC, which converts it into a digital value and sends it to the Raspberry Pi Pico via I2C.

In software, the joystick input is processed by a function such as joystickSelect(), which takes in the digitized X and Y values and compares them to a defined center position. The function calculates the difference (delta) between the current reading and the neutral center value. To reduce noise and prevent small unintentional movements from being registered, a deadzone threshold is applied. If the joystick is tilted far enough beyond this deadzone, the direction is determined by comparing the magnitude of the X and Y deviations. If the horizontal movement (delta_x) is greater than the vertical (delta_y), the function returns UP or DOWN depending on the sign of delta_x. Otherwise, it returns LEFT or RIGHT based on delta_y. If the joystick remains near the center, it returns NEUTRAL, indicating no movement. This directional output is used in-game for actions such as selecting cards, navigating menus, or moving between elements.

EEPROM for Persistent Leaderboards

An external EEPROM chip is used to store leaderboard data, allowing high scores and player names to remain in memory across power cycles. The EEPROM communicates with the Raspberry Pi Pico over the I2C protocol, with the SDA and SCL lines connected to GPIO pins 4 and 5, respectively. The EEPROM operates at the standard 7-bit I2C address 0x50, and write operations are followed by a short delay to allow internal memory updates to complete.

The software interface for the EEPROM includes functions for reading and writing single bytes, 16-bit integers, and fixed-length names. To manage leaderboard entries, player names and scores are written sequentially to predefined memory addresses. Each entry consists of a name padded to a fixed length and a 16-bit score. When a new score is inserted, the insert_score() function checks existing entries and shifts lower ranked scores down to make room for higher ones. This maintains a sorted list of top scores. The EEPROM memory is divided by game mode, allowing separate leaderboards for different difficulty levels or game types. A helper function also allows full memory erasure for resetting all stored data.

ADS 1115 for Additional ADCs

The ADS1115 is a 16-bit external ADC used in this project to monitor joystick positions since two joysticks require four ADCs. The ADS1115 communicates with the Pico via the I2C protocol, with the SDA line connected to GPIO 6 and the SCL line connected to GPIO 7. The device operates at a default I2C address (0x48) and uses an external power supply of 3.3 V, consistent with the Pico’s power supply.

In software, the joystick input is read using the ads1115_read_single_channel() function. This function configures the ADS1115 to operate in single shot mode, where each read triggers a new conversion. The CONFIG register is set with parameters including the selected input channel (mux_bits), a ±2.048 V programmable gain amplifier setting for improved resolution, a data rate of 860 samples per second, and the comparator function disabled. After initiating the conversion and waiting for 2 ms, the function reads the 16-bit result from the ADC’s conversion register. The raw digital value is then scaled to a range of -4096 to +4096, which is used to interpret joystick position in the game.

MCP4822 DAC

The MCP4822 DAC converts digital audio signals from the Raspberry Pi Pico into analog output via the SPI channel. This analog signal is then played back through a speaker connected to a 3.5 mm audio jack. The DAC is powered by the Pico’s 3.3V supply (VDD connected to 3.3VOUT, VSS to GND). SPI connections are configured as follows: MISO on GPIO 12 (not used by the DAC), Chip Select (CS) on GPIO 13, Serial Clock (SCK) on GPIO 14, and MOSI (SDI) on GPIO 15. The analog output from the DAC is connected to the speaker for audio playback.

VGA Screen

The VGA display operates at a resolution of 640 x 480 and relies on two digital synchronization signals, HSYNC and VSYNC, and three analog color channels, RED, GREEN, and BLUE. Pixel updates occur at a clock rate of 25.172 MHz. Initially, both HSYNC and VSYNC are held high while pixel data is output through the analog color signals, which range from 0 V to 0.7 V to represent varying intensities.

Each row of the display is active for 640 pixel clock cycles, followed by a horizontal blanking interval consisting of 16 cycles for the front porch, 96 for the sync pulse (when HSYNC goes low), and 48 for the back porch, all during which the analog color outputs are set to 0 V. Once complete, HSYNC returns high, and the process begins again for the next row. This repeats for 480 rows per frame. After all rows are drawn, VSYNC initiates a vertical blanking period, 10 lines of front porch, 2 lines of sync pulse (VSYNC goes low), and 33 lines of back porch, before starting a new frame.

The Raspberry Pi Pico drives these signals through the following GPIO connections: HSYNC on GPIO 16, VSYNC on GPIO 17, GREEN on GPIOs 18 (through a 470 Ω resistor) and 19 (through a 330 Ω resistor), BLUE on GPIO 20 (330 Ω), and RED on GPIO 21 (330 Ω). The VGA input has an internal 70 Ω resistor per color channel, and the series resistors form voltage dividers to ensure output voltages remain within the 0 to 0.7 V VGA standard.

Results

The final implementation of the Game of 24 was functionally complete and responsive, with smooth user interaction and correct hardware behavior. The VGA display maintained proper timing and resolution, with no visible flickering or tearing during rapid updates. Real-time drawing of numbers, selection movement, and leaderboard display were stable and free of graphical artifacts. Button presses and joystick movements were detected with minimal latency, contributing to a smooth and interactive gameplay experience.

The success of our implementation of the Game of 24 project is primarily evaluated through direct gameplay. We assessed the game based on the responsiveness of player input, the smoothness of animations, and how intuitive the game controls were. For the latter, we had independent play testers give feedback on the gameplay experience.

Due to our multi-core architecture, we ran into some issues with concurrency. In the start menu, multiple setting parameters could be highlighted and selected at once due to the start menu being modifiable by both players. We resolved this by using a mutex spinlock to ensure that only one player could modify the start menu at a time.

Our core gameplay mechanics, including player input handling, card flipping animations, and real-time score tracking, functioned as expected. Sound effects were also carefully evaluated, with all audio cues accurately synchronizing to in-game actions, contributing to the arcade-like atmosphere of the game.

We validated that we generated valid solutions of a set difficulty using an online 24 solver (see Appendix E). Each of our generated solutions was verified to be solvable with a set number of solutions that matched the difficulty level.

Safety and robustness were considered in both software and hardware. Pull-up resistors on all input lines prevented floating GPIOs, and debouncing logic helped avoid accidental multiple triggers. All analog signals (VGA and joystick) were constrained within voltage ranges safe for the Pico and external devices. Usability testing mainly by the team and friends showed that users could intuitively understand the input system and play with the game without needing technical background, proving the design's usability.

Conclusions

Our final implementation of the Game of 24 met our expectations, delivering a polished, responsive, and feature-rich experience. Beyond the core game mechanics, we successfully implemented several expansion features, including a persistent leaderboard stored in non-volatile memory, smooth animations for card sliding and flipping, and a relative scoring system that adjusts based on game difficulty. The system handled two-player input concurrently with no noticeable latency or flicker, contributing to an overall polished and arcade-like experience.

If we were to revisit this project, we would focus on further expanding the gameplay features. Some potential enhancements include a "hint" system that shakes a couple of cards or highlights a possible first move when players are stuck. We could also introduce additional sound effects, such as audio cues for successfully finding a solution or performing an operation, taking advantage of the available flash memory. Additionally, we could explore new game modes, such as a challenge mode where players compete to solve a set number of puzzles in the fastest time, offering a more diverse gameplay experience beyond the current time-based format.

As for intellectual property, we based our 24 solver algorithm on an existing open-source solution, which we modified to suit our project’s needs.

Appendicies

Appendix A

The group approves this report for inclusion on the course website.

The group approves the video for inclusion on the course youtube channel.

Appendix B: Software

The code for this project is available on GitHub here.

Appendix C: Hardware

See hardware design section for schematics.

Appendix D: Work Distribution

Ray worked on:

  • Controller PCB design, layout, and assembly
  • Interfacing with ADS 1115, EEPROM w/ I2C.
  • Game Over, Leaderboard page design
  • Python script for converting PNGs to 2D C arrays

Sean worked on:

  • Designing software architecture
  • VGA Driver modifications for sliding, flipping cards and pasting images
  • Start Menu, Game Playing page design
  • Core game logic for main gameplay loop, handling player input, handling concurrency
  • Integration

Shizhe worked on:

  • Converting MP3 audio files to C arrays
  • Integrating sound effects into core game logic
  • Controller PCB assembly
  • Developed algorithm for generating card collections at a set difficulty level.

Appendix E: References

Course References

Datasheets & Documentation

Other References

  • Online 24 Solver: Link1
  • Open-Source 24 Solver Algorithm: Link2