Built-in project · Arduino Uno R3

Ticket redemption counter on an Arduino Uno

Stop the running light in the middle to win tickets, watch a motor pay them out one by one with a sensor counting each, then trade them for prizes at the counter. The whole build, an Arduino Uno and 9 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 40 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $46. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Games
  • Motors
  • Sensors
  • Displays
  • Difficulty: Intermediate
  • Time: About 40 minutes
  • Board: Arduino Uno R3
  • Parts: 13

How it works

Stop the running light in the middle to win tickets, watch a motor pay them out one by one with a sensor counting each, then trade them for prizes at the counter.

Arduino Uno R3 runs the sketch below. The circuit around it: Arcade button, Piezo speaker, Motor driver, L9110S, DC motor, Battery: AA, coin cell, LiPo, Photo interrupter, slot. pinMode sets PLAY_PIN as an input with its pull-up on. digitalWrite drives MOTOR_B LOW. digitalRead reads whether b.pin is HIGH or LOW. analogWrite sends a PWM signal out on MOTOR_A. Serial.begin opens the serial port at 9600 baud.

Parts list

13 parts, plus the jumper wires. They retail for about $46; on Mokxi they're free.

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
Arcade button (1) pin 2Arduino Uno R3 pin GND
Arcade button (2) pin 2Arduino Uno R3 pin GND
Arcade button (3) pin 2Arduino Uno R3 pin GND
Piezo speaker pin 2Arduino Uno R3 pin GND
Piezo speaker pin 1Arduino Uno R3 pin 8
Photo interrupter, slot pin SIGArduino Uno R3 pin 7
Motor driver, L9110S pin AIBArduino Uno R3 pin 6
Motor driver, L9110S pin AIAArduino Uno R3 pin 5
Arcade button (3) pin 1Arduino Uno R3 pin 4
Arcade button (2) pin 1Arduino Uno R3 pin 3
Arcade button (1) pin 1Arduino Uno R3 pin 2
Character LCD with I2C backpack pin GNDArduino Uno R3 pin GND
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Motor driver, L9110S pin GNDGround
Battery: AA, coin cell, LiPo pin V-Ground
Battery: AA, coin cell, LiPo pin V+Motor driver, L9110S pin VCC
DC motor pin 1Motor driver, L9110S pin OA1
DC motor pin 2Motor driver, L9110S pin OA2
Photo interrupter, slot pin VCC5 V
Photo interrupter, slot pin GNDGround
Character LCD with I2C backpack pin VCC5 V

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
// Ticket redemption counter: an arcade skill game that pays out real tickets,
// and a prize counter where you spend them.
//
//   PLAY button     pin 2 -> arcade button -> GND   (INPUT_PULLUP, pressed = LOW)
//   NEXT button     pin 3 -> arcade button -> GND   (pick a prize)
//   REDEEM button   pin 4 -> arcade button -> GND   (buy the prize shown)
//   dispenser motor L9110S A-IA <- pin 5, A-IB <- pin 6, motor on OA1/OA2
//   ticket sensor   photo interrupter S -> pin 7 (a slotted disc on the motor)
//   piezo           pin 8 -> piezo -> GND
//   LCD 1602 (I2C)  SDA -> A4, SCL -> A5
//
// How to play. Press PLAY and a light (>) runs back and forth along the top
// row of the LCD. Press PLAY again to stop it. Stop it on the two middle
// squares [] for the jackpot. The closer you are, the more tickets you win.
//
// The tickets come out of a dispenser: a motor turns a wheel with one notch
// per ticket, and a photo interrupter sees every notch go by. The sketch only
// adds a ticket to your balance when the sensor confirms it, the way a real
// redemption machine does. If no notch comes by in time the dispenser is
// jammed: the motor stops (a safety interlock, so a stuck motor does not cook
// itself) and the sketch remembers how many tickets it still owes you. Press
// PLAY to try the payout again.
//
// Between games, NEXT steps through the prizes and REDEEM buys the one on the
// screen if you have enough tickets. The balance lives in EEPROM, so it is
// still there after a reset. Hold NEXT while the board starts to wipe it.
//
// Skills: a state machine (IDLE, RUNNING, PAYING, JAMMED), millis() timing
// with no long delay(), button debouncing, counting sensor pulses with a
// timeout, and saving data in EEPROM.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <EEPROM.h>

