MacroData Refinement on Pico

ECE 4760 Final Report

Akinfolami Akin-Alamu (aoa9) & Wilson Coronel (wrc62)

Game Demo
Figure 1: Game Demo showing quivering numbers.
We recreated the mysterious MacroData Refinement game from Severance on a Raspberry Pi Pico, complete with VGA display and joystick controls

Introduction

This project implements an interactive game simulating the MacroData Refinement (MDR) process from the Apple TV+ show Severance, rendered on a VGA display and controlled via a joystick and button interfaced with a Raspberry Pi Pico (RP2040). We developed this game to explore the themes presented in the show, such as corporate control and the nature of work, by allowing players to experience a version of the mysterious task of "refining" data, while also serving as a platform to implement real-time graphics, animation, and microcontroller-based game logic.

High level design

The primary inspiration for this project was the television series Severance. A central element of the show is Lumon Industries' Macro Data Refinement department, where "severed" employees sort groups of numbers based on the emotions (Woe, Frolic, Dread, and Malice) these numbers evoke. The "terminal-looking" interface provided a compelling basis for an interactive game. We also drew inspiration from the fan-made web version of the game available at lumon-industries.com, aiming to create a hardware-based counterpart. The project's goal was to not only replicate the game mechanics but also to weave in "easter eggs" and the general aesthetic of the show.

We utilized fixed-point arithmetic for efficient computation, particularly within the boid animation and game logic updates. We used 16.15 fixed-point numbers for calculations involving boid positions, velocities, and behavioral parameters. This allowed us to cram heavy floating point computations into the game logic and still meet frame rate deadlines without sacrificing smooth animations. Additionally, besides computing positions of pixels on the screen, much of the math in this game came from the movement of the invisible "boids" that cause numbers to quiver. This movement is governed by a flocking algorithm which causes each boid to adjust its velocity based on factors such as: cohesion (moving towards the average position of nearby boids), separation (avoiding really close by boids) and wall avoidance (turn away from screen boundaries). More parameters such as VISUAL_RANGE, PROTECTED_RANGE control the specifics of these behaviors and distance calculations were approximated using the "Alpha max plus beta min" method.

The game operates using a state machine and concurrent processing handled by protothreads.

All the game code is written in C. We utilized the provided vga library which gave us a great head start. An Analog joystick (purchased from Amazon) was chosen for user interaction. The proposal initially considered eight buttons, but a joystick for cursor movement was found to be more intuitive for navigating the grid.

Existing Patents, Copyrights, and Trademarks

This project is directly inspired by the Severance television show and related imagery from Lumon Industries, which are intellectual property of Apple Inc. and/or the show's creators. Our project is intended solely for educational and non-commercial purposes within the context of ECE 4760. We do not claim ownership of any copyrighted or trademarked material from Severance. The game mechanics, while inspired by the show, are our own faithful interpretation and implementation.

Program/Hardware design

Physical Hardware
Figure 2: Physical Hardware.

Joystick

For the hardware of the joystick, we connected it to the program using the Analog to Digital Converter (ADC) feature on the Raspberry Pi. This was implemented in a single protothread called Protothread_joystick. We reserved GPIO pins 27 and 28 to function as the Voltage-Read-X-coordinate (VRX) and Voltage-Read-Y-coordinate (VRY), respectively. To implement the joystick correctly, we must first understand how this specific joystick works.

Picture of Joystick used
Figure 3: Image of Joystick used in project and its respective wiring

Moving the joystick left or right changes the voltage of the VRX whereas up and down changes the voltage of VRY. This movement relays the analog voltage level to the RP2040 to which it then converts to a binary signal. In order to provide voltage in the first place, we connect the joystick to GPIO pin 36, which functions as an output of 3.3 Volts.

When turned left, the voltage level is 0 (shorted to ground), corresponding to a binary value of 0. Similarly, when turned right, the voltage level is 3.3 volts and that corresponds to a binary value of 8023. Generally in joystick architecture that is found in any controller, there is a threshold for which when the ADC value crosses, it counts as a read for turning left or right, For example, our int value X_RIGHT_THRESHOLD is set to 3000, for when the returned ADC value is greater than this number the program knows to count that as a right. This is also done for left, up and down.

