Built-in project · Arduino Uno R3

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.

The circuit runs here once the page has loaded.

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.

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 pinConnects to
Arduino Uno R3 pin GNDGround
Hobby servo pin PWMArduino Uno R3 pin 9
Piezo speaker pin 1Arduino Uno R3 pin 8
Pushbutton pin 1aArduino Uno R3 pin 4
Obstacle sensor, FC-51 pin OUTArduino Uno R3 pin 3
Limit switch, lever microswitch pin NOArduino Uno R3 pin 2
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Hobby servo pin GNDGround
Hobby servo pin VCC5 V
Obstacle sensor, FC-51 pin GNDGround
Obstacle sensor, FC-51 pin VCC5 V
Limit switch, lever microswitch pin COMGround
Pushbutton pin 2aGround
Piezo speaker pin 2Ground
Character LCD with I2C backpack pin GNDGround
Character LCD with I2C backpack pin VCC5 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.

sketch.ino · Arduino Uno R3
// 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 editor

Troubleshooting

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.