const int PLAY_PIN = 2;
const int NEXT_PIN = 3;
const int REDEEM_PIN = 4;
const int MOTOR_A = 5;      // PWM: how fast the dispenser turns
const int MOTOR_B = 6;      // held LOW so the motor only feeds forward
const int SENSOR_PIN = 7;   // LOW while a notch lets the light through
const int PIEZO_PIN = 8;

const int MOTOR_SPEED = 255;
const unsigned long STEP_MS = 110;          // how long the light sits on a square
const unsigned long FIRST_TICKET_MS = 2000; // the motor needs a moment to spin up
const unsigned long NEXT_TICKET_MS = 1000;  // then a notch should come by this often
const unsigned long DEBOUNCE_MS = 25;

LiquidCrystal_I2C lcd(0x27, 16, 2);

// The prize counter: names and prices, cheapest first.
const char *const PRIZE_NAMES[] = {"Sticker", "Bouncy ball", "Yo-yo", "Plush bear"};
const int PRIZE_COST[] = {5, 15, 30, 60};
const int PRIZES = 4;

// What we keep in EEPROM. MAGIC tells a saved card from a blank chip, which
// reads 255 in every byte.
const uint16_t MAGIC = 0x544B;  // "TK"
struct Card {
  uint16_t magic;
  uint16_t balance;
  uint16_t prizes;
};
Card card;

enum State { IDLE, RUNNING, PAYING, JAMMED };
State state = IDLE;

// The game.
unsigned long roundStart = 0;
int lastCell = -1;

// The payout.
int owed = 0;                 // tickets still to come out
int paid = 0;                 // tickets confirmed in this payout
unsigned long lastTicketAt = 0;
unsigned long ticketTimeout = FIRST_TICKET_MS;
int lastSensor = HIGH;

int prize = 0;                // the prize on show at the counter

// A debounced button: `fell` is true once, on the loop the press is settled.
struct Button {
  int pin;
  int stable;
  int raw;
  unsigned long changedAt;
  bool fell;
};
Button play = {PLAY_PIN, HIGH, HIGH, 0, false};
Button next = {NEXT_PIN, HIGH, HIGH, 0, false};
Button redeem = {REDEEM_PIN, HIGH, HIGH, 0, false};

void readButton(Button &b, unsigned long now) {
  b.fell = false;
  int r = digitalRead(b.pin);
  if (r != b.raw) {
    b.raw = r;
    b.changedAt = now;
  }
  if (r != b.stable && now - b.changedAt >= DEBOUNCE_MS) {
    b.stable = r;
    if (r == LOW) b.fell = true;
  }
}

void save() {
  EEPROM.put(0, card);  // put() only rewrites the bytes that changed
}

void motor(bool on) {
  analogWrite(MOTOR_A, on ? MOTOR_SPEED : 0);
  digitalWrite(MOTOR_B, LOW);
}

// The bottom row always shows the balance.
void showBalance() {
  lcd.setCursor(0, 1);
  lcd.print("Tickets: ");
  lcd.print(card.balance);
  lcd.print("       ");
}

void showLine(const char *text) {
  lcd.setCursor(0, 0);
  lcd.print(text);
  for (int i = strlen(text); i < 16; i++) lcd.print(' ');
}

void showPrize() {
  lcd.setCursor(0, 0);
  lcd.print(PRIZE_NAMES[prize]);
  for (int i = strlen(PRIZE_NAMES[prize]); i < 12; i++) lcd.print(' ');
  if (PRIZE_COST[prize] < 10) lcd.print(' ');
  lcd.print(PRIZE_COST[prize]);
  lcd.print("tk");
}

// Where the light is, `t` ms into a round: 0 to 15 and back again.
int cellAt(unsigned long t) {
  int step = (int)((t / STEP_MS) % 30);
  return step < 16 ? step : 30 - step;
}

// The prize table of the game. Cells 7 and 8 are the jackpot.
int ticketsFor(int cell) {
  int off = cell < 8 ? 7 - cell : cell - 8;  // squares away from the middle pair
  if (off == 0) return 20;
  if (off == 1) return 10;
  if (off <= 3) return 5;
  return 2;  // everybody wins something
}

// The track: dashes, with the jackpot pair [] in the middle.
char trackAt(int cell) {
  if (cell == 7) return '[';
  if (cell == 8) return ']';
  return '-';
}

