Learning Goals 5 min
By the end of this lesson you will be able to:
- Build a single Arduino sketch that does two things at once — runs a continuous LED animation and listens for typed commands — without ever calling
delay()in the animation loop. - Combine every Cluster D idea (begin/println from L01-24, read/available from L01-26, digit conversion from L01-27, switch/case from L01-28) with the
millis()non-blocking pattern previewed in L01-22's stretch task. - Structure a project around three mutable globals —
mode,speed,step— that a tiny command parser updates from the keyboard and an animation engine reads to decide what to display next.
Warm-Up 10 min
Cluster D taught you to make the Arduino talk (L01-24), listen (L01-26), understand digits (L01-27), and dispatch commands cleanly (L01-28). Each of those was a one-trick sketch. Today they all show up in the same project — a remote-controlled LED toy.
Quick-fire puzzle
Look at this sketch — a "blinker" with a delay in it. While it's running, the user types s into the Serial Monitor to stop it. The sketch is reading serial inside loop(). So why does the stop command sometimes take a full second to register?
void loop() {
if (Serial.available() > 0) {
/* … handle a command … */
}
digitalWrite(9, HIGH);
delay(500);
digitalWrite(9, LOW);
delay(500);
}- How often does the
Serial.available()check actually happen? - If you type
sthe instant the LED switches off, when does the sketch notice? - What's the largest possible "I typed but nothing happened" delay before the sketch reacts?
Reveal the answer
- Only once per loop pass — and each pass spends a full second in two
delay(500)calls. So the check runs once a second, not thousands of times a second like a normal loop. - At the very next check — up to a full second later, depending on where in the cycle you typed.
- One second. The user sits and waits, wondering whether their keystroke even reached the Arduino. Awful UX.
The fix isn't "check serial more often" — it's get rid of delay. Today's project does exactly that. The animation timing comes from millis() snapshots, exactly as L01-22's stretch task previewed. The loop runs thousands of times a second, the animation advances on schedule, and every typed command is noticed almost instantly.
New Concept — the command/animation engine pattern 20 min
The big idea — one loop, two jobs
Every interactive Arduino device needs to do two things at once: respond to inputs and keep the outputs moving. With delay, the loop can only do one of them at a time — while it's waiting, nothing else happens. The non-blocking pattern fixes this by replacing "wait for half a second" with "check if half a second has passed".
Today's loop body will have exactly two pieces, in this order:
- Check the keyboard. If a character is waiting, parse it (the L01-28 switch).
- Check the clock. If enough time has passed since the last animation step, advance the animation one step.
Both checks are instant. The loop runs thousands of times a second. Most passes do nothing visible — the buffer is empty and the clock hasn't ticked over. But when an event happens, it's noticed in under a millisecond.
Three mutable globals — the state of the show
The whole project's state lives in three variables at the top of the sketch:
char mode = 's'; // current animation: 's' = stop, 'b' = blink, 'c' = chase, 'a' = alt
int speed = 300; // ms between animation steps
int step = 0; // which frame of the animation we're on
unsigned long lastStep = 0; // millis() snapshot from last advanceThat's the entire model. The command parser updates mode and speed. The animation engine reads mode and uses step to decide which LEDs to light. Each piece is small; together they're a real interactive device.
The non-blocking timing pattern
From L01-22's stretch task, the recipe for "do this every N milliseconds without blocking":
unsigned long now = millis();
if (now - lastStep >= speed) {
lastStep = now;
// do one animation step
}The loop runs constantly, but the body inside the if only fires when speed ms have passed since the last fire. In between, the loop is free to check the Serial buffer thousands of times — which is what lets typed commands feel instant.
Decoding the speed digit — the L01-27 trick
The user types a single digit to set the animation tempo. We use the digit-conversion formula from L01-27 to turn the character into a number, then map it to a sensible delay in milliseconds. A simple mapping: '1' = fast (100 ms), '9' = slow (900 ms), step of 100 ms per digit.
if (isDigit(c) && c != '0') {
speed = (c - '0') * 100; // '3' → 300 ms
}One line of arithmetic — exactly the pattern L01-27 set up. We disallow '0' because a 0 ms step would run as fast as the loop, which is too fast to see.
The animation engine — switch on mode, use step
Each mode is one branch of a switch on mode. The branch reads step (which counts up forever) and decides which LEDs to light. The "blink" mode uses step % 2 to alternate on/off; "chase" uses step % 3 to cycle through the three LEDs; "alt" uses step % 2 with the LEDs split into two groups; "stop" turns everything off and ignores step entirely.
switch (mode) {
case 'b': // blink — all on, all off
if (step % 2 == 0) setLeds(HIGH, HIGH, HIGH);
else setLeds(LOW, LOW, LOW);
break;
case 'c': // chase — one at a time
setLeds(step % 3 == 0, step % 3 == 1, step % 3 == 2);
break;
}The whole engine is six or eight lines. Each frame is one expression. The mode determines which expression runs; step determines which frame within the mode.
Why it matters
This is the smallest version of the loop that lives inside every interactive device you own. A game console's main loop reads the controller while drawing the screen; a microwave's reads the keypad while running the timer; a 3D printer's reads serial commands while stepping the motors. All three follow today's exact shape — check inputs, check the clock, advance the state, repeat — at thousands of cycles per second. Today's six modes are a toy. The pattern is industrial.
Worked Example — build the show 25 min
Goal: a serial-controlled three-LED show with four animation modes (blinked, chase, alternate, stop), an adjustable speed (1–9), and a help menu (?). Same wiring as L01-15 (red, yellow, green on D9, D10, D11, sharing the − rail for GND). No buzzer needed today.
The wiring
The sketch — full listing
// Serial Light Show — Cluster D project
const int RED_PIN = 9;
const int YELLOW_PIN = 10;
const int GREEN_PIN = 11;
char mode = 's';
int speed = 300;
int step = 0;
unsigned long lastStep = 0;
void setLeds(int r, int y, int g) {
digitalWrite(RED_PIN, r);
digitalWrite(YELLOW_PIN, y);
digitalWrite(GREEN_PIN, g);
}
void printHelp() {
Serial.println("--- Serial Light Show ---");
Serial.println("b = blink c = chase a = alternate s = stop");
Serial.println("1-9 = speed (1 fastest, 9 slowest)");
Serial.println("? = this help");
}
void handleCommand(char c) {
switch (c) {
case 'b': case 'B':
case 'c': case 'C':
case 'a': case 'A':
case 's': case 'S':
mode = tolower(c);
step = 0;
Serial.print("mode = ");
Serial.println(mode);
break;
case '1': case '2': case '3':
case '4': case '5': case '6':
case '7': case '8': case '9':
speed = (c - '0') * 100;
Serial.print("speed = ");
Serial.print(speed);
Serial.println(" ms");
break;
case '?':
printHelp();
break;
default:
Serial.print("Unknown: ");
Serial.println(c);
break;
}
}
void animate() {
switch (mode) {
case 'b':
if (step % 2 == 0) setLeds(HIGH, HIGH, HIGH);
else setLeds(LOW, LOW, LOW);
break;
case 'c':
setLeds(step % 3 == 0, step % 3 == 1, step % 3 == 2);
break;
case 'a':
if (step % 2 == 0) setLeds(HIGH, LOW, HIGH);
else setLeds(LOW, HIGH, LOW);
break;
case 's':
default:
setLeds(LOW, LOW, LOW);
break;
}
}
void setup() {
Serial.begin(9600);
pinMode(RED_PIN, OUTPUT);
pinMode(YELLOW_PIN, OUTPUT);
pinMode(GREEN_PIN, OUTPUT);
printHelp();
}
void loop() {
// Job 1: check the keyboard
if (Serial.available() > 0) {
char c = Serial.read();
handleCommand(c);
}
// Job 2: check the clock
unsigned long now = millis();
if (now - lastStep >= (unsigned long)speed) {
lastStep = now;
animate();
step = step + 1;
}
}Walk through what each part does
- The four mutable globals at the top are the entire state of the show. Read them and you can tell exactly what's on screen.
setLedsis the same helper you wrote in L01-15 and L01-23 — drive all three LEDs in one call.printHelpis a separate function so it can be called fromsetup()(the welcome banner) and from the?command. Wrote-once, used twice.handleCommandis the L01-28 switch — letters changemode, digits changespeed,?prints help, anything else falls through to the default complaint. Notice the use oftolower(c)to normalise stacked-case input into a single canonical mode letter.animateis the engine — one branch per mode, each readingstepwith a modulo to pick a frame.loopis just two if-checks: serial-buffer first, clock second. Nothing else. Every other line is in a helper.
Upload, open the Monitor, drive the show
- Upload at 9600 baud. Open the Serial Monitor; set line-ending to "No line ending".
- You should see the help banner appear and the LEDs sit dark (mode =
stop is the default). - Type
b. The LEDs start blinking together at 300 ms. The Monitor confirmsmode = b. - Type
1. The blink speeds up to 100 ms. Type9— it slows to 900 ms. - Type
c. The blink changes to a chase — red, yellow, green, red, yellow, green… - Type
a. The animation switches to alternate: red+green on while yellow off, then yellow on while the others off. - Type
?. The help prints again, without interrupting the animation. - Type
s. Show stops. - Type
x. Monitor saysUnknown: x; animation keeps doing whatever it was doing.
Every command should feel instant — there's no perceptible lag between Send and the show changing. That's the non-blocking pattern doing its job.
Trace one full chase cycle
Suppose mode is 'c' and step just became 0. Fill in the LED states for the next five steps as animate() runs.
| step | step % 3 | red | yellow | green |
|---|---|---|---|---|
| 0 | 0 | HIGH | LOW | LOW |
| 1 | 1 | LOW | HIGH | LOW |
| 2 | ____ | ____ | ____ | ____ |
| 3 | ____ | ____ | ____ | ____ |
| 4 | ____ | ____ | ____ | ____ |
If you can fill in steps 2–4 without uploading, the modulo trick has clicked.
Try It Yourself — extend the show 15 min
Goal: Add a new reverse chase mode triggered by the key r. Instead of red → yellow → green like the regular chase, it goes green → yellow → red.
Plan: add r to the letter group in handleCommand's case stack (so it sets mode = 'r'). Then add a new branch case 'r': in animate() with the LEDs in reverse — use the modulo of 3 but swap which slot lights up. Update printHelp() to mention the new mode.
Questions:
- The forward chase uses
step % 3 == 0for red. What does reverse chase use to light green first? ____ - Why is updating
printHelp()just as important as adding the case? ____ - Could you do this without adding any new case in
handleCommand— just by adding it toanimate()? Why or why not? ____
Goal: Add a variable-length blink mode v where the ON time and OFF time are different — red flashes briefly (100 ms on), then yellow sits longer (400 ms on), then green flashes briefly again. The single speed control no longer fits this pattern because each colour wants its own time.
Plan: this is harder than it sounds. The simplest approach: keep a second mutable global int subStep = 0; that counts within a "cycle", and have your v-mode branch use subStep to pick: subStep == 0 → red 100 ms; subStep == 1 → yellow 400 ms; subStep == 2 → green 100 ms. The catch: the time-between-steps now depends on which step you're in.
You'll need a second variable for the "speed of this particular step". One approach:
case 'v':
if (subStep == 0) { setLeds(HIGH, LOW, LOW); stepSpeed = 100; }
else if (subStep == 1) { setLeds(LOW, HIGH, LOW); stepSpeed = 400; }
else { setLeds(LOW, LOW, HIGH); stepSpeed = 100; }
subStep = (subStep + 1) % 3;
break;Then in loop(), use stepSpeed instead of speed for the timer compare only when mode == 'v'.
Questions:
- Why doesn't
speedwork for this mode? ____ - What single global variable lets one mode "lie" to the timer about how long the current step should last? ____
- Most professional animation engines store each step as
{leds, duration}instead of using a chain ofifs. Sketch in your notebook what that would look like for the v mode. ____
Goal: Add a pause command that's different from stop. The key p freezes the animation in place (the LEDs stay in whatever state they were in); pressing p again resumes from where it left off; s still resets the show entirely.
Plan: add a fourth mutable global bool paused = false;. The p command flips it. In loop(), only do the animation step if !paused. The timer check should still run — but skip the call to animate() and the step increment when paused. Also make sure to update lastStep when you unpause, otherwise a long pause makes the show "catch up" by firing instantly.
Questions:
- If you forget to update
lastStepon unpause, what happens for the first second or two after resuming, and why? ____ - Why is
pdifferent fromsin terms of which globals it changes? ____ - What change to
?(the help) does this need? ____
Mini-Challenge — design your own mode 10 min
Make it yours
Pick a single letter command (any unused one — you've burned b c a s 1-9 ? so far, and r p v if you did the Try-It-Yourself tasks). That key invokes a brand-new animation pattern of your own design. Some seeds, but you don't have to use any of them:
- Heartbeat: two quick blinks of red close together, then a longer pause, repeating.
- Bounce: light moves R → Y → G → Y → R → Y → G → Y, so it appears to ping-pong instead of looping.
- Random: pick a random colour every step. (Use
random(3)from L01-22's preview — returns 0, 1, or 2.) - Morse SOS: blink red in the SOS pattern (three short, three long, three short, then pause).
Your task:
- Pick a letter and a pattern.
- Add the letter to
handleCommand's stacked case group of mode letters. - Add a new
caseinanimate()that implements the pattern. You may add new helpers or mutable globals if needed. - Update
printHelp()so the new command shows in the menu. - Type
?in the Monitor to verify your help line looks good; then type the new letter to verify the animation runs.
It works if:
- The help banner lists your new command.
- Typing the letter switches to your animation, with the
mode = Xconfirmation in the Monitor. - You can still
stop, switch back to a built-in mode (b,c,a) and the show works as before. - You can still adjust the speed with
1-9while your mode is running (unless your mode deliberately ignores speed, like SOS would).
Reveal a sample bounce-mode implementation
// Add to handleCommand's letter-stack:
case 'n': case 'N': // "n" for "boungN" — or any free letter
// Add to animate()'s switch:
case 'n': {
int pos = step % 4; // 0,1,2,3,0,1,2,3,...
// Map: 0=red, 1=yellow, 2=green, 3=yellow
setLeds(pos == 0,
pos == 1 || pos == 3,
pos == 2);
break;
}
// Add to printHelp():
Serial.println("n = bounce (R Y G Y …)");The trick is that the "bounce" sequence R-Y-G-Y has period 4 (not 3), so step % 4 is the right counter. Yellow appears in two of the four positions (1 and 3), red in just one, green in just one — giving the visual feel of a light moving across and back. A single line of boolean expressions inside setLeds drives all three LEDs from the position number. This is exactly the kind of "describe the pattern, let the engine play it" structure professional animation libraries use.
Recap 5 min
Cluster D's project is a tiny interactive device built around a two-job loop(): check the keyboard, check the clock, do the next thing for whichever fires. Four mutable globals — mode, speed, step, lastStep — hold the entire state. A switch parses single-letter commands; another switch picks the animation. No delay() anywhere in the loop. Type a command, the show changes; sit back, the show runs. This is the shape of every interactive embedded program from here on, and you just built the smallest version of one.
- Non-blocking loop
- A
loop()body that never callsdelay(). It usesmillis()snapshots to decide when enough time has passed, freeing the rest of the loop to check inputs and run other jobs at thousands of cycles per second. - Command parser
- The piece of a sketch that reads incoming characters and decides what each one means. Today's was a switch with stacked cases for case-insensitivity and a default branch for unknown input.
- Animation engine
- The piece of a sketch that drives the outputs over time. Today's was a switch on
modewith one branch per animation, each usingstep(the frame number) to pick what to display. - State variable
- A mutable global that some part of the code writes and another part reads. The parser writes
modeandspeed; the engine reads them. This separation is what lets the two jobs coexist cleanly. - Modulo (
%) for cycling - The expression
step % Ncycles through 0, 1, …, N−1, 0, 1, … forever. The animation engine uses this to pick which frame of an N-frame loop to show right now. - Help command
- The
?convention from old command-line tools: typing it prints a menu of all available commands. Today'sprintHelp()doubles as the startup banner and the runtime help — wrote-once, used-twice.
Homework 5 min
The two-mode mash-up. Pick any two of the animation modes (built-in or one you invented in the Try-It-Yourself or Mini-Challenge) and add a new command m that alternates between them on each step. So if you pick b (blink) and c (chase), pressing m makes the show go blink-frame, chase-frame, blink-frame, chase-frame… on every step.
- Add
mtohandleCommand's letter-stack, setmode = 'm'. - In
animate(), add acase 'm':branch. Usestep % 2to choose which sub-mode runs on this frame. You can either copy the body of each sub-mode in or call your sub-mode-implementing helpers (cleaner — see hint). - The speed control still works — it sets the interval between any two frames, whatever sub-mode they came from.
- Update
printHelp()with one line for the newmcommand, explaining which two modes it alternates between.
Hint: if your animation branches were complex enough, it's cleaner to extract each one into a helper function — void animateBlink(int s), void animateChase(int s), etc. Then the m branch becomes one tidy if/else.
Also: a design reflection on paper.
- List every mutable global your final sketch uses. Next to each, mark which functions write to it and which read. ____ (A clean design has each global written by exactly one function — usually the parser — and read by one or two — usually the engine.)
- The Cluster D project has zero new components compared to L01-15. Yet it feels much more powerful. Why? What did Cluster D add? ____
- Suppose you wanted a second Arduino across the room to receive these same commands wirelessly. Which parts of today's sketch would you keep unchanged, and which would you have to replace? ____ (Hint: the parser and engine wouldn't change. Only "where does
ccome from?" would.)
Bring back next class:
- The saved
.inofile (call itserial-light-show-mashup). - A 30-second phone video showing you typing five different commands into the Monitor and the LEDs responding to each.
- Your three written reflection answers, in your notebook.
Heads up for next class: Cluster D is done. L01-30 "RGB LED Wiring" opens Cluster E — a single tiny package that holds three LEDs (red, green, blue) inside one dome. You'll learn the two ways its pins can be arranged (common-cathode vs common-anode) and wire one onto your breadboard ready for the colour-mixing lesson that follows.