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.
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.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 3 × Arcade buttonIn Claw Machine Kit, Fight Stick Kit, Pinball Table Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Motor driver, L9110S
- 1 × DC motor
- 1 × Battery: AA, coin cell, LiPoIn Handheld Console Kit, Laser Sword Kit.
- 1 × Photo interrupter, slot
- 2 × Power, 5 V
- 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 |
|---|---|
| Arcade button (1) pin 2 | Arduino Uno R3 pin GND |
| Arcade button (2) pin 2 | Arduino Uno R3 pin GND |
| Arcade button (3) pin 2 | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
| Piezo speaker pin 1 | Arduino Uno R3 pin 8 |
| Photo interrupter, slot pin SIG | Arduino Uno R3 pin 7 |
| Motor driver, L9110S pin AIB | Arduino Uno R3 pin 6 |
| Motor driver, L9110S pin AIA | Arduino Uno R3 pin 5 |
| Arcade button (3) pin 1 | Arduino Uno R3 pin 4 |
| Arcade button (2) pin 1 | Arduino Uno R3 pin 3 |
| Arcade button (1) pin 1 | Arduino Uno R3 pin 2 |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Motor driver, L9110S pin GND | Ground |
| Battery: AA, coin cell, LiPo pin V- | Ground |
| Battery: AA, coin cell, LiPo pin V+ | Motor driver, L9110S pin VCC |
| DC motor pin 1 | Motor driver, L9110S pin OA1 |
| DC motor pin 2 | Motor driver, L9110S pin OA2 |
| Photo interrupter, slot pin VCC | 5 V |
| Photo interrupter, slot pin GND | Ground |
| Character LCD with I2C backpack pin VCC | 5 V |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// 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 editorTroubleshooting
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.