Gumball machine on an Arduino Uno
Drop a coin and a servo gate lets out one gumball, with a sensor that spots jams, a coin counter and a sold-out sign on an LCD. The whole build, an Arduino Uno and 6 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Intermediate About 30 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $38. On Mokxi they're free.
What you'll learn
- Motors
- Sensors
- Displays
- Controllers
- Moving a servo to an angle
- Difficulty: Intermediate
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 11
How it works
Drop a coin and a servo gate lets out one gumball, with a sensor that spots jams, a coin counter and a sold-out sign on an LCD.
Arduino Uno R3 runs the sketch below. The circuit around it: Hobby servo, Obstacle sensor, FC-51, Limit switch, lever microswitch, Pushbutton, Piezo speaker, Character LCD with I2C backpack. pinMode sets COIN_PIN as an input with its pull-up on. digitalRead reads whether COIN_PIN is HIGH or LOW. Serial.begin opens the serial port at 9600 baud. tone plays a note on PIEZO_PIN. millis reads the time since power-up, so the sketch can time things without stopping.
Parts list
11 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Hobby servoIn Halloween Prop Kit, Christmas Lights Kit, Starter Projects Kit.
- 1 × Obstacle sensor, FC-51
- 3 × Power, 5 V
- 1 × Limit switch, lever microswitchIn Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Arduino Uno R3 pin GND | Ground |
| Hobby servo pin PWM | Arduino Uno R3 pin 9 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 8 |
| Pushbutton pin 1a | Arduino Uno R3 pin 4 |
| Obstacle sensor, FC-51 pin OUT | Arduino Uno R3 pin 3 |
| Limit switch, lever microswitch pin NO | Arduino Uno R3 pin 2 |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Hobby servo pin GND | Ground |
| Hobby servo pin VCC | 5 V |
| Obstacle sensor, FC-51 pin GND | Ground |
| Obstacle sensor, FC-51 pin VCC | 5 V |
| Limit switch, lever microswitch pin COM | Ground |
| Pushbutton pin 2a | Ground |
| Piezo speaker pin 2 | Ground |
| Character LCD with I2C backpack pin GND | Ground |
| Character LCD with I2C backpack pin VCC | 5 V |
The note on the canvas
Word for word, as it sits beside the circuit in the editor.
Try it
1. Press Run. The LCD reads Gumballs: 10.
2. Click the coin switch to drop a coin. The gate servo swings open.
3. Drag the drop sensor's slider close and back to nothing: a gumball fell. The gate shuts and the count goes down.
4. Drop a coin and leave the slider alone: the machine tries twice, then keeps your coin as credit and says OUT OF ORDER.
5. Press SERVICE to refill the globe. The credit you were owed comes out next.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Gumball machine: one coin, one gumball. A microswitch under the coin slot
// counts coins, a servo swings a gate under the globe to let one gumball
// roll out, and an IR sensor in the chute watches for it to drop. If nothing
// comes out, the machine tries once more, and if the chute stays empty it
// keeps your coin as credit and calls for service instead of eating it.
//
// coin switch lever microswitch: NO -> pin 2, COM -> GND (INPUT_PULLUP)
// drop sensor FC-51 IR sensor across the chute: OUT -> pin 3, VCC, GND
// service pushbutton: pin 4 -> button -> GND (refills the globe)
// piezo pin 8 -> piezo -> GND
// gate servo PWM -> pin 9, VCC -> 5 V, GND -> GND
// LCD 16x2 with an I2C backpack at 0x27: SDA A4, SCL A5
//
// HOW IT WORKS
// The loop never waits. Each pass it reads the switches, then lets the
// machine take one small step from the state it is in:
//
// READY waiting. A credit and a gumball in the globe open the gate.
// DISPENSE the gate is open. The drop sensor seeing a gumball means
// success; DROP_MS without one means a miss.
// RETRY after a miss the gate shuts for a moment and tries again,
// which shakes loose a gumball sitting on the gate's edge.
// SETTLE the gate is shut after a sale; wait a little before the
// next one so two gumballs never roll out together.
// FAULT two misses in a row: jammed, or the globe is empty even
// though the count says otherwise. The coin stays as credit
// and the machine takes no more coins until it is serviced.
//
// A coin only holds the switch down for a moment, and a gumball falls past
// the sensor in a blink, but redrawing the LCD takes a good part of a
// tenth of a second. So both quick events land on the Uno's two interrupt
// pins, 2 and 3: the moment one happens a tiny function (an ISR) notes it,
// and the loop deals with it when it comes round.
//
// The coin switch also bounces: its contacts chatter for a few
// milliseconds as they land and again as they let go. The coin ISR counts
// the first touch and then ignores the switch until the loop has seen it
// sit released for DEBOUNCE_MS, so one coin is always one count.
//
// HOW TO PLAY
// Click the coin switch to drop a coin. The gate opens: drag the drop
// sensor's slider close (a gumball falling past) and back to nothing.
// Leave the slider alone to see the jam handling. Press SERVICE to refill.
//
// THINGS TO TRY
// Make it cost two coins: change PRICE. Change FULL for a bigger globe.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Servo.h>
const int COIN_PIN = 2;
const int DROP_PIN = 3;
const int SERVICE_PIN = 4;
const int PIEZO_PIN = 8;
const int GATE_PIN = 9;
const int GATE_SHUT = 0; // degrees: the gate blocks the globe
const int GATE_OPEN = 90; // degrees: one gumball can roll through
const int FULL = 10; // gumballs in a freshly filled globe
const int PRICE = 1; // coins per gumball
const int MAX_TRIES = 2; // how many times to try before calling it a jam
const unsigned long DEBOUNCE_MS = 25;
const unsigned long DROP_MS = 1500; // how long a gumball may take to fall
const unsigned long RETRY_MS = 400; // the gate shut between two tries
const unsigned long SETTLE_MS = 600; // the gate shut after a sale
enum State { READY, DISPENSE, RETRY, SETTLE, FAULT };
State state = READY;
unsigned long stateAt = 0; // when the machine entered its state
LiquidCrystal_I2C lcd(0x27, 16, 2);
Servo gate;
int stock = FULL; // gumballs we think are in the globe
int coins = 0; // every coin ever accepted
int paid = 0; // coins toward the next gumball
int credits = 0; // gumballs paid for and not yet given
int sold = 0; // gumballs that really came out
int tries = 0; // attempts at the gumball now being dispensed
bool screenDirty = true;
// Set by the interrupt handlers, read by the loop. `volatile` tells the
// compiler they can change behind the loop's back.
volatile uint8_t coinsWaiting = 0; // coins the ISR caught, not yet handled
volatile bool coinArmed = true; // false from a coin until the switch is let go
volatile unsigned long coinEdgeAt = 0; // the last time pin 2 changed at all
volatile bool gumballSeen = false; // the drop sensor saw something fall
void coinISR() { // pin 2 changed: pressed, released or bouncing
coinEdgeAt = millis();
if (coinArmed && digitalRead(COIN_PIN) == LOW) {
coinArmed = false; // ignore the bounces that follow
coinsWaiting++;
}
}
void dropISR() { // pin 3 went LOW: something broke the beam
gumballSeen = true;
}
// The coin switch is armed again once it reads released (HIGH) and has
// not changed for DEBOUNCE_MS: the bounces are over.
void rearmCoin(unsigned long now) {
noInterrupts();
unsigned long quietFor = now - coinEdgeAt;
interrupts();
if (!coinArmed && digitalRead(COIN_PIN) == HIGH && quietFor >= DEBOUNCE_MS) {
coinArmed = true;
}
}
// The SERVICE button is slow and human, so plain polling with a debounce
// is enough: it returns true once, at the moment the press is believed.
bool serviceStable = false;
bool serviceRaw = false;
unsigned long serviceChangedAt = 0;
bool servicePressed(unsigned long now) {
bool raw = digitalRead(SERVICE_PIN) == LOW; // pulled up, so pressed reads LOW
if (raw != serviceRaw) {
serviceRaw = raw;
serviceChangedAt = now; // it moved: start the clock again
}
if (raw != serviceStable && now - serviceChangedAt >= DEBOUNCE_MS) {
serviceStable = raw;
return raw;
}
return false;
}
bool chuteBlocked() {
return digitalRead(DROP_PIN) == LOW; // the FC-51 pulls OUT low while it sees something
}
void enter(State next, unsigned long now) {
state = next;
stateAt = now;
screenDirty = true;
}
// The LCD is slow, so each line is built in memory first and only sent
// when it differs from what the screen already shows.
char shown[2][17] = {"", ""};
void putLine(int row, const char *text) {
if (strcmp(shown[row], text) == 0) return;
strcpy(shown[row], text);
lcd.setCursor(0, row);
lcd.print(text);
}
void showScreen() {
char line[17];
if (state == FAULT) {
snprintf(line, sizeof line, "OUT OF ORDER ");
} else if (stock == 0) {
snprintf(line, sizeof line, "SOLD OUT ");
} else {
snprintf(line, sizeof line, "Gumballs: %-6d", stock);
}
putLine(0, line);
snprintf(line, sizeof line, "Coins %-3d Cred %d", coins, credits);
putLine(1, line);
screenDirty = false;
}
void takeCoin() {
if (state == FAULT || stock == 0) {
// A real coin mech would drop it into the return cup.
Serial.println("Coin returned: machine cannot sell");
tone(PIEZO_PIN, 220, 200);
return;
}
coins++;
paid++;
tone(PIEZO_PIN, 1320, 60);
if (paid >= PRICE) {
paid -= PRICE;
credits++;
}
Serial.print("Coin ");
Serial.print(coins);
Serial.print(", credit ");
Serial.println(credits);
screenDirty = true;
}
void openGate(unsigned long now) {
tries++;
gumballSeen = false; // only a gumball from now on counts
gate.write(GATE_OPEN);
Serial.print("Gate open, try ");
Serial.println(tries);
enter(DISPENSE, now);
}
void setup() {
Serial.begin(9600);
pinMode(COIN_PIN, INPUT_PULLUP);
pinMode(SERVICE_PIN, INPUT_PULLUP);
pinMode(DROP_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(COIN_PIN), coinISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DROP_PIN), dropISR, FALLING);
gate.attach(GATE_PIN);
gate.write(GATE_SHUT);
lcd.init();
lcd.backlight();
Serial.println("Gumball machine ready: insert a coin");
showScreen();
}
void loop() {
unsigned long now = millis();
// Collect the coins the interrupt caught. Interrupts are paused for the
// two lines that read and clear the count, so a coin cannot slip between.
noInterrupts();
uint8_t newCoins = coinsWaiting;
coinsWaiting = 0;
interrupts();
while (newCoins > 0) {
takeCoin();
newCoins--;
}
rearmCoin(now);
if (servicePressed(now)) {
stock = FULL;
tries = 0;
gate.write(GATE_SHUT);
Serial.print("Serviced: globe refilled with ");
Serial.println(stock);
enter(READY, now);
}
switch (state) {
case READY:
if (credits > 0 && stock > 0) {
tries = 0;
openGate(now);
}
break;
case DISPENSE:
if (gumballSeen) {
// Success: it fell past the sensor.
gate.write(GATE_SHUT);
credits--;
stock--;
sold++;
tries = 0;
tone(PIEZO_PIN, 1760, 120);
Serial.print("Dispensed ");
Serial.print(sold);
Serial.print(", left ");
Serial.println(stock);
if (stock == 0) Serial.println("Sold out");
enter(SETTLE, now);
} else if (now - stateAt >= DROP_MS) {
// Short dispense: the gate was open and nothing fell.
gate.write(GATE_SHUT);
if (tries < MAX_TRIES) {
Serial.println("No gumball yet, trying again");
enter(RETRY, now);
} else {
Serial.println("Jammed or empty: coin kept as credit, call service");
tone(PIEZO_PIN, 180, 600);
enter(FAULT, now);
}
}
break;
case RETRY:
if (now - stateAt >= RETRY_MS) openGate(now);
break;
case SETTLE:
// Wait for the gumball to clear the chute and the gate to shut.
if (now - stateAt >= SETTLE_MS && !chuteBlocked()) enter(READY, now);
break;
case FAULT:
// Nothing to do but wait for the service button (handled above).
break;
}
if (screenDirty) showScreen();
}
Code walkthrough
The key functions of the sketch, and what each one does.
setup sketch.ino
Runs once when the board starts. It gets the pins and libraries ready.
void setup() {
Serial.begin(9600);
pinMode(COIN_PIN, INPUT_PULLUP);
pinMode(SERVICE_PIN, INPUT_PULLUP);
pinMode(DROP_PIN, INPUT);
attachInterrupt(digitalPinToInterrupt(COIN_PIN), coinISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DROP_PIN), dropISR, FALLING);
gate.attach(GATE_PIN);
gate.write(GATE_SHUT);
lcd.init();
lcd.backlight();
Serial.println("Gumball machine ready: insert a coin");
showScreen();
}loop sketch.ino
Runs over and over for as long as the board has power. This is where the project does its work.
void loop() {
unsigned long now = millis();
// Collect the coins the interrupt caught. Interrupts are paused for the
// two lines that read and clear the count, so a coin cannot slip between.
noInterrupts();
uint8_t newCoins = coinsWaiting;
coinsWaiting = 0;
interrupts();
while (newCoins > 0) {
takeCoin();
newCoins--;
}
rearmCoin(now);
if (servicePressed(now)) {
stock = FULL;
tries = 0;
gate.write(GATE_SHUT);
Serial.print("Serviced: globe refilled with ");
Serial.println(stock);
enter(READY, now);
}
switch (state) {
case READY:
if (credits > 0 && stock > 0) {
tries = 0;
// ... 49 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The servo jitters, twitches or resets the board when it moves. Hobby servo
Power it from its own 5 V supply, not the Arduino 5 V pin, and join the grounds. A hobby servo can pull several hundred milliamps. Add a 100 uF to 470 uF capacitor across its supply. - The servo hums at the end of its travel or will not reach full angle. Hobby servo
Stay inside the pulse range, about 1000 to 2000 us for most hobby servos. Pushing past the end stop makes it strain. Calibrate the min and max pulse for your servo. - It detects nothing or always detects. Range depends on the pot and the surface, and black or shiny things reflect poorly. Obstacle sensor
Turn the pot until the OUT LED switches at your distance. Test with a pale, matte surface. OUT is LOW when something is seen, so read it as active low. - Bright sunlight or an incandescent lamp makes it fire at random, because they give off infrared light. Obstacle sensor
Shade the sensor or use it indoors. Power it from 3.3 V to 5 V, join the grounds, and keep it from facing other IR parts. - The input reads the wrong level, or floats when released. Limit switch
Use COM and one contact. NC is closed at rest, NO closes when pressed. Add a pull-up or INPUT_PULLUP so the pin never floats. - Readings chatter when the lever moves. Limit switch
Contacts bounce for a few milliseconds. Debounce in software with a 10 to 20 ms delay, or add a small capacitor across the switch. - The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.