// Draw the light at `cell`. Every character sent to an I2C LCD takes a few
// milliseconds, so after the first frame only the two squares that changed
// are redrawn: that keeps loop() quick enough to catch a short press.
void drawRunner(int cell, int previous) {
  if (previous < 0) {
    lcd.setCursor(0, 0);
    for (int i = 0; i < 16; i++) lcd.print(trackAt(i));
  } else {
    lcd.setCursor(previous, 0);
    lcd.print(trackAt(previous));
  }
  lcd.setCursor(cell, 0);
  lcd.print('>');
}

void startPayout(int tickets) {
  owed = tickets;
  paid = 0;
  lastSensor = digitalRead(SENSOR_PIN);
  lastTicketAt = millis();
  ticketTimeout = FIRST_TICKET_MS;
  motor(true);
  state = PAYING;
  showLine("Paying out...");
  Serial.print("Dispensing ");
  Serial.print(tickets);
  Serial.println(" tickets");
}

void setup() {
  Serial.begin(9600);
  pinMode(PLAY_PIN, INPUT_PULLUP);
  pinMode(NEXT_PIN, INPUT_PULLUP);
  pinMode(REDEEM_PIN, INPUT_PULLUP);
  pinMode(SENSOR_PIN, INPUT);
  pinMode(MOTOR_A, OUTPUT);
  pinMode(MOTOR_B, OUTPUT);
  motor(false);  // the dispenser is off before anything else happens

  lcd.init();
  lcd.backlight();

  EEPROM.get(0, card);
  if (digitalRead(NEXT_PIN) == LOW) card.magic = 0;  // NEXT held: wipe the card
  if (card.magic != MAGIC) {
    card.magic = MAGIC;
    card.balance = 0;
    card.prizes = 0;
    save();
    Serial.println("New ticket card");
  }
  Serial.print("Ticket counter ready, balance ");
  Serial.println(card.balance);
  showLine("PLAY to start!");
  showBalance();
}

