STEM quiz: three answers and RGB feedback

Build an OLED quiz with five STEM questions, A/B/C buttons and RGB feedback for correct and incorrect answers. Store questions in an array and handle held buttons, multiple presses and a new round.

Integrated projects and user controlsIntermediate120 min

What you will learn

  • Connect three buttons and an RGB LED with a separate resistor for each channel.
  • Match the circuit and output polarity to a common cathode or a 3.3 V common anode.
  • Store a prompt, three answer options and a correct index in an array of records.
  • Require released buttons and reject an observed multiple-button selection.
  • Display the score and start another round without restarting the board.

Before you start

Courses 06, 11 and 18: RGB channels and polarity, an I2C OLED, debouncing and interactive game states.

Reference setup

Board in Arduino IDE
ESP32 Dev Module
Arduino-ESP32
3.3.12
Serial Monitor
115200 baud

This is a reference profile, not an identification of the pictured board. Adapt the GPIO mapping and verify the circuit before using ESP32-C3/S2/S3 or a differently labelled board.

What we will build

We will build a standalone STEM quiz with an OLED, three buttons and an RGB LED. The player selects answer A, B or C. Blue means waiting, green marks a correct answer and red marks an incorrect answer. After five questions, the display shows the score. Button A starts another round without restarting the ESP32.

The question content is separate from the game rules. This lets the same program support short quizzes about other subjects later. We will pay particular attention to held buttons, bouncing contacts and situations where the program observes more than one button pressed.

What you will learn

  • Store question text, three options and the correct answer index in an array of records.
  • Connect three buttons and three individually limited RGB branches.
  • Use states to manage questions, feedback and the final score.
  • Require released buttons before accepting the next answer.
  • Explain multiple-button handling and the alternative RGB polarity.

Prerequisites

Complete courses 06, 11 and 18: RGB channels, an OLED on I2C and an event-driven game. You should understand INPUT_PULLUP, contact debouncing and elapsed-time comparisons using millis(). We use the classic ESP32/WROOM32; GPIO numbers do not describe a universal physical pin layout for every ESP32 board.

Required equipment

ComponentQuantitySpecification and notes
Development board with a classic ESP32-WROOM-32 module1Match the reference GPIO labels; check physical header positions on the actual board.
USB data and power cable1Use the connector fitted to your board; the pictured kit lists Micro-USB.
Solderless breadboard1The kit lists 830 tie points. Check whether the power rails are split.
0.96-inch OLED — reference I2C SSD1306 128×641VCC/GND/SDA/SCL, 3.3 V supply, onboard pull-ups to 3V3. The image does not confirm the controller, interface or resolution.
Momentary pushbutton3Use two terminals connected only when pressed; two legs on the same side may already be joined.
Four-lead RGB LED1R, G, B and COM. Separate common-cathode and common-anode circuits are supplied; physical lead order is not assumed.
220 Ω resistor3One current-limiting resistor per LED branch; use the kit’s 220 Ω resistors.
Jumper wires18Use male-to-male or female-to-male leads to suit the board headers.

Approximate quantity; depends on layout and connector types.

Use a four-lead RGB LED without an addressable controller and three separate 220 Ω resistors. The default version is common cathode. The package also includes a dedicated common-anode diagram; choose the one that matches your actual LED. Identify R, G, B and COM from the documentation for the specific part.

The reference display is a 128 × 64 I2C SSD1306 module powered from 3.3 V. A photograph alone does not establish its controller, address or electrical compatibility. Label the physical buttons A, B and C before starting so their positions match the answer options.

Quiz rules and electrical levels

Each button connects its input to GND. The internal pull-up produces HIGH when released and pressing the button produces LOW. The program accepts a new level after 30 ms of stability. Holding a button does not mean submitting repeated answers.

For common cathode, COM connects to GND and HIGH turns on the selected colour. For common anode, COM connects to 3V3 and LOW turns on the selected colour. COMMON_ANODE reverses the electrical outputs while preserving the meaning of the colours. Never connect COM to 5 V in either version. PWM is unnecessary because the quiz uses fully on or off colour channels.