Our first attempt to implement the joystick came with a small obstacle. We generally use the ADC to poll something such as a potentiometer, which provides a single voltage output into the RP2040. However, we are now trying to read two values, that being VRX and VRY. At first, we implemented an in-built feature of the ADC known as adc_set_round_robin. Which uses a queue in order to have a First-in-First-out data structure. The ADC reads one input, puts it in the queue, and then polls the next input into the queue. However, we noticed that it randomly picked an input to start the queue, which made it difficult to program other functions that rely on knowing if VRX or VRY is going to pop from the queue. Also, with the round robin method it provided a lot of memory overhead, something we were trying to avoid.

This then led to us having helper functions that when called, switched the input to be read to either VRX or VRY. While this does increase our running time a bit, it was the best option as we favored memory over speed.

The ADC feature polls the voltage level of the joystick far too frequently for any realistic human interaction. In order to circumvent this, we added a timer that polls the protothread every 70 ms. We got to this number by user testing random numbers and saw which timer gave us the most smooth interaction.

Included in the joystick is a button that can be pressed. This means we need debouncing and implies that there should be a separate protothread. This is done because debouncing relies on quick execution of a state machine that tells us if it reads a button press because a person is pressing the button or contact between mechanical switches that can happen arbitrarily. We need to read this at a higher frequency than that of the joystick. So we schedule the protothread for the button to run every 30 ms as opposed to the 70 ms the joystick protothread runs on.

Debouncing state machine
Figure 4: Debouncing State Machine

VGA Display

To support the VGA display, specific GPIO pins on the RP2040 are connected to the VGA port: GPIO 16 is used for horizontal sync (Hsync), and GPIO 17 for vertical sync (Vsync). Color signals are sent through GPIO 18 and GPIO 19 for green (via 470 Ω and 330 Ω resistors, respectively), GPIO 20 for blue (330 Ω), and GPIO 21 for red (330 Ω). The ground of the RP2040 is connected to VGA ground. The DMA channel, paced by the DREQ_PIO0_TX2 data request signal, feeds pixel data to the RGB PIO state machine. Each byte sent corresponds to a character in the global vga_data_array, representing two adjacent pixels. We use 4 bits per pixel: 1 bit each for red and blue, and 2 bits for green, taking advantage of the eye's greater sensitivity to green. As mentioned before, we used the vga library provided by Hunter Adams.

Overall Game Architecture

The system leverages both cores of the RP2040. Core 0 is responsible for the main graphics rendering (protothread_graphics), joystick input (protothread_joystick), and the refinement button logic (protothread_button_press). Core 1 handles animations: the bottom bins' grow/shrink effect (protothread_graphics_too) and the progress bar fill animation logic (protothread_progress_bar). This division distributes the workload, allowing for smoother animations and responsive input. Communication between threads, such as signaling the start of the game, is managed using semaphores (start_game_sem).

Game State Management

The central data structure is GameState, defined in game_state.h. It covers all dynamic aspects of the game:

The game state consists of several key components: state[ROWS][COLS] is a 2D array of Number structs representing the grid of numbers, where each Number stores its value, position, size, animation flags (animated_last_frame_by_boid0/1, refined_last_frame), and whether it's a "bad number" (is_bad_number) along with its associated BadNumber data (target bin_id). boids[NUM_BOIDS] is an array of Boid structs, storing their position (x, y), velocity (vx, vy), bias, and scout group. box_anims[5] is an array of BoxAnim structs that manages the state of the animated bins at the bottom, including their current animation height, state (ANIM_IDLE, ANIM_GROWING, ANIM_SHRINKING), and the percentage of each "emotion" (Woe, Frolic, Dread, Malice) they contain. The cursor is a Cursor struct storing the position and dimensions of the player-controlled cursor. play_state is an enum PlayState indicating the current phase of the game (e.g., START_SCREEN, PLAYING). total_bad_numbers tracks the count of unrefined "bad numbers" - when there are zero left, the game is won! Finally, progress_bar is a ProgressBarAnimation struct for managing the top progress bar's visual state.

