A living-room ball launcher that lets my dog play fetch by herself. She drops a tennis ball into a hopper, a countdown gives her three seconds to clear the exit, and the ball is thrown a dialed-in distance of 3 to 15 feet.
Most automatic ball launchers are built for yards, not living rooms. They throw 30 feet or more, and the dog has no safe way to trigger them. I wanted something that fits in a living room, is operated entirely by the dog, and gives her time to get out of the way before the ball leaves the machine. This revision features a drop in hopper vs the initial design that was essentially a large pvc pipe with a funnel at one end and flywheels on the other.
A single dial sets launch speed so throws can be tuned from about 3 ft up to a hard cap of 15 ft, the most the room allows.
A funnel sized for a standard tennis ball. A beam sensor notices the drop, so no button press or human help is needed.
The launch waits three seconds after the drop, with a beep each second, so she learns the rhythm and clears the exit path.
The ball takes a short, simple path. It drops into the hopper and rests on a servo-driven gate, where an infrared beam detects it. That starts a three second countdown. Just before the gate opens, two counter-rotating wheels spin up to the speed set by the dial. The ball rolls down a short ramp into the gap between the wheels, which pinch it and throw it out of the front of the enclosure.
The firmware is a four-state machine with no blocking delays, so the sensor and timers always stay responsive.
| Transition | Trigger |
|---|---|
| IDLE to COUNTDOWN | Beam stays broken for 100 ms (debounce). The dial position is read and locked in for this throw. |
| Inside COUNTDOWN | Buzzer chirps at 0, 1 and 2 seconds. Wheels ramp to the selected speed during the last 0.8 s so the machine is not spinning at full speed while the dog is still nearby. |
| COUNTDOWN to FIRE | 3.0 seconds elapsed. The servo opens the gate and the ball rolls into the wheels. |
| FIRE to RESET | 0.8 seconds later the gate closes and the wheels are switched off. |
| RESET to IDLE | 1.0 second pause, then the sensor is armed again. |
| Option | Distance control | Complexity | Fit for a living room |
|---|---|---|---|
| Dual flywheels (chosen) | Continuous, just change motor PWM | Two motors, one driver | Good. Low power, easy to enclose, easy to cap in software. |
| Spring-loaded arm | Coarse, usually a few fixed positions | Mechanical, needs a re-cocking mechanism | Fair. Hard to dial in short distances. |
| Pneumatic | Good, via pressure regulator | Compressor, tank, valves | Poor. Noisy and overkill for 15 ft. |
A flywheel launcher throws the ball at roughly the speed of the wheel rim. That makes the sizing a short calculation. These are ideal numbers that ignore air drag, wheel slip and the small height of the exit above the floor, so they are a starting point for calibration, not a spec.
| Quantity | Value | Notes |
|---|---|---|
| Maximum range | 15 ft (4.57 m) | Hard limit from the room |
| Speed needed at a 45° launch | about 6.7 m/s (15 mph) | Theoretical minimum, but the ball would arc toward the ceiling |
| Speed needed at a 20° launch | about 8.4 m/s (19 mph) | Chosen exit angle. Flatter, safer indoors. |
| Speed needed for a 3 ft throw at 20° | about 3.7 m/s (8 mph) | Low end of the dial |
| Wheel diameter | 3 in (7.6 cm) | Rubber or urethane tread |
| Wheel speed range | about 900 to 2,100 RPM | Rim speed divided by wheel radius |
| Motors | 12V, 3,000 to 6,000 RPM no-load | Headroom for slip and for the speed sag when the ball loads the wheels |
The enclosure has three jobs: guide the ball, keep paws and noses away from the wheels, and stay put when it fires. The wheels sit in a closed box whose only openings are the ball-sized entry from the hopper and the ball-sized exit slot at the front.
All angles and gaps are starting values to tune on the bench. Wheel grip on the ball and the ramp slope are the two things most likely to need adjustment.
Materials. Quarter-inch plywood is easy to cut and stiff enough. 3D-printed PETG panels are a good alternative because wheel spacing can be tuned between prints. The base is wide and weighted, with non-slip feet, so the launcher does not walk across the floor when it fires.
An Arduino Uno handles everything. A microcontroller with no operating system is a better fit than a single-board Linux computer here, because PWM, servo timing and sensor debouncing all need predictable timing.
| Arduino pin | Connects to |
|---|---|
D2 | IR break-beam receiver output (internal pull-up, LOW means beam broken) |
A0 | Speed potentiometer wiper |
D4 | Piezo buzzer |
D5, D6 | L298N ENA and ENB (PWM speed for each motor) |
D7, D8, D10, D11 | L298N IN1 to IN4 (motor direction) |
D9 | Gate servo signal |
| Part | Spec | Estimate |
|---|---|---|
| 2x DC motors | 12V, 3,000 to 6,000 RPM no-load, 380/390 class, shaft that fits the wheel hubs | $14 to $24 |
| 2x wheels | 3 to 3.5 in diameter, rubber or urethane tread | $10 to $20 |
| Motor driver | L298N dual H-bridge | $6 to $8 |
| Micro servo | SG90, 9 g, for the gate | $3 to $5 |
| IR break-beam sensor | Emitter and receiver pair | $5 to $8 |
| Microcontroller | Arduino Uno or Nano | $8 to $15 |
| Potentiometer | 10k linear | $1 to $2 |
| Piezo buzzer | 5V | $1 to $2 |
| Power | 12V 3A adapter and buck converter | $10 to $16 |
| Hopper | 3.5 in ID PVC pipe or printed funnel | $10 to $15 |
| Frame and base | Plywood or printed panels | $15 to $25 |
| Miscellaneous | Wire, connectors, screws, rubber feet | $10 to $15 |
| Total | Everything bought new | about $93 to $155 |
Spare motors, scrap plywood or a 3D printer bring that down. Plan on roughly 8 to 15 hours for the whole build, with wheel spacing and range calibration taking the most trial and error.
The sketch below runs the state machine. MIN_LAUNCH_PWM and MAX_LAUNCH_PWM are deliberately conservative starting values. Raise the maximum in small steps during testing until a throw lands at 15 ft, then leave it there.
#include <Servo.h>
// Pin definitions
const int IR_SENSOR_PIN = 2; // break-beam receiver, LOW = beam broken (ball waiting at gate)
const int BUZZER_PIN = 4;
const int MOTOR_A_PWM = 5; // driver enable A (PWM)
const int MOTOR_B_PWM = 6; // driver enable B (PWM)
const int MOTOR_A_IN1 = 7;
const int MOTOR_A_IN2 = 8;
const int SERVO_GATE_PIN = 9;
const int MOTOR_B_IN1 = 10;
const int MOTOR_B_IN2 = 11;
const int SPEED_POT_PIN = A0;
Servo gateServo;
// Gate positions in degrees, tune to your mechanism
const int GATE_CLOSED = 0;
const int GATE_OPEN = 90;
// Timing in milliseconds
const unsigned long COUNTDOWN_MS = 3000; // delay before the ball is released
const unsigned long SPINUP_LEAD_MS = 800; // wheels start this long before release
const unsigned long FIRE_DURATION_MS = 800; // gate stays open while the ball drops and launches
const unsigned long RESET_DELAY_MS = 1000; // pause before accepting another ball
const unsigned long BEAM_DEBOUNCE_MS = 100; // beam must stay broken this long to count
// Speed limits, PWM 0 to 255
// Conservative starting values. Calibrate with a tape measure:
// raise MAX_LAUNCH_PWM in small steps until the first landing point is 15 ft,
// then stop. The dial can never exceed this cap.
const int MIN_LAUNCH_PWM = 50; // calibrate so the dial minimum lands about 3 ft out
const int MAX_LAUNCH_PWM = 150; // calibrate so the dial maximum lands about 15 ft out
const int IDLE_PWM = 0;
enum State { IDLE, COUNTDOWN, FIRE, RESET_STATE };
State currentState = IDLE;
unsigned long stateStartTime = 0;
unsigned long beamSince = 0;
int lastBeepSecond = -1;
int launchSpeed = MIN_LAUNCH_PWM;
void setup() {
pinMode(IR_SENSOR_PIN, INPUT_PULLUP);
pinMode(MOTOR_A_IN1, OUTPUT);
pinMode(MOTOR_A_IN2, OUTPUT);
pinMode(MOTOR_B_IN1, OUTPUT);
pinMode(MOTOR_B_IN2, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
// The two wheels counter-rotate so both rims push the ball forward.
// If the ball is driven backward, swap the wires on one motor.
digitalWrite(MOTOR_A_IN1, HIGH);
digitalWrite(MOTOR_A_IN2, LOW);
digitalWrite(MOTOR_B_IN1, LOW);
digitalWrite(MOTOR_B_IN2, HIGH);
gateServo.attach(SERVO_GATE_PIN);
gateServo.write(GATE_CLOSED);
setMotorSpeed(IDLE_PWM);
Serial.begin(9600);
Serial.println("Ball launcher ready.");
}
void loop() {
switch (currentState) {
case IDLE:
if (digitalRead(IR_SENSOR_PIN) == LOW) {
if (beamSince == 0) beamSince = millis();
if (millis() - beamSince >= BEAM_DEBOUNCE_MS) {
launchSpeed = readSpeedDial();
stateStartTime = millis();
lastBeepSecond = -1;
beamSince = 0;
currentState = COUNTDOWN;
Serial.print("Ball detected. Launch PWM: ");
Serial.println(launchSpeed);
}
} else {
beamSince = 0;
}
break;
case COUNTDOWN: {
unsigned long elapsed = millis() - stateStartTime;
// One beep per second at 0 s, 1 s and 2 s
int secondMark = elapsed / 1000;
if (secondMark != lastBeepSecond && secondMark < 3) {
lastBeepSecond = secondMark;
tone(BUZZER_PIN, 1000, 150);
}
// Spin the wheels up just before the gate opens
if (elapsed >= COUNTDOWN_MS - SPINUP_LEAD_MS) {
setMotorSpeed(launchSpeed);
}
if (elapsed >= COUNTDOWN_MS) {
gateServo.write(GATE_OPEN);
stateStartTime = millis();
currentState = FIRE;
}
break;
}
case FIRE:
if (millis() - stateStartTime >= FIRE_DURATION_MS) {
gateServo.write(GATE_CLOSED);
setMotorSpeed(IDLE_PWM);
stateStartTime = millis();
currentState = RESET_STATE;
}
break;
case RESET_STATE:
if (millis() - stateStartTime >= RESET_DELAY_MS) {
currentState = IDLE;
Serial.println("Ready for next ball.");
}
break;
}
}
void setMotorSpeed(int pwmValue) {
analogWrite(MOTOR_A_PWM, pwmValue);
analogWrite(MOTOR_B_PWM, pwmValue);
}
int readSpeedDial() {
int raw = analogRead(SPEED_POT_PIN); // 0 to 1023
return map(raw, 0, 1023, MIN_LAUNCH_PWM, MAX_LAUNCH_PWM);
}
MIN_LAUNCH_PWM until that is about 3 ft.MAX_LAUNCH_PWM in steps of 10 to 15, with the dial at maximum, until the first landing point reaches 15 ft. Stop there and record the value.