Before each question, all three buttons must be released and confirmed at a stable high level. If the program observes multiple pressed buttons, it does not arbitrarily prefer A: it requests release again. A second press that happens after an answer has already been accepted cannot retroactively invalidate that decision.

Wiring and connection order

RGB — common cathode

Default circuit

COM connects to GND; COMMON_ANODE false. Each R/G/B channel has its own 220 Ω resistor. Identify actual physical lead order.

Required sketch setting: #define COMMON_ANODE false

RGB — common cathode — electrical connections listed in the table below
RGB — common cathodeEnlargeSVGPNG
Connections · RGB — common cathode
FromToConnection
ESP32.GPIO25R1.1Digital control: red channel.
R1.2RGB1.RR1, 220 Ω, the individual series channel resistor.
ESP32.GPIO26R2.1Digital control: green channel.
R2.2RGB1.GR2, 220 Ω, the individual series channel resistor.
ESP32.GPIO33R3.1Digital control: blue channel.
R3.2RGB1.BR3, 220 Ω, the individual series channel resistor.
RGB1.COMESP32.GNDCommon cathode to GND; keep COMMON_ANODE false.
ESP32.3V3OLED1.VCCReference 128×64 I2C SSD1306, powered at 3.3 V.
ESP32.GNDOLED1.GNDCommon ground.
ESP32.GPIO21OLED1.SDAI2C data.
ESP32.GPIO22OLED1.SCLI2C clock, 100 kHz reference.
ESP32.GPIO27SW1.CONTACT_AA button; INPUT_PULLUP input.
SW1.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
ESP32.GPIO32SW2.CONTACT_AB button; INPUT_PULLUP input.
SW2.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
ESP32.GPIO14SW3.CONTACT_AC button; INPUT_PULLUP input.
SW3.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.

RGB — common anode

Alternative circuit

COM connects to 3V3; COMMON_ANODE true. Each R/G/B channel has its own 220 Ω resistor. Identify actual physical lead order.

Required sketch setting: #define COMMON_ANODE true

RGB — common anode — electrical connections listed in the table below
RGB — common anodeEnlargeSVGPNG
Connections · RGB — common anode
FromToConnection
ESP32.GPIO25R1.1Digital control: red channel.
R1.2RGB1.RR1, 220 Ω, the individual series channel resistor.
ESP32.GPIO26R2.1Digital control: green channel.
R2.2RGB1.GR2, 220 Ω, the individual series channel resistor.
ESP32.GPIO33R3.1Digital control: blue channel.
R3.2RGB1.BR3, 220 Ω, the individual series channel resistor.
RGB1.COMESP32.3V3Common anode to 3V3; set COMMON_ANODE true.
ESP32.3V3OLED1.VCCReference 128×64 I2C SSD1306, powered at 3.3 V.
ESP32.GNDOLED1.GNDCommon ground.
ESP32.GPIO21OLED1.SDAI2C data.
ESP32.GPIO22OLED1.SCLI2C clock, 100 kHz reference.
ESP32.GPIO27SW1.CONTACT_AA button; INPUT_PULLUP input.
SW1.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
ESP32.GPIO32SW2.CONTACT_AB button; INPUT_PULLUP input.
SW2.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
ESP32.GPIO14SW3.CONTACT_AC button; INPUT_PULLUP input.
SW3.CONTACT_BESP32.GNDThe contact pair that connects only while pressed.
  1. Disconnect USB and verify the RGB type, its lead functions and the OLED labels.
  2. Connect OLED GND to ESP32 GND, VCC to 3V3, SDA to GPIO21 and SCL to GPIO22.
  3. Connect GPIO25 through R1, 220 Ω, to RGB R; GPIO26 through R2, 220 Ω, to G; and GPIO33 through R3, 220 Ω, to B.
  4. For common cathode, connect COM to GND and leave #define COMMON_ANODE false. For common anode, use the second diagram, connect COM to 3V3 and set #define COMMON_ANODE true.
  5. Connect SW1, button A, between GPIO27 and GND; SW2, button B, between GPIO32 and GND; and SW3, button C, between GPIO14 and GND.
  6. On a four-leg button, choose terminals that connect only when pressed. Check continuity and breadboard rows; some terminals are already joined internally.
  7. Inspect all three RGB branches, the common ground and the selected variant before connecting USB.