Game State
Figure 5: Main animation loop logic

Initialization is handled by game_state_init(), which populates the number grid with random values, assigns some as "bad numbers" with random target bins, and spawns the boids. Spawning boids involves initializing all boids' position, velocity, and bias characteristics. Then, for each frame, we calculate new velocities and positions for each boid based on interactions with other boids and screen boundaries. Additionally, we check if a boid is within BOID_COLLISION_RADIUS of any number cell. If so, the animate_numbers() function is called, causing the number to slightly shift its position (quiver) based on a random pixel shift. If the number is a "bad number", its display size is increased. Flags (animated_last_frame_by_boid0 or animated_last_frame_by_boid1) are set on the Number struct to indicate it has been "touched" by a boid. This is crucial for the refinement logic.

Number Grid and Refinement Logic

The game screen features a grid of numbers (ROWS x COLS). Numbers are initially random digits (0-9). A small percentage are flagged as is_bad_number and assigned a random bin_id (0-3) corresponding to one of the four emotion bins. When the player moves the cursor over a number and presses the refinement button, handle_cursor_refinement() function is invoked. This function identifies the number that is currently selected, using the current (x,y) position of the cursor. Then it checks if it is a bad number AND if it has been animated in the current frame. This is important so that we don't refine bad numbers that are not quivering in this frame. So if these conditions are met, the refined number and ALL the numbers that are animated by that same boid are marked as refined_last_frame. This is why we have a flag for each boid indicating it has animated some numbers. For example, when boid0 comes in contact with some numbers, it marks them as animated_last_frame_by_boid0. This means that when we find that a bad_number is animated by the boid0, only numbers animated_last_frame_by_boid0 are refined.

Following, the identification of the numbers to be refined, total_bad_numbers is decremented, the corresponding bin animation (state->box_anims[bin_id].anim_state = ANIM_GROWING;) and the main progress bar animation are triggered. Finally, in the next main graphics cycle, numbers marked refined_last_frame are regenerated with new random values, potentially becoming new "bad numbers."

A tricky part was coordinating the boid animation flags with the refinement logic to ensure only "quivering" bad numbers could be refined. It involved maintaining many layers of state between different parts of the game. The interaction where refining one number in a boid-activated cluster also "refines" the others in that cluster was an explicit design choice to simulate sorting a "group" of numbers as described in the show.

Graphics

Drawing is performed using the vga16_graphics.h library, which provides primitives for pixels, lines, rectangles, circles, and text. Our initial plan was to use a custom font. But we weren't able to find an open source font bitmap that correctly matched the given vga graphics library. We mostly used the provided functions like drawPixel, drawLine, drawRect, fillRect, drawChar, setCursor, setTextColor, setTextSize and writeString. We also implemented a drawOval(short x0, short y0, short rx, short ry, char color) method. This function uses the midpoint ellipse algorithm to draw ovals, which was essential for rendering the Lumon logo.

The number quivering/sizing was by modifying x, y, size in the Number struct within animate_numbers() and redrawing in protothread_graphics. We made sure to cap the max pixel shift by the size of the current number grid. On the other hand, the BoxAnim struct's current_anim_height is incremented/decremented during ANIM_GROWING/ANIM_SHRINKING states, and drawRect is used to visualize this. The animation pauses for 3 seconds when fully grown to display percentages. This animation happens over more than one frame. Essentially, when the state is ANIM_GROWING, we progressively erase and draw the box taller than the last frame until it reaches a specific height after which we transition to ANIM_SHRINKING. In the next frame, the reverse of the process for growing the box happens.

Results

