Learning Goals 5 min
- Recap switch bounce from L01-18 — the few-millisecond flutter at the moment of a press — and the L1 fix of
delay(50). - Replace that
delay-based debounce with themillis()-based version: track a last-change timestamp, only accept a state as "stable" once it's been steady for 50 ms. - Wrap the whole thing into a reusable
readDebounced(pin)helper that fits inside a tick function — Cluster F discipline preserved.
Warm-Up 10 min
L01-18 fixed bounce by sticking a delay(50) after detecting a press. It worked, but it broke the whole "don't block the loop" principle we cemented in Cluster F. Today we replace that with the clean version.
What bounce looks like in real life
A tactile push-button is two metal plates pressed together by your finger. The first contact happens, then bounces apart microscopically, makes contact again, bounces apart, makes contact... for a few milliseconds. Your finger feels one press. The Arduino sees 5–20 separate HIGH→LOW transitions.
Without debouncing, "press counter" sketches count 5–20 each press. Game-controller sketches think you mashed the button five times. State-machine sketches flip back and forth in mid-bounce.
New Concept · The millis() debouncer 20 min
The idea in one sentence
A button is only "really" in a state once it's been in that state for at least N milliseconds without changing. Set N = 50 (the standard tactile-switch debounce window) and you're done.
The pattern
const unsigned long DEBOUNCE_MS = 50;
unsigned long lastChange = 0;
int rawState = HIGH;
int stableState = HIGH;
void tickButton() {
int now = digitalRead(BUTTON);
if (now != rawState) {
rawState = now;
lastChange = millis(); // bounce started
}
if (millis() - lastChange >= DEBOUNCE_MS && rawState != stableState) {
stableState = rawState; // bounce has settled
// ... fire an event based on stableState ...
}
}Three variables: rawState = whatever digitalRead said last (bouncy, untrustworthy); stableState = the state we're confident about; lastChange = when we last saw rawState change. The if-block at the bottom only commits a new stableState once the raw signal has held its new value for 50 ms.
State-change detection on top
Once stableState is reliable, you can build the "fired exactly once per press" logic on top:
if (stableState == LOW && prevStable == HIGH) {
// a brand-new press just happened (and the bounce is over)
pressCount++;
}
prevStable = stableState;Combined: bounce-free, edge-triggered, instant-feeling, non-blocking. Best of both worlds.
Wrap it in a reusable helper
For one button this is easy; for three buttons it's six variables and getting messy. Roll into a struct:
struct DebouncedButton {
int pin;
unsigned long lastChange = 0;
int rawState = HIGH;
int stableState = HIGH;
int prevStable = HIGH;
bool justPressed = false; // set true for one tick after a settled press
void tick() {
int now = digitalRead(pin);
if (now != rawState) { rawState = now;
lastChange = millis();
}
justPressed = false;
if (millis() - lastChange >= 50 && rawState != stableState) {
prevStable = stableState;
stableState = rawState;
if (stableState == LOW && prevStable == HIGH) justPressed = true;
}
}
};
DebouncedButton btn1{2}; // pin 2
DebouncedButton btn2{3};
void loop() {
btn1.tick();
btn2.tick();
if (btn1.justPressed) Serial.println("btn1 pressed!");
if (btn2.justPressed) Serial.println("btn2 pressed!");
}One struct per button. Each ticks itself. The flag justPressed is true for exactly one loop iteration per real press — perfect for "event" consumers.
Worked Example · The bounce-free press counter 20 min
Step 1 — wiring
| Component | Pin |
|---|---|
| Button 1 (INPUT_PULLUP, to GND) | D2 |
| Button 2 (INPUT_PULLUP, to GND) | D3 |
| Optional indicator LED | D13 (on-board) |
Step 2 — the sketch
Save as debounce-counter.ino:
// L02-37: Two debounced buttons with millis()
struct DebouncedButton {
int pin;
unsigned long lastChange;
int rawState, stableState, prevStable;
bool justPressed;
void begin(int p) {
pin = p;
pinMode(pin, INPUT_PULLUP);
rawState = stableState = prevStable = digitalRead(pin);
lastChange = millis();
justPressed = false;
}
void tick() {
int now = digitalRead(pin);
if (now != rawState) { rawState = now;
lastChange = millis();
}
justPressed = false;
if (millis() - lastChange >= 50 && rawState != stableState) {
prevStable = stableState;
stableState = rawState;
if (stableState == LOW && prevStable == HIGH) justPressed = true;
}
}
};
DebouncedButton btn1, btn2;
int count1 = 0, count2 = 0;
void setup() {
Serial.begin(9600);
pinMode(13, OUTPUT);
btn1.begin(2);
btn2.begin(3);
}
void tickHeartbeat() {
static unsigned long prev = 0;
static int state = LOW;
if (millis() - prev >= 500) {
prev = millis();
state = !state;
digitalWrite(13, state);
}
}
void loop() {
btn1.tick();
btn2.tick();
tickHeartbeat();
if (btn1.justPressed) {
count1++;
Serial.print("btn1: ");
Serial.println(count1);
}
if (btn2.justPressed) {
count2++;
Serial.print("btn2: ");
Serial.println(count2);
}
}Step 3 — test the "rapid press" case
Open Serial Monitor. Press button 1 ten times in five seconds. The count should go up by exactly 10. Not 30. Not 7. Exactly 10.
Now hold button 1 down for 3 seconds. The count goes up by exactly 1, then stays. Releasing doesn't fire an event (the helper detects press, not release).
Step 4 — verify both buttons work independently
Press both buttons in alternation, very rapidly. Each count goes up by exactly the right amount. The heartbeat LED on D13 stays at a steady 1 Hz — the loop isn't blocked at any point.
Step 5 — see the bounce by removing the debounce
For comparison, temporarily change the 50 in the tick to 1. Re-upload. Press a button once. You'll likely see btn1: 1, then btn1: 2, btn1: 3 from the SAME physical press — the raw bounce coming through. Restore 50 — the bug disappears.
Basic 5 min
Goal: Add a third button on D4 that resets both counters when pressed. Use the same DebouncedButton struct.
Challenge 1 5 min
Goal: Add a justReleased flag to the struct so you can detect button release as a separate event. Useful for "hold-to-do-X" gestures.
Challenge 2 5 min
Goal: Detect a long press (button held for ≥ 1 second). Add a longPressFired flag that fires once when the button has been stably pressed for 1000 ms.
Challenge 3 · The mode-cycling controller 15 min
Build a tiny controller with three debounced buttons and three LEDs. The system has 4 modes (off, mode A, mode B, mode C) and a single button cycles through them. The other two buttons do mode-specific actions. The LEDs visually represent which mode you're in.
Spec:
- D2 = mode-cycle button.
- D3 = action button A.
- D4 = action button B.
- D9 = mode-A LED.
- D10 = mode-B LED.
- D11 = mode-C LED.
Behaviour:
- Mode OFF: all three LEDs off. Action buttons do nothing.
- Mode A: D9 on. Action A toggles D9 brightness via PWM. Action B does nothing.
- Mode B: D10 on. Action A increments a counter (Serial logs it). Action B resets the counter.
- Mode C: D11 on (blinking at 250 ms). Both actions print messages to Serial.
It's done when:
- The mode button cycles cleanly through all 4 modes with no skip or stuck states.
- Action buttons only fire their effects in the right mode.
- All button presses are debounced — no doubles ever.
- The D11 blinker in Mode C is steady, not jittery.
- Zero
delay()in the entire sketch.
Recap 5 min
The millis() debouncer replaces L1's blocking delay(50) with a non-blocking "wait until the signal has been steady for 50 ms" check. The pattern lives in a small struct (DebouncedButton) that's reusable across any number of buttons. Each button exposes a justPressed flag that's true for exactly one loop iteration per real press — the cleanest possible API for event-driven code. Combined with the Cluster F discipline (no delays, tick functions), your buttons now feel like real product buttons: instant, single-fire, never missed.
- Switch bounce
- The brief flutter (1–30 ms typically) of contacts in a mechanical switch as they make or break. Without filtering it produces multiple HIGH/LOW transitions per physical press.
- Debouncing
- Any technique for filtering out bounce so each physical press registers as one logical event. Software with millis() is the Arduino-idiomatic way; capacitor + Schmitt-trigger is the hardware way.
- Debounce window
- The duration the signal must stay steady before we trust it. 50 ms is standard for tactile buttons; 20 ms feels snappier for gaming; 100 ms is safer for grimy old switches.
- Raw vs stable state
- Two variables:
rawState= whatdigitalReadsays right now (bouncy);stableState= our debounced answer (trustworthy). The debouncer's job is keeping them in sync only after stability. struct- A C++ keyword that bundles related variables (and optionally methods) into one type. Used here to group per-button state. We'll meet classes formally in L3-41.
- One-shot flag
- A boolean that becomes true for exactly one loop iteration when an event occurs, then resets.
justPressedis the classic example. - Long press
- A second gesture from the same button: hold for ≥ N ms = different action. Common in single-button consumer devices.
- Phantom press at boot
- A spurious press detected in the first 50 ms after power-on, caused by initialising
stableStateto a value different from the pin's actual state. Fix: initialise fromdigitalReadinbegin().
Extra Mission 5 min
Part 1 — Design a one-button gadget
Pick an everyday object that could be run by a single button. Ideas: a torch, a kitchen timer, a music box, a bike bell. On paper, design what each kind of press does.
Your design must include:
- A gesture table: short press, long press (1 second or more) and release.
- The output for each gesture — an LED, a buzzer tone or a Serial message.
- The debounce time you will use, in ms.
- How you stop two gestures clashing. A long press also starts with a press!
Part 2 — Make it
Build it with one button and today's DebouncedButton struct. Add the justReleased and long-press flags from Challenges 1 and 2 if your design needs them. Use zero delay() calls.
Bring back next class: your gesture table, your hw-l02-37.ino sketch, and a Serial Monitor screenshot showing each gesture detected exactly once.