The diagram gives electrical functions rather than a universal physical lead order. A single resistor on COM does not replace the three specified branch resistors. GPIO34 appears in one question but is not wired in this project; the question refers to the analogue input used in earlier lessons.

Preparing the Arduino environment

In Arduino IDE 2.x, select esp32 by Espressif Systems 3.3.12, ESP32 Dev Module and the correct port. Install Adafruit SSD1306 2.5.17, Adafruit GFX Library 1.12.6 and Adafruit BusIO 1.17.4. Wire is included with the ESP32 core.

Open stem_quiz/stem_quiz.ino. Check the RGB setting and OLED address: the default is 0x3C; select the documented alternative 0x3D only if it matches your module. The program probes the address before initialising the display. I2C uses GPIO21/22 at 100 kHz. Set Serial Monitor to 115200 baud.

Choose Verify, then Upload. OLED messages use ASCII English in both course languages because this example uses the built-in font. “Release all buttons” asks for every button to be released, and “Score” counts correct answers. If the initial OLED checks fail, the RGB stays off and the quiz does not accept invisible answers. An I2C address response alone does not establish that the module has an SSD1306 controller.

Library Manager — documented versions and dependencies
Library and releaseVersionDependencies
Adafruit SSD13062.5.17Adafruit GFX Library 1.12.6
Adafruit GFX Library1.12.6Adafruit BusIO 1.17.4
Adafruit BusIO1.17.4None

Wire is included in the ESP32 core; do not install it separately through Library Manager.

Complete Arduino program

stem_quiz.ino Arduino / C++
Download .ino

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#ifndef COMMON_ANODE
#define COMMON_ANODE false  // true ONLY with the common-anode wiring variant.
#endif
constexpr uint8_t BUTTON_A_PIN = 27, BUTTON_B_PIN = 32, BUTTON_C_PIN = 14;
constexpr uint8_t RED_PIN = 25, GREEN_PIN = 26, BLUE_PIN = 33;
constexpr uint8_t SDA_PIN = 21, SCL_PIN = 22;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint32_t DEBOUNCE_MS = 30;
constexpr uint32_t FEEDBACK_MS = 1200;
// A fresh event needs 30 ms at one level. A held or inhibited press must
// release stably before another event can be accepted.
struct DebouncedButton {
  uint8_t pin;
  bool rawHigh = true, stableHigh = true, armed = true;
  bool pressed = false, released = false;
  uint32_t rawChangedAt = 0;
  explicit DebouncedButton(uint8_t inputPin) : pin(inputPin) {}
  void begin(uint32_t now) {
    pinMode(pin, INPUT_PULLUP);
    rawHigh = stableHigh = digitalRead(pin) == HIGH;
    armed = rawHigh;
    rawChangedAt = now;
    pressed = released = false;
  }
  void update(uint32_t now) {
    pressed = released = false;
    const bool readingHigh = digitalRead(pin) == HIGH;
    if (readingHigh != rawHigh) {
      rawHigh = readingHigh;
      rawChangedAt = now;
    }
    if (static_cast<uint32_t>(now - rawChangedAt) < DEBOUNCE_MS) return;
    if (rawHigh != stableHigh) {
      stableHigh = rawHigh;
      if (stableHigh) released = true;
      else if (armed) {
        pressed = true;
        armed = false;
      }
    }
    // Also recovers from an inhibited LOW pulse too short to become stable.
    if (rawHigh && stableHigh) armed = true;
  }
  void inhibit() { armed = false; pressed = false; }
  bool isReleased(uint32_t now) const {
    return rawHigh && stableHigh &&
           static_cast<uint32_t>(now - rawChangedAt) >= DEBOUNCE_MS;
  }
};
Adafruit_SSD1306 display(128, 64, &Wire, -1, 100000UL, 100000UL);
bool ready = false;

