Help! The chickens have gotten lost in the swamp! Can you save them before the alligator gets them?
Today we're going to to read and process sensor values from the PSOC™ 6 in Arduino to create a video game in Python. In doing so we're going to touch on subjects like sprite animation, sprite groups and game state machines.
Hardware: Designing WatchMaterials
All you need for your watch is a 3D printed case, this or a similar watch strap, four M3x10 screws, a long USB-C cable and your PSOC™ 6.
Attaching the PSOC™6
Carefully screw your PSOC™6 into the case and close the lid.
Attaching the Strap
Thread your strap through the watch holes.
Placing on Wrist
Make sure to wear the watch so that the arrows point toward your fingers. This is important because the code that calculates the sensor angle assumes the it's positioned this way!
The first step is finding a way to detect and track arm movement. Since bicep curls require you to flex and stretch out your arm, the easiest way to do so is to measure the angle your arm is at. We can do this by using the accelerometer BMI270 on the Infineon AI Evaluation Kit.
1. Reading AccelerometerValues (I2C connection)
To calculate the angle, we need to get the acceleration in the x and z directions.
If you're using a PSOC™6 for the first time, please check out our article on initializing and using PSOC™6 on the Arduino platform.
Now, plug your board into your computer using a long USB-C cable. In order for Arduino to know where to read information from, we need to define a port and a board. To do so, in the Arduino IDE, select Tools > Board > Infineon PSOC6 Boards > CY8CKIT-062S2-AI. Then, select Tools > Port > your
PSOC™ 6's COM port
. This is how your PSOC™6 and your Arduino will communicate.
Our base code that gets reads accelerometer values looks like this:
// Libraries to read sensor values
#include <Wire.h>
#include "SparkFun_BMI270_Arduino_Library.h"
// Create a new sensor object
BMI270 imu;
// I2C address selection
uint8_t i2cAddress = BMI2_I2C_PRIM_ADDR; // 0x68
//uint8_t i2cAddress = BMI2_I2C_SEC_ADDR; // 0x69
// Setup loop
void setup()
{
// Start serial connection + set baud rate to 9600
Serial.begin(9600);
// Initialize the I2C library
Wire.begin();
// Check if sensor is connected and initialize
while(imu.beginI2C(i2cAddress) != BMI2_OK)
{
// Not connected, inform user
Serial.println("Error: BMI270 not connected, check wiring and I2C address!");
// Wait a bit to see if connection is established
delay(1000);
}
}
// Handle sensor data
void loop()
{
//get and save acceleration in X and Z direction
imu.getSensorData();
float X = imu.data.accelX;
float Z = imu.data.accelZ;
//calculate angle and print sensor data
}
2. Calculating theAngle
Now that we've managed to read data from our sensor, we want to use that information to calculate the angle of the PSOC™ 6.
An accelerometer returns how much gravitational acceleration acts in each direction. In this case flexing our arm returns an X value of ≈ 1 and extending our arm returns an X value of ≈ -1.
Since we're going to use some simple trigonometry, make sure to add the <math.h>
library to our code!
#include <math.h>
Our Z value isn't very reliable at our two extremes (flexed and extended) because it fluctuates around zero (and around negative and positive values), which makes calculating the angle using arctan quite difficult. Instead, we're going to use this formula:
hypothenuse = root(z² + x²)
opposite = x
arccos(hypothenuse / opposite) = angle (in radiant)
Now, the value 0° represents flexing your arm, 180° represents extending it and the other values represent everything in between.
Additionally, to avoid the angle being calculated when our arm is being over- or underextended, we can add an if statement.
if(z_average < 0){
Serial.println((acos(x_average/hyp)*180/PI));
}
//if z_average < 0 -> overrotation!
else{
//arm all the way up -> 0 degrees
if(x_average > 0){
Serial.println("0.00");
}
//arm all the way down -> 180 degrees
else Serial.println("180.00");
}
3. Introducing a Buffer
When visualizing data in real time, we need to smooth out sensor noise and prevent large, sudden jumps from distorting the output.
One way of doing so is with a buffer that calculates the average of last few sensor readings and returns it. An important parameter is the length of the buffer, which we will call maxlen
in our code, and set to 5. The longer the buffer, the less sensitive it is to sudden spikes, but the slower it is to react to real changes.
Our buffers (one for x and one for z) are going to be implemented using a deque, for which we first need to import the module:
#include <deque>
Our main loop is going to fill out buffers first and then calculate the x and z averages.
//initialize buffers (except the last value)
if((bufferx.size() < (maxlen - 1)) || (bufferz.size() < (maxlen - 1))){
bufferx.push_back(x);
bufferz.push_back(z);
}
Then, we're going to add in the latest value, calculate the average, and remove the oldest value.
else{
// Initialize new averages
float x_average = 0;
float z_average = 0;
// Add in newest value
bufferx.push_back(x);
bufferz.push_back(z);
// Calculate average
for (int i = 0; i < maxlen; i++) {
x_average = x_average + bufferx[i];
z_average = z_average + bufferz[i];
}
x_average = x_average/maxlen;
z_average = z_average/maxlen;
// Pop out oldest value
bufferx.pop_front();
bufferz.pop_front();
// Calculate and print angle
We're going to replace the x and z values in our earlier function with these averages and then calculate and print the resulting angle into our serial port.
Example of Sensor Readings
This is what your sensor readings should look like when moving your arm:
End Goal
This is what our game should look like in the end, here's an example of a player losing and winning once.
Installing Pygame
First and foremost, we need to install Pygame in order for our computer to be able to use it. Do so by typing into this command into your computer's command window.
pip install pygame pyserial
It's time to move on to the next part of the game! Before we dive into the individual functions and features, let's take a look at our game structure.
File Structure
It's important to create the files and folders and save the images under these exact names and in this structure, because these are the names our code is going to use to access everything it needs.
Game Structure
Most games have a different backgrounds depending on where you are in the game. There's the start screen, the set-up menu, the main game play, etc. In this example, we're going call these backgrounds "states", and use a game state machine to manage them using a stack data structure.
1.Mainfolder:
- game.py: this is the file you run to start the game. It manages all states, sets game-wide parameters, handles user input and many other key tasks.
>asset folder:
- contains all the images used for our backgrounds, player animations and text display.
> state folder:
- state.py: holds the
State
class (the parent class of all other states), manages state transitions and defines common functions. - title.py: displays the game's title screen when you first launch it.
- get_reps_and_sets.py: this is the second screen to pop up. It prompts the user for the amount of sets and reps they want to do as well as the maximum time one rep should take.
- stretch_to_start.py: this state requires the player to extend their arm for a set amount of time before gameplay begins. This ensures that the player has assumed the correct starting position and helps prevent the game from misinterpreting sensor data.
- game_world.py: this is the main state and the one the user will spend the most time in. It handles the actual gameplay, initializes the characters and handles their interactions.
- next_set.py: a short resting state that lets the player take a break before starting the next set.
- game_over.py: the final state, displaying either a win or loss screen depending on the player's performance.
>>assisting functions folder:
- sensor_data.py: retrieves the angle data from our Arduino code and converts it into the corresponding on-screen y-position.
- sprite_sheet_cutter.py: prepares the frames we need to animate our characters. Don't worry, we'll get to all that a bit later.
The set-up code is based on CDcode's YouTube video "Creating an In-game Menu using States", which offers a nice and detailed explanation on state machines. Please check it out if you have any more questions!
The following parts will explain the game states in more detail.
Pygame: Game ManagerThe first thing we need to do is import all the libraries required for our game, along with get any classes we'll be using from other files.
#import relevant libraries
import os, time, pygame, serial
# Load the next state from it's respective file
from states.title import Title
init:
When we create a Game
object, we automatically call pygame.init().
This function initializes all the imported Pygame modules (such as display
, font
, mixer
, image
, time
and joystick
) that we'll need to build our game.
Game
is also responsible for:
- initializing game wide constants like the size of the screen, background colors, etc.
- creating and drawing the screen.
- creating flags like
running
andplaying
to track the player's progress. - checking for player input.
- initializing the stack that holds all of our game states (current state is at top).
- calling any other setup functions needed at the beginning of the game.
game_loop:
The game sets the core sequence for the entire game.
#main function that loops while the game is being run
def game_loop(self):
while self.playing:
self.get_dt() # compute delta time since last frame
self.get_events() # handle user input such as keyboard or mouse events
self.update() # update the current state (top of the stack)
self.render() # render the current state on the screen
Other functions include:
draw_text:
gets a string and blits it onto the screen.load_assets:
create a path to the assets folder (and subdirectories), so that other states can access images, sounds and animation.load_states:
initializes and loads the first (title) state onto the stack.reset_keys:
resets all key flags toFalse
(not pressed).
Starting the Game
To start the game, we check whether the game.py file is being run directly (and not being imported as a module for example) and loop through the game while running
== True
.
#Handle case: game.py file is being run directly
if __name__ == "__main__":
# Create Game object
g = Game()
# Loop through while running
while g.running:
g.game_loop()
Pygame: Main Game LogicLet's dive into the main part of the game!!
ImportSection:
The first thing game_world
needs to do is import all required libraries and files for the main gameplay.
# Import required libraries
import pygame, os, time
# Import states from other files
from states.state import State
from states.next_set import Next_Set
from states.game_over import Game_Over_Lose
from states.game_over import Game_Over_Win
# Import assisting functions from other files
from states.assisting_functions.sensor_data import angle_to_pixel
from states.assisting_functions.sprite_sheet_cutter import Chicken_SheetCutter
from states.assisting_functions.sprite_sheet_cutter import Alligator_SheetCutter
Game_World Class:
This class handles the actual gameplay, here are its functions:
init:
sets key game constants (sprite boundaries, etc.) and loads the background image. It then initializes all our sprite objects and adds the chicken and alligator objects to their respective sprite groups. There are as many chickens as there aretotal_reps,
and they will get saved to a list in the order they get picked up by the gripper. Lastly it starts the timer that runs fortime_for_curl
.
Tip: What's a sprite group?
Sprite groups allow us to perform actions on multiple sprite objects at once, instead of calling each one individually. Typically, you group together sprites that serve similar purposes, like enemies, collectibles or obstacles in your game, so you can manage them in bulk. In this case, we're using them to update and render our all our alligator and chicken objects at once, but they're much more versatile than that. Both Coding With Russ and Codemy.com have great tutorials on sprite groups in Pygame, check 'em out!
update:
handles the gameplay logic by managing sprite interaction, checking flags and switching through the different gameplay scenarios accordingly.
render:
renders the game background and calls the render functions for all the sprites or sprite groups.
Tip: Structuring your game
In our setup, the Game_World
class acts as the conductor. It only handles what's relevant to the main game logic (sprites interactions, state transitions and winning conditions) and then delegates the rest (like with the render function). Think of it like a waterfall: the game world manages the big picture, while each smaller class takes care of its own details.
The best part of this is, this structure scales beautifully! You can expand your game into something much more complex while keeping it clean, modular, and easy to maintain.
FunctionsEvery Sprite Class has:
init:
passes on on global attributes, sets image size and speed of sprites. Also initializes starting position, state flags masks and rects
Tip: What are rects and masks in pygame?
When creating a rect or a mask in Pygame, you're essentially creating a shape of a sprite to use for sprite collision detection (when the two shapes overlap).A rect is rectangular cutout of the sprite, and is good for fast collision checks with simple box-shaped objects, whereas a mask is a pixel-perfect outline and is better at detecting precise collisions between irregular shapes. Check out these YouTube videos on rects and masks for more.
update:
handles on-screen movement (by changing x and y coordinates) and keeps the sprite in bounds. It also manages transitions into different animation states depending on the flags set in the main game play and calls the animate function.animate:
manages the current frame.
Tip: What is animation?
A video is a series of pictures moving fast enough to it create the illusion of motion. In Pygame, animating means switching between multiple frames at a certain frame rate.
load_sprites:
loads sprite frames from a sprite sheet and saves in animation lists.render:
blits the sprite image onto the screen.
Gripper Class
init,load_sprites,render:
there's nothing left to add, please check out the earlier description of these functions. :)change_grip:
switches to other grip image.update:
sets the y-coordinate to the value returned by ourangle_to_pixel()
function.
Chicken Class
init:
it does everything we already talked about earlier and a little bit more. This is how the animation parameters get handled in the chicken class:
# Map animation states to corresponding animation sheet names
animation_files = {
"idle_blink": "chicken_idle_strip4.png",
"flying_away": "chicken_fly_strip4.png",
"walk_right": "chicken_walk_strip8.png",
"falling": "chicken_fall_strip2.png"
}
# Create dictionary that maps animation name to corresponding animation list
self.animations = {}
for anim_name, filename in animation_files.items():
#load appropriate image from sprite directory
img = pygame.image.load(os.path.join(self.sprite_dir, filename)).convert_alpha()
#animations directory stores lists of frames for respective animation names (keys)
self.animations[anim_name] = self.sprite_sheet_cutter.extract_all_chicken_frames(img, self.chicken_width, self.chicken_height)
# Initialize animation state
self.animation_state = "idle_blink"
self.current_frame = 0
self.last_frame_update = 0 #time since last frame update
self.curr_anim_list = self.animations[self.animation_state]
self.image = self.curr_anim_list[0]
update:
changes chicken movement depending on which flags have been set. Time for another flowchart!
animate:
loops through our respective animation list at the speed of our frame duration.
def animate(self, delta_time):
# Increment last_frame_update by time since last game update
self.last_frame_update += delta_time
# Get current animation list (using our current state)
self.curr_anim_list = self.animations.get(self.animation_state, self.animations["idle_blink"])
# Handle case: frame duration amount of time has passed -> switch to next animation frame
if self.last_frame_update > self.game.frame_duration:
# Restart timer for frame update
self.last_frame_update = 0
# Go to next frame (loop through), update image and get mask
self.current_frame = (self.current_frame + 1) % len(self.curr_anim_list)
self.image = self.curr_anim_list[self.current_frame]
self.mask = pygame.mask.from_surface(self.image)
Alligator Class:
init:
handles everything we've previously discussed, plus more complex animation management. Some animations loop (water_swim
), while others only play through once (bite_open
,bite_closed
,water_emerge
andwater_submerge
). This information will be saved in a dictionary calledlooping_animations
:
# Define which animations should loop (only the ones we'll use in our game)
self.looping_animations = {
"water_swim": True,
"water_bite": False,
"water_submerge": False,
"water_emerge": False,
"bite_open": False,
"bite_close": False
}
To make bite animation smoother, we'll also define the spot where the alligator's mouth is and make sure that that spot ends up in the middle when the time runs out.
set_animation:
reinitializes our animation variables every time we enter a new animation state.update:
checks current animation state and changes the alligator's position accordingly. It also keeps the alligator in bounds and deletes a sprite once it's submerged.
def update(self, delta_time):
#make it move left if in swimming/emerging/submerging state
if self.animation_state in ["water_swim", "water_emerge", "water_submerge"]:
self.rect.left = self.rect.left - self.alligator_speed * delta_time
#keep in bounds
if (self.rect.left <= self.target_left_x):
self.rect.left = self.target_left_x
#transition emerge to swim
if self.animation_state == "water_emerge" and self.animation_finished:
self.set_animation("water_swim")
#transition submerge to emerge
# Note: This transition is triggered externally when the player successfully puts down a chicken
if self.animation_state == "water_submerge" and self.animation_finished:
if self.should_die:
# Remove this alligator (whether it's old or last one)
self.kill()
self.animate(delta_time)
animate:
checks if the current frame is the last frame of the animation list and sets theanimation_finished
flag accordingly. (The alligator or gameplay function might use this flag to switch to the next animation state in their update functions).
def animate(self, delta_time):
# Increment timer
self.last_frame_update += delta_time
# Go to next frame if enough time has passed
if self.last_frame_update >= self.game.frame_duration:
self.last_frame_update = 0
self.current_frame += 1
# Check if reached the end
if self.current_frame >= len(self.curr_anim_list):
# Handle case: looping animation -> back to first frame
if self.looping_animations.get(self.animation_state, False):
self.current_frame = 0
self.animation_finished = False # Reset for looping animations
# Handle case: one-time animation -> stay at last frame
else:
self.current_frame = len(self.curr_anim_list) - 1
self.animation_finished = True
# Update the image and mask
self.image = self.curr_anim_list[self.current_frame]
self.mask = pygame.mask.from_surface(self.image)
Progress_Window
This creates a window that displays the current rep and set the player is at.
Codemy.com explains text in Pygame in more detail.
Pygame: Other Statestitle
This state renders the title screen and handle transition to get_reps_and_sets
state when Enter key is pressed.
get_reps_and_sets
In this state, the player is presented with three input fields: number of reps, number of sets, and maximum time per rep. The current input field will be highlighted in white with a blinking cursor. Each input field will have a character limit and a valid range for input values. If the player enters invalid input, an error message will be displayed.
Players can use Backspace to correct input or return to the previous stage and Enter key to confirm their input and transition to the next stage
The Get_Reps_and_Sets
class will manage this state, it class includes the following methods:
init
: Initializes the cursor timer, sets the input range, and inherits from theState
class.update
: Updates the cursor timer, retrieves the current key presses, and saves them as input. This method also handles transitions between input states and game states, and checks for input errors.render
: Renders the input fields, instructions, and error messages onto the screen.
Tip: Handling Key Input in Pygame:
In Pygame, thekeys = pygame.key.get_pressed()
stores a boolean sequence (a list ofTrue
/False
values) that represents the current state of every key on the keyboard (True
== pressed,False
== not pressed). Every key I on your keyboard has an index in the form K_i,key[pygame.K_i]
returns the state of that key.
Tip: Displaying Key Input in Pygame:
A common mistake when trying to displaying key input is not taking the time it takes to press a key into account. Right now, our code updates every 0.005 seconds! That means if we print a 3 every time our code checks if the 3-key is being pressed, and we hold the 3-key down for 0.1 seconds, we'd get 333333333333... (20times 😲), even if the player only wanted to press it once!
To counteract that, we'll create two sets,keys_pressed_last_frame
andcurrent_keys_pressed
, that store all the keys that match those descriptions. We'll only change our input if our key incurrent_keys_pressed
wasn't being pressed last frame. It's that simple!
stretch_to_start
The better a game is, the more robust it is against user error. Instead of assuming the player is using the right form when playing, we want to make sure that they're in the right starting position before starting the actual gameplay. In this case, that means making the player hold out their arm at self.target_angle
(max_angle with a bit of leeway) for required_stretch_time
amount of time. Only then will we transition to the next state.
But first, we have to import required libraries, the parent class and the necessary functions
# Import required libraries
import pygame, time, os
# Import State (parent) class
from states.state import State
# Import necessary functions
from states.assisting_functions.sensor_data import get_angle_from_port
The Stretch_To_Start class, which inherits from the State
class, will manage this state. This class includes the following methods:
init:
inherits from state class, sets the position for the text elements and initializes these parameters to help us monitor the user input:
# Stretch detection parameters
self.angle_leeway = 10 # Degrees of leeway for stretch detection
self.target_angle = self.game.max_angle - self.angle_leeway # Min angle where arm still counts as stretched out
# Time tracking parameters
self.required_stretch_time = 3.0 # Time player needs to hold correct stretch
self.stretch_timer = 0.0 # Accumulates time spent in correct position
# Flag to indicate if arm is currently stretched correctly
self.arm_stretched_correctly = False
update:
gets the current sensor angle and checks if arm is in correct position, if so it setsself.arm_stretched_correctly
toTrue
increments theself.stretch_timer,
if not it sets the flag toFalse
. It also checks if the stretch has already been held long enough and transitions togame_world
accordingly.render:
renders the main instructions, progress timer and input flag onto screen.
next_set
This state is for the player to rest it before starting a new set. It will show your progress and provide instructions on how to continue playing.
First, you need to import all libraries, the parent class and the next state to put on the state.
# Import required libraries
import pygame, os
# Import State (parent) class
from states.state import State
# Import next state
from states.stretch_to_start import Stretch_To_Start
The Next_Set
class, which inherits from the State
class, will manage this state.
init:
inherits from state class, sets x and y positions for the text elements.update:
checks if the enter key has been pressed. If so, it creates a newStretch_to_Start
object and transitions into that state.render:
centrally aligns, spaces out and renders each text element onto the screen.
game_over
Has both a Game_Over_Lose
and a Game_Over_Win
class that blit their respective screens when initialized.
The following explains the assisting functions in more detail:
Pygame: Processing Sensor DataIn our game, the gripper will be moving up and down according to the player's arm movement. To get the exact position in pixels, there are three steps.
1. Initialize serial connection
import serial
#get serial port address
ser = serial.Serial('COM5', 9600, timeout=1)
In order for our game to connect to our Arduino code, we have to import the serial library and get the address of the port we're using to store our Arduino data.
2. get_angle_from_port
This function converts the data we got from our port (in the form of a string) into a float. If it fails to do so max_retries
amount of time, or if there isn't any data available, it sends an error message.
3.angle_to_pixel
This function gets the current sensor angle as an argument and maps it to the y-position on screen. Since it returns the value in pixels, it's dependent on GAME_H, which is why we normalize the angle before calculating our y-coordinate.
'''Map angle to pixel position on screen'''
def angle_to_pixel(lower_screen_boundary, upper_screen_boundary, min_angle, max_angle):
angle = get_angle_from_port()
# Calculate the normalized angle (between 0 and 1)
normalized_angle = (angle - min_angle) / (max_angle - min_angle)
# Map the normalized angle to the screen coordinates
y_pixel = upper_screen_boundary + (lower_screen_boundary - upper_screen_boundary) * normalized_angle
# Keep in bounds
if(y_pixel > lower_screen_boundary): y_pixel = lower_screen_boundary
elif(y_pixel < upper_screen_boundary): y_pixel = upper_screen_boundary
return y_pixel
The min_angle
is the smallest input angle and what counts as a fully flexed bicep curl. The max_angle
is the biggest input angle and what counts as a fully stretched bicep curl. They are automatically set by the game function but you can change them if you feel like your boundaries are slightly different.
The normalized_angle tells you how big our angle is respective to our GAME_H.
- 0 represents the minimum angle position
- 1 represents the maximum angle position
- 0.5 would be halfway between min and max
We can add an if statement to keep our y_pixel
between our lower_screen_boundary
and our upper_screen_boundary
, two constants also set by the game class.
Tip: Graph structure in Pygame
Keep in mind that the graph axes are placed differently in Pygame! The x-axis points to the right but the y-axis points downwards. So increasing a character's y-value actually moves it down, not up.Pygame: Preparing Animation Frames
Woohoo!! You've made it to the most fun part of game design, the animations! This is where you can make characters really come to life.
Tip: Sprite Sheets in Animation
In game design, characters are also called sprites. A sprite sheet is a collection of frames that make up a sprite animation. Sometimes, there's a separate sheet for each animation, sometimes they're stacked on top of each other and stored in one single picture. In our game we're going to encounter both variations. Today, we'll be using these two sprite sheets: crocodile sheet and chicken sheet. It you don't want to purchase them online, you can also just use a picture of a chicken, an alligator or whatever character you like and declare that as the self.image
of your character.
Sheet Cutter Class
As mentioned before, sprite sheets usually come as one picture with multiple frames in it. Our first job is to extract these individual frames and save them in a list that we can then iterate through to create our animation. Since our alligator and chicken sprite sheets have a different layout, it's best to create two different classes to handle these cases individually.
1. Chicken_SheetCutter
init:
saves chicken_frame_width and chicken_frame_height (the sizes of an individual frame). These vary depending on the sprite sheet and should be checked beforehand.extract_chicken_frame:
receives a sheet for one animation as well as the index of the frame you need to extract from and returns exactly that frame. It also removes the background.extract_all_chicken_frames:
take an animation sheet as an argument and returns a list with the individual frames.
2. Alligator_SheetCutter
This class functions similarly to the chicken version, except its animation sheets are stacked vertically. Because of that, we first need to separate the individual animation sequences before extracting their frames.
init:
saves the size of an individual frame and initializes a list with the length of each animation sheet. It then maps the animations in order of occurrence in the sprite sheet.extract_alligator_sheet:
this receives an animation state and (with the help of the the map we just created), extracts exactly that animation from the sprite sheet.extract_alligator_frame
andextract_all_alligator_frames:
very similar to the chicken functions, except that the number of frames gets read from our list, instead of calculated.
If you have any more questions, feel free to check out this YouTube video by Coding with Russ that explains sprite sheet cutting very well.
Goodbye!And thus concludes our journey through the swamp. But that doesn’t mean your adventure has to end here! Why not experiment with the game yourself?
Try adding new animations, adjusting input conditions for different exercises, or even creating a feature that tracks progress. If you’re feeling ambitious, you could explore the Wi-Fi feature on our PSOC™ 6 to make the game completely wireless.
If you’d like to see another example of using Infineon microcontrollers to build video game in Pygame, check out our DPS-Man game using the pressure sensor (DPS) from Kit2Go.
We’re excited to see what you create! Please like, comment, and try building it yourself—we're happy to answer any questions and are open feedback and future project suggestions.
Comments