Learning Goals 5 min
- Polish the L02-40 reaction timer into an arcade-quality experience: LCD scoreboard, EEPROM-backed high score, big light, satisfying buzzer.
- Combine LCD (Cluster E), EEPROM (Cluster G), millis() timing (Cluster F), and debounced buttons (L02-37) into a single fun-feeling game.
- Add a 3-attempt round structure: best of three counts as your "score", then it's the next player's turn — proper arcade pacing.
Warm-Up 10 min
L01-22 had the bare-bones game: random delay, LED, button, reaction time. L02-40 added EEPROM high score. Today we add an LCD and arcade pacing. Same core mechanic, but now it feels like a thing you'd play more than once.
What "arcade" means here
The difference between a sketch and an arcade game:
- Visible high score on every "ready" screen.
- Multiple attempts per round (3 attempts, best counted).
- Big visible cues (LED + LCD + buzzer all reinforce the GO signal).
- Tight loops — the next round starts immediately, no thinking required.
- Score history on the LCD (this attempt, best in round, all-time best).
New Concept · Arcade-style game state 20 min
State machine — bigger than v1
The arcade version has more states than the simple version:
| State | What's happening |
|---|---|
| IDLE | Showing high score, "Press to play" |
| READY | Random 2-5 s wait. Show "Wait...". |
| ARMED | LED + tone + LCD says "GO!". Timer running. |
| RESULT | Brief display of this attempt's time + comparison. |
| FALSE | Player pressed before ARMED. Brief shame buzzer. |
| ROUND_OVER | After 3 attempts, show best of round. |
RESULT cycles back to READY for the next attempt; ROUND_OVER cycles back to IDLE.
Round structure
const int ATTEMPTS_PER_ROUND = 3;
unsigned long roundTimes[ATTEMPTS_PER_ROUND];
int attemptIdx = 0;
unsigned long bestThisRound = 0xFFFFFFFF;Each round: 3 attempts collected, best computed, compared against the EEPROM high score. The all-time high score is the BEST OF ALL ROUNDS, not BEST OF ALL ATTEMPTS — meaning you can't just spam the button hoping for one lucky try.
Two-line LCD layout for IDLE
Press to play Best: 247 ms
Two-line layout for RESULT (mid-round)
That: 312 ms 1/3 done
Two-line layout for ROUND_OVER
Round best: 247 ** NEW HIGH **
Worked Example · The arcade sketch 25 min
Step 1 — wiring
| Component | Pin |
|---|---|
| I²C LCD | A4 / A5 |
| Big arcade LED (with 220 Ω) | D9 |
| Buzzer | D8 |
| Player button (the big one!) | D2 (INPUT_PULLUP) |
If you have a 5+ V tactile arcade-style button (the kind with a metal ring), use it for D2 — feels great. Otherwise a regular pushbutton works fine.
Step 2 — the sketch
Save as reaction-arcade.ino:
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <EEPROM.h>
LiquidCrystal_I2C lcd(0x27, 16, 2);
const int LED=9, BUZZ=8, BTN=2;
const int HIGH_ADDR = 0;
const unsigned long NO_SCORE = 0xFFFFFFFF;
const int ATTEMPTS_PER_ROUND = 3;
unsigned long allTimeHigh = NO_SCORE;
unsigned long roundTimes[ATTEMPTS_PER_ROUND];
int attemptIdx = 0;
unsigned long bestThisRound = NO_SCORE;
enum State { IDLE, READY, ARMED, RESULT, FALSE_STATE, ROUND_OVER };
State state = IDLE;
unsigned long stateEnteredAt = 0;
unsigned long armDelay = 0;
unsigned long armedAt = 0;
unsigned long lastReaction = 0;
// ---- Debounced button ----
struct Btn {
int pin;
unsigned long lastChange;
int rawState, stableState, prevStable;
bool justPressed;
void begin(int p) {
pin = p;
pinMode(p, INPUT_PULLUP);
rawState = stableState = prevStable = digitalRead(p);
lastChange = millis();
}
void tick() {
int n = digitalRead(pin);
if (n != rawState) { rawState = n;
lastChange = millis();
}
justPressed = false;
if (millis() - lastChange >= 50 && rawState != stableState) {
prevStable = stableState;
stableState = rawState;
if (stableState == LOW && prevStable == HIGH) justPressed = true;
}
}
};
Btn btn;
void drawIdle() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Press to play");
lcd.setCursor(0, 1);
if (allTimeHigh == NO_SCORE) lcd.print("No high score");
else { char buf[17];
sprintf(buf, "Best: %lu ms", allTimeHigh);
lcd.print(buf);
}
}
void drawReady() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Wait...");
char buf[17];
sprintf(buf, "Attempt %d/%d", attemptIdx + 1, ATTEMPTS_PER_ROUND);
lcd.setCursor(0, 1);
lcd.print(buf);
}
void drawArmed() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("** GO ! **");
}
void drawResult() {
lcd.clear();
char buf[17];
sprintf(buf, "That: %lu ms", lastReaction);
lcd.setCursor(0, 0);
lcd.print(buf);
sprintf(buf, "%d/%d done", attemptIdx, ATTEMPTS_PER_ROUND);
lcd.setCursor(0, 1);
lcd.print(buf);
}
void drawFalse() {
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("False start!");
lcd.setCursor(0, 1);
lcd.print("Press to retry");
}
void drawRoundOver() {
lcd.clear();
char buf[17];
sprintf(buf, "Round: %lu ms", bestThisRound);
lcd.setCursor(0, 0);
lcd.print(buf);
lcd.setCursor(0, 1);
if (bestThisRound < allTimeHigh) lcd.print("** NEW HIGH **");
else lcd.print("Try again?");
}
void enterState(State next) {
state = next;
stateEnteredAt = millis();
switch (next) {
case IDLE:
digitalWrite(LED, LOW);
drawIdle();
break;
case READY:
digitalWrite(LED, LOW);
armDelay = random(2000, 5000);
drawReady();
break;
case ARMED:
digitalWrite(LED, HIGH);
armedAt = millis();
tone(BUZZ, 1800, 80);
drawArmed();
break;
case RESULT:
digitalWrite(LED, LOW);
drawResult();
break;
case FALSE_STATE:
digitalWrite(LED, LOW);
tone(BUZZ, 250, 350);
drawFalse();
break;
case ROUND_OVER:
digitalWrite(LED, LOW);
if (bestThisRound < allTimeHigh) {
allTimeHigh = bestThisRound;
EEPROM.put(HIGH_ADDR, allTimeHigh);
tone(BUZZ, 2200, 150);
delay(180);
tone(BUZZ, 2600, 200);
} else {
tone(BUZZ, 1500, 100);
}
drawRoundOver();
break;
}
}
void setup() {
Wire.begin();
lcd.init();
lcd.backlight();
pinMode(LED, OUTPUT);
pinMode(BUZZ, OUTPUT);
btn.begin(BTN);
Serial.begin(9600);
randomSeed(analogRead(A0));
EEPROM.get(HIGH_ADDR, allTimeHigh);
enterState(IDLE);
}
void loop() {
btn.tick();
switch (state) {
case IDLE:
if (btn.justPressed) {
attemptIdx = 0;
bestThisRound = NO_SCORE;
enterState(READY);
}
break;
case READY:
if (btn.justPressed) enterState(FALSE_STATE);
else if (millis() - stateEnteredAt >= armDelay) enterState(ARMED);
break;
case ARMED:
if (btn.justPressed) {
lastReaction = millis() - armedAt;
roundTimes[attemptIdx] = lastReaction;
if (lastReaction < bestThisRound) bestThisRound = lastReaction;
attemptIdx++;
enterState(RESULT);
}
break;
case RESULT:
if (millis() - stateEnteredAt >= 1500) {
if (attemptIdx >= ATTEMPTS_PER_ROUND) enterState(ROUND_OVER);
else enterState(READY);
}
break;
case FALSE_STATE:
if (btn.justPressed) enterState(READY);
break;
case ROUND_OVER:
if (millis() - stateEnteredAt >= 4000 || btn.justPressed) enterState(IDLE);
break;
}
}Step 3 — play your first round
LCD shows "Press to play" + high score. Press the button. The round begins:
- Attempt 1: Wait → GO → tap → "That: 312 ms" → next attempt.
- Attempt 2: Same flow.
- Attempt 3: Same.
- ROUND_OVER: shows your best of three. If it beat the all-time, double-tone celebrates + EEPROM updates.
- After 4 seconds (or a button press) it returns to IDLE.
Step 4 — play several rounds
Notice how the pacing feels. The 1.5 s RESULT display is just long enough to read your time, not so long you get bored. The 4 s ROUND_OVER lets the player savour a new high. The auto-return to IDLE means "next player, press the button" — perfect tournament rhythm.
Step 5 — power cycle, beat your record
Unplug, replug. LCD shows your high score from before. Play. If you beat it: celebration tone, EEPROM updates again.
Basic 5 min
Goal: Add a custom "trophy" character (from L02-30) that appears on the ROUND_OVER screen ONLY when a new all-time high is set.
Challenge 1 5 min
Goal: Add a long-press reset for the high score. Hold the button for 2 s while in IDLE → wipes the high score.
Challenge 2 5 min
Goal: Save the top THREE all-time scores plus the name (3 chars) of the player who set them. Programming a name uses a longer name-entry screen (3 buttons up/down/confirm). Major UX project; do it for the L1 Level 1 Recap.
Challenge 3 · The missing NEW HIGH 15 min
Play rounds until you beat the all-time best. The double celebration tone plays and the EEPROM updates. But the LCD says Try again?, not ** NEW HIGH **. The bug is in the worked example.
- Read
enterState()'sROUND_OVERcase anddrawRoundOver()line by line. Find why the message is wrong. - Fix it with a
bool newHighflag that is set beforeallTimeHighchanges. - Then add a "too fast" rule. A press less than 100 ms after GO cannot be a real reaction. Send it to
FALSE_STATE, notRESULT.
To test step 3, change 100 to 400 for a moment, then change it back.
It works if a new best shows ** NEW HIGH **, an ordinary round shows Try again?, and a too-fast press shows False start!.
Recap 5 min
The Reaction-Tester Arcade closes Cluster H's integration builds. The new lesson today wasn't technical — it was the pacing-and-polish discipline that turns a working game into a fun one. Six states (IDLE, READY, ARMED, RESULT, FALSE, ROUND_OVER), proper round structure (3 attempts), EEPROM-backed high score, LCD scoreboard, celebration audio for new highs. None of these required a new library or technique; all the parts came from earlier lessons. Cluster H's overall lesson: integration is its own skill, separate from the underlying tech. Tomorrow we wrap up L2 with schematic reading, debugging, and the recap before assessment.
- Round structure
- A repeatable unit of gameplay (here: 3 attempts, best counted). Provides pacing, comparable results between players, and natural breakpoints.
- Best-of-N
- Scoring multiple attempts and taking the best. Reduces the impact of one lucky / unlucky attempt, rewards skill over luck.
- Idle screen
- The attract-mode display shown when nothing is happening. Should communicate what the device does, how to start, and what's at stake (the high score).
- Celebration feedback
- Distinctive audio + visual cues for the most important events (new high score). Makes the moment feel earned. Generic beeps don't.
- Auto-return
- After a final screen, returning automatically to IDLE without requiring a press. Keeps the game ready for the next player without manual handoff.
- Pacing
- The rhythm of state transitions and screen durations. Too fast = confusing; too slow = boring. Tune by playtesting, not by guessing.
- Attract mode
- Old arcade-cabinet term for the screens shown when nobody's playing — designed to attract someone to step up. IDLE is your attract mode.
- Tournament-ready
- Designed for one-after-another play with minimal between-player overhead. Auto-return to IDLE, persistent high score, clear LCD state — all part of being tournament-ready.
Extra Mission 5 min
Part 1 — Design your own arcade game
Pick a simple test of skill people play at a fair or a party. Ideas: stop a moving light on a target, count taps in 5 seconds, press when you think 10 seconds have passed. On paper, design it for one button, one LED, a buzzer and the LCD.
Your design must include:
- The rules, in three sentences or fewer.
- A state list, like IDLE → READY → … → ROUND_OVER.
- The LCD screen for each state, drawn on 16×2 grids.
- How the score works, and what is saved as the all-time best.
Part 2 — Make it
Build it by reusing today's arcade structure: enterState(), one draw function per screen, a debounced button and an EEPROM best. Play five rounds and fix anything that feels wrong.
Bring back next class: your design, your hw-l02-45.ino sketch, and a video of one full round.