bool initializeDisplay() {
  if (!Wire.begin(SDA_PIN, SCL_PIN)) {
    Serial.println("OLED ERROR: I2C initialization failed; reset to retry.");
    return false;
  }
  Wire.setClock(100000);
  Wire.setTimeOut(50);
  Wire.beginTransmission(OLED_ADDRESS);
  if (Wire.endTransmission() != 0) {
    Serial.println("OLED ERROR: address did not ACK; check wiring/address and reset.");
    return false;
  }
  Serial.print("OLED address ACK: 0x"); Serial.println(OLED_ADDRESS, HEX);
  Serial.println("An ACK does not identify the display controller.");
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false)) {
    Serial.println("OLED ERROR: buffer initialization failed; reset to retry.");
    return false;
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setTextWrap(false);
  return true;
}

DebouncedButton buttonA(BUTTON_A_PIN), buttonB(BUTTON_B_PIN), buttonC(BUTTON_C_PIN);
struct Question {
  const char* prompt;
  const char* options[3];
  uint8_t correctIndex;
};
const Question Questions[] = {
  {"GPIO logic level?", {"3.3 V", "5 V", "12 V"}, 0},
  {"Button to GND is?", {"HIGH", "LOW", "PWM"}, 1},
  {"ADC input here?", {"GPIO34", "3V3 pin", "GND pin"}, 0},
  {"OLED data bus?", {"UART", "SPI", "I2C"}, 2},
  {"PIR detects?", {"Air temp", "Motion", "Humidity"}, 1}
};
constexpr size_t QUESTION_COUNT = sizeof(Questions) / sizeof(Questions[0]);
enum QuizState { WAIT_RELEASE, QUESTION, FEEDBACK, FINISHED };
QuizState state = WAIT_RELEASE;
size_t questionIndex = 0, score = 0;
uint32_t stateSince = 0;
bool answerCorrect = false, restartReady = false, screenDirty = true;
void enterState(QuizState next, uint32_t now);

void setRgb(bool red, bool green, bool blue) {
  digitalWrite(RED_PIN, (red != COMMON_ANODE) ? HIGH : LOW);
  digitalWrite(GREEN_PIN, (green != COMMON_ANODE) ? HIGH : LOW);
  digitalWrite(BLUE_PIN, (blue != COMMON_ANODE) ? HIGH : LOW);
}

void enterState(QuizState next, uint32_t now) {
  state = next;
  stateSince = now;
  screenDirty = true;
  if (state == WAIT_RELEASE || state == QUESTION) setRgb(false, false, true);
  else if (state == FEEDBACK) setRgb(!answerCorrect, answerCorrect, false);
  else setRgb(false, false, false);
}

bool allReleased(uint32_t now) {
  return buttonA.isReleased(now) && buttonB.isReleased(now) && buttonC.isReleased(now);
}

void drawQuiz() {
  if (!screenDirty) return;
  display.clearDisplay();
  if (state == FINISHED) {
    display.setCursor(0, 0); display.print("QUIZ FINISHED");
    display.setCursor(0, 16); display.print("Score: "); display.print(score);
    display.print(" / "); display.print(QUESTION_COUNT);
    display.setCursor(0, 38); display.print(restartReady ? "A: restart" : "Release all buttons");
  } else if (state == WAIT_RELEASE) {
    display.setCursor(0, 0); display.print("Question "); display.print(questionIndex + 1);
    display.print(" / "); display.print(QUESTION_COUNT);
    display.setCursor(0, 22); display.print("Release all buttons");
    display.setCursor(0, 38); display.print("Choose one: A, B or C");
  } else if (state == FEEDBACK) {
    display.setCursor(0, 0); display.print(answerCorrect ? "CORRECT" : "NOT CORRECT");
    display.setCursor(0, 18); display.print("Answer: ");
    display.print(static_cast<char>('A' + Questions[questionIndex].correctIndex));
    display.setCursor(0, 34); display.print("Score: "); display.print(score);
    display.print(" / "); display.print(QUESTION_COUNT);
  } else {
    const Question& question = Questions[questionIndex];
    display.setCursor(0, 0); display.print("Q"); display.print(questionIndex + 1);
    display.print("/"); display.print(QUESTION_COUNT); display.print("  Score "); display.print(score);
    display.setCursor(0, 12); display.print(question.prompt);
    for (uint8_t i = 0; i < 3; ++i) {
      display.setCursor(0, 24 + i * 10);
      display.print(static_cast<char>('A' + i)); display.print(": "); display.print(question.options[i]);
    }
    display.setCursor(0, 56); display.print("Press one button");
  }
  display.display();  // Redraw only on a state/value change; short taps may be missed.
  screenDirty = false;
}

