Learning Goals 5 min
By the end of this lesson you will be able to:
- Take the bare reaction timer from L01-22 and turn it into an arcade-style game with multiple rounds, a running average score, fake-start detection, an audio "go!" cue, and a replay button — all on the same wiring.
- Drive the whole game from a small state machine (IDLE → PREP → WAITING → RESULT → GAME_OVER → loop) — the L01-23 + L01-29 pattern applied to a game-loop instead of an alarm or animation.
- Store per-round results in an array, compute an average and a best-of-N at game-end, and print a clean "scorecard" to the Serial Monitor — the L01-33 data shape used for game state.
Warm-Up 10 min
L01-22's reaction timer measured one number once: "how fast was that one slap?" That's a sensor reading. Today you build the game around it — start screens, a series of rounds, a "play again" prompt at the end, and the small flourishes that turn a measurement into an experience.
Quick-fire puzzle
Compare in your head: L01-22's reaction timer vs an arcade game you've played that involves reflex testing (Whac-a-Mole, Guitar Hero, the lightning-quick "press button when target appears" mini-game in any console).
- How does the arcade game tell you "get ready" before it tests you?
- How does it handle a player who slams the button before the cue (a fake start)?
- How does it show you your final score — one number, or several together?
- What does it do when the game's over — sit silent, or invite you to play again?
Reveal the answer
- Countdowns and audio cues — "ready, set, GO!" plus a beep or visual change. The cue is unambiguous and not at the same exact moment every time, so the player can't anticipate it.
- Penalty — fake starts get logged, sometimes with their own loud "wrong!" sound. Real arcade games never reward a guess.
- A scorecard — each round's time, average, best, sometimes worst. Showing the spread tells the player whether they were consistent or got lucky once.
- "Press to play again" — never just hang. A good loop respects the player's time but invites them back instantly.
All four ideas — countdown, penalty, scorecard, replay — fit cleanly on top of the L01-22 timer. Today's project adds each one as a tiny piece of additional state-machine logic.
New Concept — a game is a state machine with a scorecard 15 min
The big idea — five states, four transitions
Every arcade game ever made has a "game loop" that's some flavour of state machine. Ours has five states, drawn as a circle:
The data — three arrays, one int
The whole game's state fits in a tiny block at the top of the sketch:
const int ROUNDS = 5;
int reactionTimes[ROUNDS]; // ms per round, 0 = not yet played
int currentRound = 0; // 0..ROUNDS-1
int falseStarts = 0; // penalty counter
int state = 0; // 0=IDLE 1=PREP 2=WAITING 3=RESULT 4=GAME_OVERThe L01-33 idea: data is the game's memory. reactionTimes[] stores each round's time so we can compute average and best at the end. currentRound tells us where we are in the series. falseStarts counts the player's button-mashing penalties. state drives the switch.
Fake-start detection — what makes the game fair
If the player presses the button during PREP (before the LED turns on), they've guessed instead of reacted. That's a fake start. Two reasonable responses:
- Soft: log it as +1 to
falseStarts, retry the same round with a new random delay. - Hard: end the game immediately with a "DISQUALIFIED" message.
The worked example uses the soft version — newer players get a chance to learn. The Try-It-Yourself stretches go harder.
The "go!" cue and the audio
Polished arcades give an unambiguous "go!" cue. We use the LED (visual) plus a 50 ms beep at 1200 Hz (audio). Players can react to either; many people are faster on audio. The audio is more important than it sounds — it's the single change that makes the timer feel like a game instead of an experiment.
The scorecard at GAME_OVER
At the end, sum the array, compute the average, find the minimum (the best round), and print it all to the Monitor in a clear format. This is the same array-of-numbers handling as L01-33 but with the player's data instead of musical notes:
int total = 0;
int best = 9999;
for (int i = 0; i < ROUNDS; i = i + 1) {
total = total + reactionTimes[i];
if (reactionTimes[i] < best) best = reactionTimes[i];
}
int avg = total / ROUNDS;Why it matters
You've now seen the state-machine pattern three times: the L01-23 burglar alarm (2 states), the L01-29 light show (4 states), and today's reaction game (5 states). It scales. The data-in-arrays pattern has shown up in L01-33 (notes), L01-34 (animation frames), and now today (round results). Together these two patterns — state machine for what to do, arrays for what to remember — describe nearly every interactive embedded device. Today is the cleanest example yet of bolting them together.
Worked Example — build the arcade 25 min
The wiring
Same as L01-22, plus a buzzer:
- LED + 220 Ω on D9 — the stimulus (L01-07 layout).
- Button on D7 with
INPUT_PULLUP— the "react" / "start" / "replay" button (L01-17 layout). - Piezo buzzer on D8 — the audio "go!" cue (L01-14 layout).
- Shared GND through the − rail.
- One pin left floating: A0 stays unconnected, on purpose, to seed the random number generator with electrical noise.
The sketch — full game listing
// Reaction-Time Arcade — five-state game with scorecard
const int LED_PIN = 9;
const int BTN_PIN = 7;
const int BUZZER_PIN = 8;
const int ROUNDS = 5;
const int MIN_PREP_MS = 1500;
const int MAX_PREP_MS = 4000;
const int TIMEOUT_MS = 3000; // no-react cap
// States
const int IDLE = 0;
const int PREP = 1;
const int WAITING = 2;
const int RESULT = 3;
const int GAME_OVER = 4;
// State data
int state = IDLE;
int reactionTimes[ROUNDS];
int currentRound = 0;
int falseStarts = 0;
unsigned long prepStart = 0;
unsigned long targetDelay = 0;
unsigned long cueAt = 0;
int lastButton = HIGH;
// === Helpers ===
bool pressed() {
int now = digitalRead(BTN_PIN);
bool edge = (lastButton == HIGH && now == LOW);
lastButton = now;
if (edge) delay(5); // debounce
return edge;
}
void printScorecard() {
Serial.println("=== GAME OVER ===");
int total = 0;
int best = 9999;
for (int i = 0; i < ROUNDS; i = i + 1) {
Serial.print("Round "); Serial.print(i + 1);
Serial.print(": "); Serial.print(reactionTimes[i]);
Serial.println(" ms");
total = total + reactionTimes[i];
if (reactionTimes[i] < best) best = reactionTimes[i];
}
Serial.print("Average: "); Serial.print(total / ROUNDS); Serial.println(" ms");
Serial.print("Best: "); Serial.print(best); Serial.println(" ms");
Serial.print("False starts: "); Serial.println(falseStarts);
Serial.println("Press to play again.");
}
void startRound() {
prepStart = millis();
targetDelay = random(MIN_PREP_MS, MAX_PREP_MS);
digitalWrite(LED_PIN, LOW);
state = PREP;
}
void setup() {
Serial.begin(9600);
pinMode(LED_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
pinMode(BTN_PIN, INPUT_PULLUP);
randomSeed(analogRead(A0)); // noise-seed (A0 left floating)
Serial.println("Reaction-Time Arcade — press to start");
}
void loop() {
switch (state) {
case IDLE:
if (pressed()) {
currentRound = 0;
falseStarts = 0;
Serial.println("GO! Round 1 of 5");
startRound();
}
break;
case PREP:
if (pressed()) { // fake start!
falseStarts = falseStarts + 1;
Serial.println("FALSE START — retry");
tone(BUZZER_PIN, 200, 300); // low buzz
delay(400);
startRound();
}
else if (millis() - prepStart >= targetDelay) {
digitalWrite(LED_PIN, HIGH);
tone(BUZZER_PIN, 1200, 50); // "go!" beep
cueAt = millis();
state = WAITING;
}
break;
case WAITING:
if (pressed()) {
int reaction = (int)(millis() - cueAt);
reactionTimes[currentRound] = reaction;
Serial.print(" "); Serial.print(reaction); Serial.println(" ms");
digitalWrite(LED_PIN, LOW);
state = RESULT;
cueAt = millis(); // reuse for result-hold
}
else if (millis() - cueAt >= TIMEOUT_MS) {
reactionTimes[currentRound] = TIMEOUT_MS;
Serial.println(" TIMEOUT (3000 ms)");
digitalWrite(LED_PIN, LOW);
state = RESULT;
cueAt = millis();
}
break;
case RESULT:
if (millis() - cueAt >= 800) {
currentRound = currentRound + 1;
if (currentRound >= ROUNDS) {
printScorecard();
state = GAME_OVER;
}
else {
Serial.print("Round "); Serial.print(currentRound + 1); Serial.println(" of 5");
startRound();
}
}
break;
case GAME_OVER:
if (pressed()) {
Serial.println();
Serial.println("--- new game ---");
state = IDLE;
}
break;
}
}Walk through what each part does
- Constants block — names every magic number so changes (more rounds, different cue range) are one-line edits.
- State machine globals —
stateplus the small bookkeeping ints, the same shape as L01-29. pressed()helper — wraps L01-19 state-change detection with a tiny debounce. Returns true exactly once per physical press. Used by every state.printScorecard()— the L01-33 array-traverse trick: walk the array, sum, track the min, print everything.startRound()— DRY helper. Used by both IDLE→PREP and RESULT→PREP transitions.setup()— the trick line israndomSeed(analogRead(A0)). A0 is left floating; the noise gives a different seed every boot, so the random delays in PREP genuinely vary.loop()— one switch onstate, one case per state. Each case decides "do I stay here, do I transition?" and acts accordingly. The whole game is in this 60-line switch.
Upload and play
- Upload at 9600 baud. Open the Monitor. Banner: "Reaction-Time Arcade — press to start."
- Press the button. Monitor: "GO! Round 1 of 5". LED is off — wait for it.
- After 1.5 to 4 seconds (varies), LED snaps on + beep. Slap the button. Monitor: " 245 ms" (or whatever you got).
- Brief pause (800 ms), then Monitor: "Round 2 of 5". Repeat 5 times.
- After round 5: a printed scorecard with all five times, average, best, and the false-start count.
- Press to play again — back to the start.
- Try to cheat: press the button during the PREP wait. Monitor: "FALSE START — retry", buzzer's low buzz, then the same round resumes with a fresh random delay.
- Try to "freeze": press once at IDLE, then ignore the cue. After 3 seconds the round auto-times-out at 3000 ms.
Trace one transition on paper
Suppose the player presses the button 320 ms after the LED lit. Fill in what happens at each step.
| Event | State before | Action | State after |
|---|---|---|---|
| Press while LED off (PREP) | PREP | fake-start branch fires | PREP (new random delay) |
| PREP timer hits target | PREP | ____ | ____ |
| Player presses 320 ms after cue | ____ | ____ | ____ |
| 800 ms pause finishes (and round < 5) | ____ | ____ | ____ |
| Round 5 RESULT pause finishes | ____ | ____ (calls printScorecard) | ____ |
Walking through this table is how you sanity-check any state machine. Every row is one transition; every transition has a clear trigger and a clear destination. If you can fill in the table without re-reading the code, the state-machine pattern has clicked.
Try It Yourself — three polish moves 15 min
Goal: A countdown jingle before round 1. After the IDLE press, play three short beeps spaced 500 ms apart ("ready, set, go") before entering PREP for the first round.
Plan: add a fourth state, COUNTDOWN, between IDLE and PREP. On IDLE press, transition to COUNTDOWN and remember countdownStart = millis(). In COUNTDOWN, fire a tone at 0 ms, 500 ms, 1000 ms; transition to PREP at 1500 ms.
(For simplicity, you can also do all three beeps with three tone()+delay() calls inside the IDLE press handler, before calling startRound(). It blocks for 1.5 s, but at startup that's fine.)
Questions:
- Why doesn't the "blocking" simpler version need a new state? ____
- What's the user-experience effect of giving the player a "ready, set" cue before the first round only — not before every round? ____
- What sound (frequency, length) would feel most "arcade"? Try a few. ____
Goal: A "new personal best" celebration. After RESULT calculates the round's time, check whether it beats the lowest time of all previous rounds in this game. If yes, play a quick rising arpeggio (three notes climbing) and print "NEW BEST!" alongside the time.
Plan: keep a int currentBest = 9999; at the start of each game (reset in the IDLE→PREP transition). In the WAITING→RESULT transition, if reaction < currentBest, update currentBest, print the message, play tone(BUZZER, 523), tone(BUZZER, 659), tone(BUZZER, 784) with brief gaps.
Questions:
- Why does
currentBestreset at game start, not at chip boot? ____ - Round 1 is always a new best (nothing has happened before it). How does your code handle that gracefully? ____ (Hint: starting
currentBestat 9999 makes the first round always less.) - Should a TIMEOUT (3000 ms) count as a candidate "best"? Why or why not? ____
Goal: A persistent leaderboard. Keep an array of the top 3 all-time best reactions across all games played since power-on (resets at unplug). At the end of each game, slot the player's best round into the leaderboard if it's good enough, then print the top 3 at GAME_OVER.
const int LB_SIZE = 3;
int leaderboard[LB_SIZE] = { 9999, 9999, 9999 };
void tryInsertLeaderboard(int time) {
for (int i = 0; i < LB_SIZE; i = i + 1) {
if (time < leaderboard[i]) {
// Shift the rest down
for (int j = LB_SIZE - 1; j > i; j = j - 1) {
leaderboard[j] = leaderboard[j - 1];
}
leaderboard[i] = time;
return;
}
}
}In printScorecard, find this game's best round, call tryInsertLeaderboard(best), then print the leaderboard.
Questions:
- The inner loop shifts entries "down". Trace through what happens when you insert 200 into
{180, 220, 300}. ____ - Why is the final state
{180, 200, 220}and not{180, 200, 300}? ____ - Real arcade machines store leaderboards in non-volatile memory (EEPROM on AVR, flash on modern boards). Why is "lost on unplug" only OK for a teaching project? ____
Mini-Challenge — two-player duel mode 10 min
"Whoever presses first wins the round"
Add a second button on D6 (same INPUT_PULLUP wiring as the first). When the LED fires, whichever player presses first wins the round and their reaction time is recorded. Five rounds; the player with the most wins takes the game.
Your task:
- Add
const int BTN2_PIN = 6;and a matchingpressed2()helper for state-change detection on it. - Track
int wins1 = 0; int wins2 = 0;as game state. - In the PREP state, both buttons cause fake starts (assign the penalty to whichever player jumped).
- In WAITING, the first button down wins — record the reaction time and the winner.
- The scorecard reports each player's wins, their per-round best, and crowns a winner.
It works if:
- Both players can press independently; whoever hits the button first wins.
- Pressing during PREP counts as a fake start (with player number printed).
- At game end, the Monitor prints something like
Player 1: 3 wins, best 220 ms. Player 2: 2 wins, best 195 ms. Winner: PLAYER 1.
Reveal one valid WAITING case
case WAITING: {
bool p1 = pressed();
bool p2 = pressed2();
if (p1 || p2) {
int reaction = (int)(millis() - cueAt);
int winner = p1 ? 1 : 2;
if (winner == 1) {
wins1 = wins1 + 1;
if (reaction < best1) best1 = reaction;
}
else {
wins2 = wins2 + 1;
if (reaction < best2) best2 = reaction;
}
Serial.print(" P"); Serial.print(winner);
Serial.print(" — "); Serial.print(reaction); Serial.println(" ms");
digitalWrite(LED_PIN, LOW);
state = RESULT;
cueAt = millis();
}
else if (millis() - cueAt >= TIMEOUT_MS) {
Serial.println(" no-one reacted!");
state = RESULT;
cueAt = millis();
}
break;
}The new bit: read both buttons in the same loop pass; p1 || p2 is the "someone pressed" trigger; p1 ? 1 : 2 picks the winner. If both presses arrive on the same loop pass (within ~1 ms of each other — vanishingly rare for human reflexes), p1 wins on the tiebreaker. The braces around case WAITING: { … } create a local scope so the new p1, p2, reaction variables don't leak between cases — useful when a switch case needs locals.
Recap 5 min
An arcade game is a state machine with a scorecard. Five states (IDLE / PREP / WAITING / RESULT / GAME_OVER) drive the game loop; an array of per-round times is the scorecard. Polish moves on top of the bare L01-22 timer — random delays seeded from analog noise, an audible "go!" cue, fake-start detection, a timeout for distracted players, a "press to play again" loop — turn a one-shot measurement into a real game. The same state-machine + arrays combo will reappear in every interactive build from here on.
- Game loop
- The structured cycle a game runs in: take input, update state, render output, wait, repeat. Every game ever made — from Pong to Fortnite — has one. Today's reaction game is the smallest interesting version.
- Random seeding
- The act of giving
random()a different starting value each time the program runs, so the same "random" sequence doesn't repeat.randomSeed(analogRead(A0))with A0 left floating uses electrical noise to pick a fresh seed every boot. - Fake start
- A pressed-too-early input in a reaction game. Real arcades penalise them; this one logs them and retries. Detecting them is one of the marks of a polished game vs a sketch.
- Timeout
- An upper bound on how long the game waits for the player to react. Without it, a distracted player could "freeze" the game indefinitely. Today: 3000 ms.
- Scorecard
- A formatted end-of-game summary — per-round results, derived stats (average, best), plus any penalties. Prints to the Monitor at GAME_OVER. The L01-33 array-handling pattern in a player-data context.
- Personal best / leaderboard
- Stats tracked across multiple games in one session. Without flash memory writes (Level 2 topic), the values reset on unplug.
- Local case scope
- Wrapping a switch case body in braces (
case X: { … }) so that new variables declared inside don't conflict with sibling cases. Becomes necessary when cases need their own locals — like the two-player WAITING case above.
Homework 5 min
Tune your arcade. Take the worked-example game and tweak three of its constants and behaviours to make it feel like your game. Pick freely:
- Increase
ROUNDSfrom 5 to 10 (longer game). - Widen the prep delay to 2000–6000 ms (more suspense per round).
- Lower the TIMEOUT to 1000 ms (much faster pace, more pressure).
- Change the "go!" beep to two notes (a more distinctive cue).
- Make the fake-start penalty harder — three strikes and the game ends in DISQUALIFIED.
- Add a "best round so far" prompt at the start of each round.
Pick any three. Upload, play a few games, and write down in your notebook how each tweak changed how the game feels to play.
Also: a design reflection on paper.
- If you had to describe today's sketch's state machine to a friend without showing them the code, what would you draw? ____
- You stored five reaction times in an array, then computed the average. What if the game were 100 rounds instead of 5 — would you still store every time? Why or why not? ____ (Hint: you could keep a running total without storing each.)
- The fake-start penalty is "log it and retry". Argue for a different design choice (DQ, time penalty, score zeroed) — pick one and defend it. ____
- What's the simplest possible game you could build with the same five states but different inputs/outputs? ____ (Hint: Whac-a-Mole with three LEDs would use the same shape exactly.)
Bring back next class:
- The saved
.inofile (call itreaction-arcade-tuned). - A 30-second phone video showing two full rounds plus the scorecard.
- Your four written reflection answers, in your notebook.
Heads up for next class: L01-45 "Morse Code Blinker" is the next Build. Instead of a game, you'll encode text (your own name in Morse) into LED dots and dashes — combining strings, character arithmetic from L01-27, and timing into a tiny information-coding device.