PicoKart racing game
[Spring 2025] For our final project, we created our own Mario Kart–inspired racing game using the RP2040. To bring it to life, we 3D printed a steering wheel, used foot pedals connected to potentiometers for analog control, and animated everything using VGA graphics. We can use foot pedals to accelerate or break. Watch out though, if you don't step on the acclerator for a while then your speed will go down automatically. Like the original, our version feat
- Suitable forB.Tech/B.E Embedded Systems students
- Build time4-6 weeks
- LevelIntermediate
- Available atAjvika Labs, Kakinada
About this project
Pico Kart on an RP2040
A dynamic kart-racing game with pedal input and real-time VGA graphics
Project Introduction
For our final project, we created our own Mario Kart–inspired racing game using the RP2040. To bring it to
life, we 3D printed a steering wheel, used foot pedals connected to potentiometers for analog control, and
animated everything using VGA graphics. We can use foot pedals to accelerate or break. Watch out though, if
you don't step on the acclerator for a while then your speed will go down automatically. Like the original,
our version features power ups, but with a Cornell
twist. Hit the iconic clock tower bell to activate a speed boost. Drive through Martha’s weather machine (a
pumpkin) to change the time of day. Want invincibility? Grab our dragon, power up. But watch out: just like in
Mario Kart, we’ve got obstacles too. Cows, bears, and bananas will drop your velocity to zero and push you
back. Survive 3 laps of chaos and you're crowned champion!
We chose this project because Mario Kart was a shared childhood memory, one full of laughter, chaos, and
shouting at friends. We wanted to recreate that joy from scratch on the RP2040 microcontroller. By combining
physical controls, real-time rendering, sound, and a touch of Cornell-themed humor, we aimed to build an
immersive, nostalgic experience that feels both familiar and uniquely ours!
Also fun fact: we chose a dragon
because of Cornell's Dragon Day.
High-Level Design
Inspiration & Rationale
We first drew our inspiration from the game
Mario Kart
. To look for further inspiration and sources,
we decided to see previous projects
to see if anyone has done something similar. We saw a
previous
project
and
were very inspired by it as it contained a lot of components that we wanted. We used it as an outline of what we
wanted and how to get there. Inspired by these things, our objective was to create an embedded
experience that feels
arcade-quality using only a microcontroller.
Drawing the Mario Kart game and racing games in general, we wanted to accelerate, brake, and steer. These are
just handy components that any racing games need. Drawing specifically from Mario Kart we wanted 2 things:
powerups and obstacles. For our powerups we thought of the iconic ones in Mario Kart: speed boost and
invincibility. As for obstacles we had more freedom and wanted to make some of them cornell specific. For
example, we wanted a bear to represent our Big Red Bear!
Background Math – Collision detection
The collision detection system checks whether the player's kart intersects with any active obstacles or
power ups on the road. This is achieved using axis-aligned bounding box (AABB) collision logic.
Mathematical Description
Let the kart's bounding box be defined as:
Left:
x_k
Right:
x_k + w_k
Top:
y_k
Bottom:
y_k + h_k
Where x_k is the karts top left x-coordinate, y_k is the karts top left y-coordinate, w_k is the width of the
kart, and h_k is the height of the kart
Let the obstacle’s bounding box be defined as:
Left:
x_o
Right:
x_o + w_o
Top:
y_o
Bottom:
y_o + h_o
These are similar variables as the kart
A collision occurs if and only if both of the following are true:
x_k + w_k ≥ x_o AND x_k ≤ x_o + w_o (horizontal overlap)
y_k + h_k ≥ y_o AND y_k ≤ y_o + h_o (vertical overlap)
This is implemented as:
bool x_overlap = !(kart_right < obj_left || kart_left > obj_right);
bool y_overlap = !(kart_bottom < obj_top || kart_top > obj_bottom);
return x_overlap && y_overlap && !racer.invincible;
Design Considerations
The bounding boxes are dynamically scaled based on perspective (objects further away are smaller).
Collisions are only registered when obstacles are within a certain vertical range (e.g., below y=350) to
avoid premature detection.
Power ups and obstacles are handled separately—colliding with power ups applies effects, while hitting
obstacles penalizes the player.
Objects are deactivated once a collision is detected to avoid multiple triggers per frame. It is reactivated
after it bounces backwards.
Background Math – Road Curvature
To simulate the effect of a dynamically curving road in real time, we use a sine wave based function that
determines the horizontal shift of the road at each vertical screen line. For any scanline
y
, the
curve offset is computed as:
curve = amplitude * sinf(curve_phase + 2(PI) * (y + road_scroll) / CURVE_PERIOD);
The
amplitude
controls the strength of the curve, and
curve_phase
increases slightly
every frame to give the illusion of road movement over time. This function is evaluated once per scanline and is
responsible for generating left and right edge positions that define the road shape.
We also apply a perspective effect using a quadratic scaling function based on each line’s distance from the
horizon. This gives the appearance of depth, with the road widening toward the bottom of the screen:
progression = (y - SKY_HEIGHT) / (VGA_HEIGHT - SKY_HEIGHT);
perspective_scale = min_scale + (1.0 - min_scale) * progression^2;
road_width = base_width * perspective_scale;
These calculations are used not only for rendering the road itself, but also to ensure that other elements in
the scene such as power ups, obstacles, and even the kart stay visually locked to the road. For example, we use
the same
getRoadCurve()
function in our obstacle logic to shift objects slightly in the direction
of the curve as they move down the track. Similarly, the kart’s x-position is bounded within the computed road
edges at its y-position, ensuring the player can only steer within the valid playable area regardless of how the
road curves.
Over the course of a lap, the
amplitude
value changes to simulate different driving sections. From
10k–20k units of distance, the curve gradually increases for gentle turns; from 20k–30k, it reaches a tighter
maximum; and after 50k, it begins to return to a flat road by decreasing the amplitude back to zero. This
control over curvature progression allows us to design a lap with varied road conditions, all handled
procedurally with minimal computation per frame.
Figure 1:
A curvy road
Overall, the road curvature math forms the backbone of both the game’s visual style and its object interaction
logic. It allows us to render smooth, animated road movement and ensures all gameplay elements are spatially
consistent with the track.
Background Math – Scaling
To simulate a realistic 3D racing perspective on a 2D VGA display, we use a combination of
perspective scaling
and
nonlinear progression-based transformations
to draw
the road and objects.
1. Perspective Scaling
Each horizontal scanline of the road uses a scaling factor based on how far down the screen (y-position) it is.
The progression value is computed as:
progression = (y - SKY_HEIGHT) / (VGA_HEIGHT - SKY_HEIGHT)
This value lies in [0, 1] and gets squared to exaggerate the perspective:
perspective_scale = min_scale + (1 - min_scale) * (progression)^2
The result is a wider road near the bottom (closer to the player) and narrower at the horizon, imitating depth.
2. Road Width
The width of the road is dynamically scaled each frame:
road_width = base_road_width * perspective_scale
Dashes and center lines also use
dash_length
and
dash_gap
scaled by this factor to
maintain consistency.
3. Object Scaling
Objects like power ups and obstacles are also scaled by a linear function of progression:
object_scale = 0.2 + progression * 2.0
This ensures distant objects appear smaller and grow larger as they approach the player, enhancing the 3D
illusion.
Gameplay
Figure 1:
Start Screen
We first start off our system by having our start screen. We included this because it would
feel unnatural
if the player couldn't start the game at ease. It is also nice art to look at! During this time, the code is
in
a while loop that won't break until the button is pushed. This button is connected to the breadboard and is
not
on the Pico itself. To generate the image, we used an online python script.
Figure 2:
The beginning of a lap.
Once the start button is pressed, the program launches
core1_entry()
using
multicore_launch_core1()
. This initializes
core 1
, which is responsible for
all
main gameplay threads. Specifically, it runs
protothread_vga
for rendering the road, kart, and
game
objects;
protothread_stats
for handling the HUD and pedal input; and
protothread_background
for managing environmental effects like clouds, road shaping, and
obstacle
spawning.
Meanwhile,
core 0
handles just one thread:
protothread_sfx
, which is
responsible
for streaming audio samples to the DAC in real-time. This separation allows the audio system to run without
interruption or frame stutter, while core 1 handles the more computationally demanding game logic and
rendering.
Figure 3:
The beginning of a lap.
We then start at the beginning of our lap and start our adventure! Note that a lap is 80k units. Everytime
we
update the distance we update it based off of the racer's current velocity. The reason why we have such
large
units is because the distance is being updated almost every frame! In figure 1, you see a cow approaching.
Notice how it is small. This shows the effectiveness of our scale funciton.
Overall,
main()
ensures all peripherals are configured, shows the intro screen, waits for player
input, then splits the workload:
core 0 handles audio
via a dedicated sound thread, while
core 1 handles all gameplay, render
A dynamic kart-racing game with pedal input and real-time VGA graphics
Project Introduction
For our final project, we created our own Mario Kart–inspired racing game using the RP2040. To bring it to
life, we 3D printed a steering wheel, used foot pedals connected to potentiometers for analog control, and
animated everything using VGA graphics. We can use foot pedals to accelerate or break. Watch out though, if
you don't step on the acclerator for a while then your speed will go down automatically. Like the original,
our version features power ups, but with a Cornell
twist. Hit the iconic clock tower bell to activate a speed boost. Drive through Martha’s weather machine (a
pumpkin) to change the time of day. Want invincibility? Grab our dragon, power up. But watch out: just like in
Mario Kart, we’ve got obstacles too. Cows, bears, and bananas will drop your velocity to zero and push you
back. Survive 3 laps of chaos and you're crowned champion!
We chose this project because Mario Kart was a shared childhood memory, one full of laughter, chaos, and
shouting at friends. We wanted to recreate that joy from scratch on the RP2040 microcontroller. By combining
physical controls, real-time rendering, sound, and a touch of Cornell-themed humor, we aimed to build an
immersive, nostalgic experience that feels both familiar and uniquely ours!
Also fun fact: we chose a dragon
because of Cornell's Dragon Day.
High-Level Design
Inspiration & Rationale
We first drew our inspiration from the game
Mario Kart
. To look for further inspiration and sources,
we decided to see previous projects
to see if anyone has done something similar. We saw a
previous
project
and
were very inspired by it as it contained a lot of components that we wanted. We used it as an outline of what we
wanted and how to get there. Inspired by these things, our objective was to create an embedded
experience that feels
arcade-quality using only a microcontroller.
Drawing the Mario Kart game and racing games in general, we wanted to accelerate, brake, and steer. These are
just handy components that any racing games need. Drawing specifically from Mario Kart we wanted 2 things:
powerups and obstacles. For our powerups we thought of the iconic ones in Mario Kart: speed boost and
invincibility. As for obstacles we had more freedom and wanted to make some of them cornell specific. For
example, we wanted a bear to represent our Big Red Bear!
Background Math – Collision detection
The collision detection system checks whether the player's kart intersects with any active obstacles or
power ups on the road. This is achieved using axis-aligned bounding box (AABB) collision logic.
Mathematical Description
Let the kart's bounding box be defined as:
Left:
x_k
Right:
x_k + w_k
Top:
y_k
Bottom:
y_k + h_k
Where x_k is the karts top left x-coordinate, y_k is the karts top left y-coordinate, w_k is the width of the
kart, and h_k is the height of the kart
Let the obstacle’s bounding box be defined as:
Left:
x_o
Right:
x_o + w_o
Top:
y_o
Bottom:
y_o + h_o
These are similar variables as the kart
A collision occurs if and only if both of the following are true:
x_k + w_k ≥ x_o AND x_k ≤ x_o + w_o (horizontal overlap)
y_k + h_k ≥ y_o AND y_k ≤ y_o + h_o (vertical overlap)
This is implemented as:
bool x_overlap = !(kart_right < obj_left || kart_left > obj_right);
bool y_overlap = !(kart_bottom < obj_top || kart_top > obj_bottom);
return x_overlap && y_overlap && !racer.invincible;
Design Considerations
The bounding boxes are dynamically scaled based on perspective (objects further away are smaller).
Collisions are only registered when obstacles are within a certain vertical range (e.g., below y=350) to
avoid premature detection.
Power ups and obstacles are handled separately—colliding with power ups applies effects, while hitting
obstacles penalizes the player.
Objects are deactivated once a collision is detected to avoid multiple triggers per frame. It is reactivated
after it bounces backwards.
Background Math – Road Curvature
To simulate the effect of a dynamically curving road in real time, we use a sine wave based function that
determines the horizontal shift of the road at each vertical screen line. For any scanline
y
, the
curve offset is computed as:
curve = amplitude * sinf(curve_phase + 2(PI) * (y + road_scroll) / CURVE_PERIOD);
The
amplitude
controls the strength of the curve, and
curve_phase
increases slightly
every frame to give the illusion of road movement over time. This function is evaluated once per scanline and is
responsible for generating left and right edge positions that define the road shape.
We also apply a perspective effect using a quadratic scaling function based on each line’s distance from the
horizon. This gives the appearance of depth, with the road widening toward the bottom of the screen:
progression = (y - SKY_HEIGHT) / (VGA_HEIGHT - SKY_HEIGHT);
perspective_scale = min_scale + (1.0 - min_scale) * progression^2;
road_width = base_width * perspective_scale;
These calculations are used not only for rendering the road itself, but also to ensure that other elements in
the scene such as power ups, obstacles, and even the kart stay visually locked to the road. For example, we use
the same
getRoadCurve()
function in our obstacle logic to shift objects slightly in the direction
of the curve as they move down the track. Similarly, the kart’s x-position is bounded within the computed road
edges at its y-position, ensuring the player can only steer within the valid playable area regardless of how the
road curves.
Over the course of a lap, the
amplitude
value changes to simulate different driving sections. From
10k–20k units of distance, the curve gradually increases for gentle turns; from 20k–30k, it reaches a tighter
maximum; and after 50k, it begins to return to a flat road by decreasing the amplitude back to zero. This
control over curvature progression allows us to design a lap with varied road conditions, all handled
procedurally with minimal computation per frame.
Figure 1:
A curvy road
Overall, the road curvature math forms the backbone of both the game’s visual style and its object interaction
logic. It allows us to render smooth, animated road movement and ensures all gameplay elements are spatially
consistent with the track.
Background Math – Scaling
To simulate a realistic 3D racing perspective on a 2D VGA display, we use a combination of
perspective scaling
and
nonlinear progression-based transformations
to draw
the road and objects.
1. Perspective Scaling
Each horizontal scanline of the road uses a scaling factor based on how far down the screen (y-position) it is.
The progression value is computed as:
progression = (y - SKY_HEIGHT) / (VGA_HEIGHT - SKY_HEIGHT)
This value lies in [0, 1] and gets squared to exaggerate the perspective:
perspective_scale = min_scale + (1 - min_scale) * (progression)^2
The result is a wider road near the bottom (closer to the player) and narrower at the horizon, imitating depth.
2. Road Width
The width of the road is dynamically scaled each frame:
road_width = base_road_width * perspective_scale
Dashes and center lines also use
dash_length
and
dash_gap
scaled by this factor to
maintain consistency.
3. Object Scaling
Objects like power ups and obstacles are also scaled by a linear function of progression:
object_scale = 0.2 + progression * 2.0
This ensures distant objects appear smaller and grow larger as they approach the player, enhancing the 3D
illusion.
Gameplay
Figure 1:
Start Screen
We first start off our system by having our start screen. We included this because it would
feel unnatural
if the player couldn't start the game at ease. It is also nice art to look at! During this time, the code is
in
a while loop that won't break until the button is pushed. This button is connected to the breadboard and is
not
on the Pico itself. To generate the image, we used an online python script.
Figure 2:
The beginning of a lap.
Once the start button is pressed, the program launches
core1_entry()
using
multicore_launch_core1()
. This initializes
core 1
, which is responsible for
all
main gameplay threads. Specifically, it runs
protothread_vga
for rendering the road, kart, and
game
objects;
protothread_stats
for handling the HUD and pedal input; and
protothread_background
for managing environmental effects like clouds, road shaping, and
obstacle
spawning.
Meanwhile,
core 0
handles just one thread:
protothread_sfx
, which is
responsible
for streaming audio samples to the DAC in real-time. This separation allows the audio system to run without
interruption or frame stutter, while core 1 handles the more computationally demanding game logic and
rendering.
Figure 3:
The beginning of a lap.
We then start at the beginning of our lap and start our adventure! Note that a lap is 80k units. Everytime
we
update the distance we update it based off of the racer's current velocity. The reason why we have such
large
units is because the distance is being updated almost every frame! In figure 1, you see a cow approaching.
Notice how it is small. This shows the effectiveness of our scale funciton.
Overall,
main()
ensures all peripherals are configured, shows the intro screen, waits for player
input, then splits the workload:
core 0 handles audio
via a dedicated sound thread, while
core 1 handles all gameplay, render
Technologies used
Hardware components
- RP2040
- VGA display
- IMU
- DAC
- ADC
Software & tools
- Python
Project demo video
What you get
- Working project / hardware kit, tested before delivery
- Complete source code with explanation
- Project demo and guidance for your viva
- Project report, PPT and IEEE paper support (on request)
Delivery & Payment
Delivery takes a minimum of two weeks from the date your inquiry is confirmed.
Payment is in two parts:
• 50% advance to start the project
• Remaining 50% before delivery
Note:
• Documentation and PPT are not included by default
• If you need documentation/PPT, it can be arranged for extra time and cost
We'll confirm the exact timeline and cost with you after reviewing your requirements.