void setup() {
  Serial.begin(115200);
  pinMode(RED_PIN, OUTPUT); pinMode(GREEN_PIN, OUTPUT); pinMode(BLUE_PIN, OUTPUT);
  setRgb(false, false, false);
  const uint32_t now = millis();
  buttonA.begin(now); buttonB.begin(now); buttonC.begin(now);
  ready = initializeDisplay();
  if (!ready) return;
  enterState(WAIT_RELEASE, millis());
  drawQuiz();
}

void loop() {
  if (!ready) return;
  const uint32_t now = millis();
  buttonA.update(now); buttonB.update(now); buttonC.update(now);
  if (state == WAIT_RELEASE) {
    if (allReleased(now)) enterState(QUESTION, now);
  } else if (state == QUESTION) {
    const uint8_t rawPressed = static_cast<uint8_t>(!buttonA.rawHigh) +
                               static_cast<uint8_t>(!buttonB.rawHigh) +
                               static_cast<uint8_t>(!buttonC.rawHigh);
    if (rawPressed > 1) {
      // An observed chord is rejected, without any A/B/C priority.
      enterState(WAIT_RELEASE, now);
    } else {
      int8_t answer = -1;
      if (buttonA.pressed && buttonB.rawHigh && buttonC.rawHigh) answer = 0;
      else if (buttonB.pressed && buttonA.rawHigh && buttonC.rawHigh) answer = 1;
      else if (buttonC.pressed && buttonA.rawHigh && buttonB.rawHigh) answer = 2;
      if (answer >= 0) {
        answerCorrect = static_cast<uint8_t>(answer) == Questions[questionIndex].correctIndex;
        if (answerCorrect) ++score;
        enterState(FEEDBACK, now);
      }
    }
  } else if (state == FEEDBACK) {
    if (static_cast<uint32_t>(now - stateSince) >= FEEDBACK_MS) {
      if (questionIndex + 1 < QUESTION_COUNT) {
        ++questionIndex;
        enterState(WAIT_RELEASE, now);
      } else {
        restartReady = false;
        enterState(FINISHED, now);
      }
    }
  } else {
    // B/C do not restart, and any observed chord must release completely.
    if ((!buttonB.rawHigh || !buttonC.rawHigh) && restartReady) {
      restartReady = false;
      screenDirty = true;
    }
    if (!restartReady && allReleased(now)) {
      restartReady = true;
      screenDirty = true;
    }
    if (restartReady && buttonA.pressed && buttonB.rawHigh && buttonC.rawHigh) {
      score = questionIndex = 0;
      restartReady = false;
      enterState(WAIT_RELEASE, now);
    }
  }
  drawQuiz();
}

Keep the displayed program and downloadable .ino in agreement. Try the five supplied questions before changing their contents.

How the program works and what the questions assess

The Questions[] array holds the prompt, three options and correctIndex: A is index 0, B is index 1 and C is index 2. QUESTION_COUNT is calculated as sizeof(Questions) / sizeof(Questions[0]), so adding a record does not require manually replacing the number five in several places.

