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.
PlayState
enum:
The game begins in the START_SCREEN
state, which displays an initial message prompting the user
to
start. During the PLAYING
state, which is the main interactive phase, the refinement process
takes
place. Finally, when all refining is complete, the game transitions to the GAME_WON
state where
it
displays the completion screen.
PLAYING
state, the system continuously.
It updates boid positions and velocities, checks for collisions between boids and the
number grid (which triggers animations), and scans for user input through the joystick for cursor movement
and
button for refinement. When refinement is triggered on a "bad number", the game state is updated by removing
refined numbers and regenerating new ones, bin animations are triggered, and the overall progress bar is
updated. Finally, all visual elements are redrawn on the VGA screen.
pt_cornell_rp2040_v1_3.h
library.
On Core 0, we have three protothreads: protothread_graphics which handles drawing the main game screen,
protothread_joystick which reads ADC values from the joystick and updates cursor position, and
protothread_button_press which debounces the refinement button and triggers refinement logic. Core 1 runs
two
protothreads: protothread_graphics_too which manages the animations of the Woe, Frolic, Dread, and Malice
bins,
and protothread_progress_bar which updates the state of the main progress bar animation.
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.
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.
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.
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.
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.
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
).
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.
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.
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.
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.
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.
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.
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.