Links: Demo video · RFID exploration
Overview
Welcome to the build log for our mini-golf adjacent experiment: a high-speed golf ball launcher that started as a serious training tool and evolved into something louder, simpler, and honestly more fun.
We began with a credible goal—help people practice putting with repeatable launches and measurable outcomes. As constraints showed up (motor tuning, RFID physics, time), we leaned into a different story: a kinetic desk toy / range toy with scoring, lights, and just enough chaos to demo well.
Why the pivot felt inevitable
- The motor could not be tuned down into “reasonable putting speeds” with the hardware we had on hand.
- RFID through a golf ball was inconsistent—and worse at launcher speeds.
- A reliable ball-return path was going to eat the rest of the semester.
Project photos
Launcher
Hole
Hardware
BOM:
| Part | Quantity | Price |
|---|---|---|
| Seeduino Xiao | 2 | $5.90 |
| NeoPixel Matrix | 2 | $9.95 |
| 6s LiPo Battery | 1 | $30.00 |
| ESC SpeedyBee | 1 | $60.00 |
| Bearings | 4 | $10.00 |
| 3D printed parts | 1 | — |
| Limit Switch | 2 | $5.00 |
| Switches | 2 | $5.00 |
| Microphone | 1 | $5.00 |
CAD
Launcher:
Laser Cut Parts
Hole Plate:
Electronics
Wiring Diagram Launcher

Wiring Diagram Hole

Development
RFID
Watch the RFID exploration clip →
The original idea was to use the RFID tags embedded into the balls to read the player's score. Initial testing with the RFID was positive, as it could read different tags with different point values and add them.
However, the issue showed up during integration with real balls: the reader could not see through the ball reliably, and at launcher speeds the window to read a tag basically vanished. We swapped to limit switches—boring, robust, and fast.
The lesson stuck: don’t buy complexity you can’t validate early. Sometimes the “smart golf ball” fantasy should lose to a button.
RFID never shipped in the final product—but the prototype work mattered. It taught us what “good enough sensing” had to look like under real timing and packaging constraints.
RFID prototype code
#include <SPI.h>
#include <MFRC522.h>
#include <MD_MAX72xx.h>
//DOT Matrix Setup
#define HARDWARE_TYPE MD_MAX72XX::GENERIC_HW
#define MAX_DEVICES 1
#define CS_PIN 6
#define DELAYTIME 50
MD_MAX72XX mx = MD_MAX72XX(HARDWARE_TYPE, CS_PIN, MAX_DEVICES);
//RFID Scanner Setup
#define RST_PIN 5 // Configurable, see typical pin layout above
#define SS_PIN 7 // Configurable, see typical pin layout above
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
byte readCard[4];
const char\* rfidTags[] = {"931D7CD", "492561", "4A23E1","451421"};
int tagPoints[] = {1,2,3,4};
String tagID = "";
int points = 0;
void setup() {
// put your setup code here, to run once:
Serial.begin(9600); // Initialize serial communications with the PC
while (!Serial); // Do nothing if no serial port is opened (added for Arduinos based on ATMEGA32U4)
SPI.begin(); // Init SPI bus
mfrc522.PCD_Init(); // Init MFRC522
delay(4);
mfrc522.PCD_DumpVersionToSerial(); // Show details of PCD - MFRC522 Card Reader details
mx.begin();
mx.control(MD_MAX72XX::INTENSITY, 0);
mx.clear();
Serial.println("Scan Card");
}
void loop() {
//Wait until new tag is available
while (getID()) {
//Search for Tag in dictonary
for(int i = 0; i < sizeof(rfidTags); i++) {
if (tagID == rfidTags[i]) {
points = points + tagPoints[i];
Serial.print("TAG: ");
Serial.println(tagID);
Serial.print("Points: ");
Serial.println(points);
break;
}
}
if (points >= 8) {
Serial.println("WINNNER");
win();
} else {
for(int i = 0; i < (points); i++) {
//mx.setRow(i,0xff);
mx.setColumn(i,0xff);
delay(DELAYTIME);
}
}
Serial.print("Total POINTS: ");
Serial.println(points);
delay(200);
}
}
//Read new tag if available
boolean getID() {
// Getting ready for Reading PICCs
if ( ! mfrc522.PICC_IsNewCardPresent()) { //If a new PICC placed to RFID reader continue
//Serial.println("NEWCARD");
return false;
}
if ( ! mfrc522.PICC_ReadCardSerial()) { //Since a PICC placed get Serial and continue
//Serial.println("READCARD");
return false;
}
tagID = "";
for ( uint8_t i = 0; i < 4; i++) { // The MIFARE PICCs that we use have 4 byte UID
//readCard[i] = mfrc522.uid.uidByte[i];
tagID.concat(String(mfrc522.uid.uidByte[i], HEX)); // Adds the 4 bytes in a single String variable
}
tagID.toUpperCase();
mfrc522.PICC_HaltA(); // Stop reading
return true;
}
void win() {
points = 0;
for (int i = 0; i < 3; i++) {
int rmin = 0, rmax = ROW_SIZE-1;
int cmin = 0, cmax = (COL_SIZE\*MAX_DEVICES)-1;
mx.clear();
while ((rmax > rmin) && (cmax > cmin))
{
// do row
for (int i=cmin; i<=cmax; i++)
{
mx.setPoint(rmin, i, true);
delay(DELAYTIME/MAX_DEVICES);
}
rmin++;
// do column
for (uint8_t i=rmin; i<=rmax; i++)
{
mx.setPoint(i, cmax, true);
delay(DELAYTIME/MAX_DEVICES);
}
cmax--;
// do row
for (int i=cmax; i>=cmin; i--)
{
mx.setPoint(rmax, i, true);
delay(DELAYTIME/MAX_DEVICES);
}
rmax--;
// do column
for (uint8_t i=rmax; i>=rmin; i--)
{
mx.setPoint(i, cmin, true);
delay(DELAYTIME/MAX_DEVICES);
}
cmin++;
}
}
mx.clear();
}
Launcher
The original design of the launcher was a tube that would need an additional feeder tube to function. The issue with this design was that the ball would get stuck in the feeder tube and not be able to be launched. The solution was to change the design to a single tube that would be able to launch the ball without the need of a feeder tube.
Launcher Initial Design:

Launcher Final Design:

However, this design didn't allow enough balls to be loaded in and our project changed to a single ball launcher. At this point, we decided to completely pivot from a putting assistant to a fun golf ball launcher. The lesson learned was to not be afraid to change the design if it doesn't work.
The final pivot point was the addition of a miss counter. This is done via a mic on the back of the device that can track if the user misses. This allows us to re-gamify the system and add a competitive aspect to the game.
Code Snippets
ESC Arming:
The most difficult part of the launcher code was the arming of the ESC. This required us to go through the BLHELI_32 documentation and codebase to figure out the specific commands to send to the ESC. The code below is the final code that we used to arm the ESC.

Motor.attach(0);
delay(100);
Motor.writeMicroseconds(0);
delayMicroseconds(400);
Motor.writeMicroseconds(1800);
delayMicroseconds(500);
Motor.writeMicroseconds(1000);
Hole
Code Snippets
Pixel Matrix: The pixel matrix output is done via two ways.
- Manually setting each row / col.
- The
win()andupdatePoints()functions use this method.
- The
- Using a custom
updateDisplay()function.- Other functions like
dissolve(),displayNumberOne(), anddisplayNumberTwo()all use this update function.
- Other functions like
The updateDisplay() function is as follows:
void updateDisplay(bool matrix[][COL_SIZE])
{
for (int r = 0; r < ROW_SIZE; r++)
{
for (int c = 0; c < COL_SIZE; c++)
{
mx.setPoint(r, c, matrix[r][c]);
}
}
}
Score Tracking (Microphone): Below is the code used to detect if the player missed their shot. It is a simple system that pulls the microphone to see if it drop below its volume threshold.
#define VOLUME_THRESHOLD 20
while (digitalRead(button))
{
int sound = analogRead(mic);
if (sound < VOLUME_THRESHOLD)
{
hits++;
delay(500);
Serial.print("HIT: ");
Serial.println(sound);
points++;
updatePoints();
}
if (hits >= 16)
{
break;
}
}