OLED questionA / B / CCorrect answer and explanation
GPIO logic level?3.3 V / 5 V / 12 VA. This ESP32 circuit uses 3.3 V GPIO logic. The 5 V USB supply is not an acceptable GPIO input level.
Button to GND is?HIGH / LOW / PWMB. Pressing the button connects the input to GND. The pull-up establishes the released state.
ADC input here?GPIO34 / 3V3 pin / GND pinA. GPIO34 is the analogue input used in previous examples; 3V3 and GND are supply connections.
OLED data bus?UART / SPI / I2CC. Our reference OLED exchanges data on SDA/SCL. Other OLED products can use different interfaces.
PIR detects?Air temp / Motion / HumidityB. In these lessons, the PIR reports movement through changing infrared radiation; it does not measure air temperature or humidity.

WAIT_RELEASE establishes readiness for another answer. QUESTION permits one newly qualified press only while the other raw inputs are high. FEEDBACK displays green or red for 1200 ms; the score increases once when a correct answer is accepted. The program then advances to another question or FINISHED. The final screen retains the score until a fresh A press is accepted after all buttons have been released. B and C do not restart the quiz.

The feedback timer compares elapsed time without delay(). Reaching the end of that interval does not turn a held button into an answer to the following question. drawQuiz() refreshes only changed content, but display.display() transfers the frame synchronously. Sending 1024 bytes at 100 kHz has a calculated payload time of about 92 ms, plus protocol overhead. Very short taps during a transfer may be missed, so use ordinary deliberate presses. Feedback timing uses elapsed time, not a count of displayed frames.

Experiment and expected results

ActionExpected behaviour
Release between questions, then choose A, B, A, C, B.Five correct answers, green feedback each time and a final score of 5/5.
Choose C for the first question.Red for 1200 ms; the score remains zero.
Hold A through the end of feedback.The next question waits for release; the score does not increase by itself.
Press two buttons so the program observes both down.No answer and no penalty point; release everything before making another selection.
Reset while holding B.The quiz requires release before accepting the first answer.
At the end, release everything and then press A.A new round begins with a score of zero.

Record your own observations, especially the order of releases and presses. This table lists expected behaviour; it does not claim that measurements or physical checks have already been performed on your circuit.

Troubleshooting

SymptomCheck and corrective action
Blank display and RGB off.Check VCC/GND, SDA/SCL, address and SSD1306 controller type; read the Serial Monitor error.
A correct answer produces red or blue.Check the actual R/G/B lead identification and the three branch connections.
Colours stay on when they should be off.Match the COM connection with COMMON_ANODE; changing the software alone is insufficient.
The quiz keeps requesting release.Look for an input permanently connected to GND, the wrong button terminal pair or a short.
The next question ignores a held button.This is intentional: release all three, allow confirmation, then make a fresh press.

Independent challenges

Add a sixth question: “Common cathode to?” with “GND”, “5 V” and “GPIO only” as the options. Check that the screen fits and that the final score uses six as its total. Then change feedback duration from 1200 to 2000 ms without introducing delay().

Worked challenge solutions

Add a comma after the current last record in Questions[], then append:

{"Common cathode to?", {"GND", "5 V", "GPIO only"}, 0}

The correct sequence becomes A, B, A, C, B, A, producing 6/6. Do not use index 1 for A: indexing begins at zero. Keep the existing calculation of the number of array elements.

Change only FEEDBACK_MS to 2000. The condition still compares elapsed time against the interval. A longer display does not change the one-point rule or the release requirement. Also try an incorrect sixth answer: the total must remain six, while the score should be five when all other answers are correct.

Knowledge check with answers

Why inspect the other buttons' raw levels as well? Another button may already be down without completing its 30 ms qualification. That observed multiple selection should not be treated as an unambiguous answer.

Why require release after every question? One physical action belongs to one question. A held button or a press made during feedback must not carry over to the next question.

Does a reset preserve the score? No. The score exists in working memory and starts again at zero. Does a wrong answer subtract a point? No; it simply adds no point.

Primary sources

Downloads

Choose the diagram matching your actual RGB LED. A real ESP32 build, execution of the actual libraries and a physical component test are separate verification steps; this lesson does not present them as already completed.