The primary goal of this project was to replicate the sorting process featured in the TV show Severance, with a strong emphasis on usability. Our objective was to make the game fully playable and closely aligned with its on-screen counterpart, ensuring that fans of the show could engage with it and feel as though they were experiencing the original game firsthand. We ended up with a result that satisfies the above. With the in game mechanics and scoring system based off the game, we were able to replicate it to a close degree of accuracy. We emphasized minor details such as the Lumon Logo on the upper right hand corner, and the visuals that happen when a point is won. Due to multiple things happening at the same time, there are occasional flickers in the game. This is mostly due to having to constantly update the boids, redraw the screen, and poll the ADC values at a very high frequency so that it appears everything is happening concurrently. However, this flickering was improved when work was divided between the two cores.

The game's functionality was primarily verified through visual inspection and interaction.

For VGA Display, the game successfully renders on a 640x480 VGA display. The number grid, cursor, Lumon logo, progress bar, and bottom bins are drawn clearly. Animations, such as number quivering, bin pop-ups, and progress bar filling, are visible.

Speed of Execution was a key focus in our implementation. The game targets and generally achieves a frame rate close to 60 FPS, as indicated by FRAME_RATE = 60000 microseconds in main.c. Joystick control for the cursor is responsive, and button presses for refinement are registered promptly due to the debouncing logic. Protothreads effectively manage concurrent tasks - animations (boids, bins, progress bar) run alongside user input processing and main graphics updates without noticeable hesitation or lag. Flicker is minimal, achieved through selective clearing and redrawing of screen elements (e.g., only animated numbers or changing parts of UI) which helps maintain a stable image.

Safety and Usability

This project does not involve high voltages (beyond the RP2040's 3.3V logic levels and USB 5V) or dangerous mechanical parts. Safety considerations are minimal. Input debouncing prevents erratic game behavior from noisy button signals, and standard coding practices were followed to avoid crashes or undefined behavior.

From a usability perspective, the game was designed to be intuitive. Many students and professor Adams found it easy to navigate with the joystick. Additionally, visual cues like "Press Button to start game", or highlight of the current cursor position helped guide the user. The overall progress bar indicates how close the player is to completing the game. The connection to the Severance theme is apparent through the Lumon logo, the concept of bins, and the task itself. While the lack of explicit emotion selection buttons (as per the original proposal) deviates from the show's direct depiction, it simplifies the control scheme to just joystick and one action button, which might make it more accessible.

Conclusion

The design of this project aligned very well with our expectations. We expected to model the game from the show Severance and the result turned out to be very similar to the game in the show.

What could have been done in another approach, in order to improve accuracy, was display things such as the logo using a bitmap. This could be achieved by downloading the Lumon logo online and turning it into a bitmap image, loading it onto the RP2040’s memory, and displaying it on the VGA. This would have better mirrored what is on the show.

Another thing we could have implemented was music. Although there were attempts, the background music could not have been found online to download. Additionally, the background music lasted too long such that once this audio was converted to an array of unsigned 16 bit integers, there would have been too much memory overhead that other parts of the program would suffer.

As mentioned in our Week 1 progress report, we initially attempted to use the Adafruit GFX library to incorporate different fonts. This resulted in unexpected characters being displayed. We decided to simplify and use the fonts already compatible with or easily integrated into the existing vga16_graphics framework. We believe a custom font could enhance the game play experience significantly

While we did not reuse any code or directly copy protected material, our design was inspired by the sorting process depicted in the TV show Severance. As such, all intellectual property rights related to the original concept and design elements remain with the creators and rights holders of Severance. All patented or trademark issues are non-existent as long as we don't present this project as an original creation or try to monetize this game in any manner. Therefore, a patent opportunity is not an option.

In conclusion, this project provided a chance to use what we learned throughout the semester such that we were able to replicate a game from a popular TV show using a Raspberry Pi 2040. This project emphasizes the versatility and potential of a microcontroller. It was a valuable learning experience in managing a real-time system with multiple concurrent processes, custom graphics rendering, and hardware interfacing on the RP2040.

Appendix A

Appendix B

Task Distribution

Appendix C (Full code)

Note: Code only includes code written by us. Rest of included files can be found on the course website.

References