void loop() {
  unsigned long now = millis();
  readButton(play, now);
  readButton(next, now);
  readButton(redeem, now);

  switch (state) {
    case IDLE:
      if (play.fell) {
        state = RUNNING;
        roundStart = now;
        lastCell = -1;
        Serial.println("Round started");
        tone(PIEZO_PIN, 660, 60);
      } else if (next.fell) {
        prize = (prize + 1) % PRIZES;
        showPrize();
        tone(PIEZO_PIN, 880, 30);
      } else if (redeem.fell) {
        uint16_t cost = PRIZE_COST[prize];
        if (card.balance >= cost) {
          card.balance -= cost;
          card.prizes++;
          save();
          Serial.print("Redeemed ");
          Serial.print(PRIZE_NAMES[prize]);
          Serial.print(" for ");
          Serial.print(cost);
          Serial.print(", balance ");
          Serial.println(card.balance);
          showLine("Enjoy your prize");
          tone(PIEZO_PIN, 1319, 200);
        } else {
          Serial.print("Need ");
          Serial.print(cost - card.balance);
          Serial.print(" more for ");
          Serial.println(PRIZE_NAMES[prize]);
          showLine("Not enough yet");
          tone(PIEZO_PIN, 196, 300);
        }
        showBalance();
      }
      break;

    case RUNNING: {
      int cell = cellAt(now - roundStart);
      if (cell != lastCell) {
        drawRunner(cell, lastCell);
        lastCell = cell;
      }
      if (play.fell) {
        int won = ticketsFor(cell);
        Serial.print("Stopped on ");
        Serial.print(cell);
        Serial.print(": ");
        if (won == 20) Serial.print("JACKPOT! ");
        Serial.print(won);
        Serial.println(" tickets");
        tone(PIEZO_PIN, won == 20 ? 1568 : 988, 150);
        startPayout(won);
      }
      break;
    }

    case PAYING: {
      // A notch going by: the sensor goes from blocked (HIGH) to light (LOW).
      int s = digitalRead(SENSOR_PIN);
      if (s == LOW && lastSensor == HIGH) {
        paid++;
        owed--;
        card.balance++;
        lastTicketAt = now;
        ticketTimeout = NEXT_TICKET_MS;
        Serial.print("Ticket ");
        Serial.println(paid);
        tone(PIEZO_PIN, 2093, 15);
        showBalance();
        if (owed == 0) {
          motor(false);
          save();  // once per payout, not once per ticket: EEPROM wears out
          state = IDLE;
          Serial.print("Payout done, balance ");
          Serial.println(card.balance);
          showLine("PLAY or NEXT");
        }
      }
      lastSensor = s;

      // Safety interlock: no ticket in time means the dispenser is jammed.
      if (state == PAYING && now - lastTicketAt > ticketTimeout) {
        motor(false);
        save();
        state = JAMMED;
        Serial.print("JAM! Motor stopped, still owe ");
        Serial.println(owed);
        showLine("JAM: call staff");
        tone(PIEZO_PIN, 220, 500);
      }
      break;
    }

    case JAMMED:
      // An attendant clears the jam, then PLAY finishes the payout.
      if (play.fell) {
        Serial.println("Retrying payout");
        startPayout(owed);
      }
      break;
  }
}

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(PLAY_PIN, INPUT_PULLUP);
  pinMode(NEXT_PIN, INPUT_PULLUP);
  pinMode(REDEEM_PIN, INPUT_PULLUP);
  pinMode(SENSOR_PIN, INPUT);
  pinMode(MOTOR_A, OUTPUT);
  pinMode(MOTOR_B, OUTPUT);
  motor(false);  // the dispenser is off before anything else happens

  lcd.init();
  lcd.backlight();

  EEPROM.get(0, card);
  if (digitalRead(NEXT_PIN) == LOW) card.magic = 0;  // NEXT held: wipe the card
  if (card.magic != MAGIC) {
    card.magic = MAGIC;
    card.balance = 0;
    card.prizes = 0;
    save();
    Serial.println("New ticket card");
  }
  Serial.print("Ticket counter ready, balance ");
  Serial.println(card.balance);
  showLine("PLAY to start!");
  showBalance();
}

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();
  readButton(play, now);
  readButton(next, now);
  readButton(redeem, now);

  switch (state) {
    case IDLE:
      if (play.fell) {
        state = RUNNING;
        roundStart = now;
        lastCell = -1;
        Serial.println("Round started");
        tone(PIEZO_PIN, 660, 60);
      } else if (next.fell) {
        prize = (prize + 1) % PRIZES;
        showPrize();
        tone(PIEZO_PIN, 880, 30);
      } else if (redeem.fell) {
        uint16_t cost = PRIZE_COST[prize];
        if (card.balance >= cost) {
          card.balance -= cost;
          card.prizes++;
          save();
          Serial.print("Redeemed ");
          Serial.print(PRIZE_NAMES[prize]);
          Serial.print(" for ");
          Serial.print(cost);
  // ... 82 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The button reads pressed all the time, or never changes, when wired to a pin. Arcade button
    Use the COM and NO terminals of the microswitch. Wire COM to ground, NO to the pin, and use INPUT_PULLUP. NC is the opposite and reads pressed when released.
  • One press counts as several presses in your code. Arcade button
    Microswitch contacts bounce for a few milliseconds. Ignore changes for 10 to 20 ms after the first one, or react only to the edge and wait before checking again.
  • A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
    A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.
  • Sound is very quiet or the pin seems overloaded. Piezo speaker
    Drive the piezo from a pin directly, or through a 100 ohm resistor. For a bigger buzzer, switch it with a transistor. Check that you wired the right two legs and joined grounds.
  • The motor does not turn or stops quickly when both inputs are the same. Motor driver
    Each output follows its input. Set one input high and the other low to turn, swap them to reverse. Equal inputs brake the motor rather than coast. Use PWM on one input for speed.
  • The driver gets hot or the board resets. Motor driver
    Power the motors from a separate supply of about 2.5 to 12 V, within the chip's rating, and share ground with the controller. Small motors only: stalled or large motors exceed its roughly 800 mA limit.
  • The motor does not spin, or the pin gets hot or the board resets when it is wired straight to a GPIO pin. DC motor
    A pin gives about 20 mA and a motor needs far more. Drive it through a transistor or motor driver, with its own supply and a shared ground.
  • The transistor or pin is damaged by spikes when the motor stops. DC motor
    Put a flyback diode across the motor, cathode to the positive side. A motor is a coil and kicks back voltage when switched off. Add a 0.1 uF capacitor across the motor terminals to cut noise.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.