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.
Try It Yourself 15 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.
Hint
Design a 5×8 trophy bitmap. createChar(0, trophy) in setup. In drawRoundOver(), if a new high was set, lcd.write((byte)0) at column 15 row 0.
Goal: Add a long-press reset for the high score. Hold the button for 2 s while in IDLE → wipes the high score.
Hint
Add the longPressFired field to the Btn struct (from L02-37). In the IDLE handler, check it; if true, set allTimeHigh = NO_SCORE and EEPROM.put it, then re-draw IDLE.
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.
Hint
This is genuinely big — name entry on a 16×2 LCD with only buttons is the same UX challenge as old game-console high-score entry. Multi-state for the entry screen, EEPROM-stored array of 3 records (4 bytes time + 3 bytes name = 7 bytes per record, 21 bytes total). Plan it on paper before coding.
Mini-Challenge · Tournament time 15 min
Set up a real tournament. Get 4–6 people. Each plays one round (3 attempts). The system tracks who has the current high score, who's second, etc.
For this challenge, polish the device:
- Mount everything on a flat board so the LCD and button are clearly visible to a standing player.
- Make sure the button is loud and tactile (big arcade button if you have one, or a tactile switch with a label).
- Use the trophy character (Easy task above) for new-high signalling.
- Run a 6-person tournament. Track results on paper.
It's arcade-quality when:
- A player who's never seen it can play their first round without verbal instruction (the LCD prompts are clear enough).
- The round-over screen makes new-high winners feel proud.
- Multiple people want to play more than one round in a row.
- Someone, eventually, beats the high score and there's genuine applause. That moment is the whole point.
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.
Homework 5 min
Run the tournament. Recruit 4+ players. Each plays one round (3 attempts). Record the data:
| Player | Attempt 1 | Attempt 2 | Attempt 3 | Round best |
|---|---|---|---|---|
| You | ? | ? | ? | ? |
| P2 | ? | ? | ? | ? |
| P3 | ? | ? | ? | ? |
| P4 | ? | ? | ? | ? |
Then answer:
- Who won? By how much?
- Did the winner improve from attempt 1 to attempt 3? Why might that be?
- The current all-time high after your tournament — whose is it?
- What single feature would you add for a v3 to make this even more fun for repeat players?
Bring back next class:
- Your filled-in tournament table.
- Your four written answers.
- Your
hw-l02-45.inosketch. - Cluster H wraps tomorrow with Schematic Reading II — the engineer's view of all the projects you've been building.