An Arduino stoplight is the quintessential embedded systems rite of passage. But while most tutorials stop at a blocking delay() loop that blindly cycles colors, a real-world traffic controller must be responsive. It needs to read pedestrian crossing buttons, handle emergency vehicle overrides, and manage timing without freezing the microcontroller. To do this, you must abandon blocking delays and build a non-blocking state machine.

This guide gives you the exact component values, a robust non-blocking C++ state machine, and the bench-tested debugging steps to get your intersection running on the first try.

Decision Tree: Which Stoplight Architecture Should You Build?

Before grabbing a handful of LEDs, decide on your hardware architecture. Your choice dictates your wiring complexity and how many GPIO pins you consume.

Architecture Pros Cons Best For
KY-011 Traffic Light Module Zero wiring thought; built-in resistors; 3 pins total. Resistors are often poorly matched (dim green, blinding red); fixed 5mm size. Quick classroom demos where hardware isn't the focus.
Discrete 5mm LEDs + Resistors Exact current control; highly visible; teaches proper GPIO current sinking. Requires breadboarding and calculating voltage drops. Permanent installs, custom enclosures, and learning real electronics.
74HC595 Shift Register Controls 8+ lights using only 3 Arduino pins. Overkill for 3 LEDs; adds software complexity for bit-shifting. Multi-intersection grids or when GPIO pins are severely limited.
The Concrete Pick: For 95% of hobbyists and students, Discrete 5mm LEDs with 220Ω resistors on an Arduino Uno R4 Minima is the default choice. It provides the best balance of visibility, electrical safety for the GPIO pins, and foundational learning. The rest of this guide assumes this architecture.

Parts List & Pin Mapping for the Discrete GPIO Build

We are targeting the Arduino Uno R4 Minima (ABX00080). The R4 Minima uses a Renesas RA4M1 32-bit ARM Cortex-M4, meaning it operates at 5V logic but has vastly more memory and processing headroom than the legacy ATmega328P, making it ideal for complex state machines.

Bill of Materials (BOM)

  • Microcontroller: Arduino Uno R4 Minima (ABX00080)
  • LEDs: 5mm Diffused (Red: 2.0Vf, Yellow: 2.1Vf, Green: 2.2Vf @ 20mA)
  • Current Limiting Resistors: 3x 220Ω (1/4W, 5% tolerance). Note: 220Ω yields ~13-14mA, which is bright enough for indoor/desk use and safely under the R4's 20mA per-pin absolute maximum.
  • Pedestrian Button: 12mm tactile switch (SPST-NO)
  • Wiring: 22 AWG solid core jumper wires

Pin Mapping Table

Component Arduino R4 Pin Wiring Notes
Red LED Anode (+) D8 220Ω resistor in series between pin and anode.
Yellow LED Anode (+) D9 220Ω resistor in series between pin and anode.
Green LED Anode (+) D10 220Ω resistor in series between pin and anode.
All LED Cathodes (-) GND Tied to a common ground bus on the breadboard.
Pedestrian Button D2 One side to D2, other side to GND. Uses internal pull-up.

Step-by-Step Wiring & Non-Blocking Code

  1. Seat the LEDs: Place the three 5mm LEDs on the breadboard. Identify the cathode (short leg, flat side of the bulb) and connect all three to the negative ground rail.
  2. Install Resistors: Insert a 220Ω resistor for each LED. Connect one leg to the LED anode (long leg) and the other to the respective Arduino digital pin (8, 9, 10).
  3. Wire the Button: Place the tactile switch across the breadboard's center trench. Wire one diagonal pin to Arduino D2, and the opposite diagonal pin to the ground rail.
  4. Establish Common Ground: Run a jumper wire from the Arduino GND pin to the breadboard's negative ground rail. Missing this step is the #1 cause of floating voltages and dim, ghosting LEDs.

The Non-Blocking State Machine Code

Using delay() halts the CPU. If a pedestrian presses the crosswalk button during a 5-second red light delay(), the Arduino won't register it until the delay finishes. Instead, we use millis() to track time while continuously polling the button. For a deeper understanding of non-blocking timing, refer to the official Arduino millis() documentation.

// Target Board: Arduino Uno R4 Minima
// Project: Non-Blocking Stoplight with Pedestrian Override

#define RED_PIN 8
#define YELLOW_PIN 9
#define GREEN_PIN 10
#define BUTTON_PIN 2

// Timing constants (in milliseconds)
const unsigned long RED_DURATION = 5000;
const unsigned long YELLOW_DURATION = 2000;
const unsigned long GREEN_DURATION = 5000;
const unsigned long CROSSWALK_DURATION = 3000; // Forced red for pedestrians

enum LightState { STATE_GREEN, STATE_YELLOW, STATE_RED, STATE_CROSSWALK };
LightState currentState = STATE_GREEN;

unsigned long previousMillis = 0;
unsigned long currentInterval = GREEN_DURATION;
bool crosswalkRequested = false;

void setup() {
  pinMode(RED_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(GREEN_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP); // Enables internal 20k-50k pull-up resistor
  
  // Initialize to Green
  digitalWrite(GREEN_PIN, HIGH);
  digitalWrite(YELLOW_PIN, LOW);
  digitalWrite(RED_PIN, LOW);
  
  Serial.begin(115200);
  Serial.println("Stoplight Controller Initialized.");
}

void loop() {
  unsigned long currentMillis = millis();
  
  // 1. Poll Pedestrian Button (Active LOW due to INPUT_PULLUP)
  if (digitalRead(BUTTON_PIN) == LOW) {
    crosswalkRequested = true;
  }

  // 2. State Machine Timing (Non-Blocking)
  if (currentMillis - previousMillis >= currentInterval) {
    previousMillis = currentMillis;
    
    switch (currentState) {
      case STATE_GREEN:
        if (crosswalkRequested) {
          transitionTo(STATE_YELLOW, YELLOW_DURATION);
        } else {
          // Stay green, reset timer
          currentInterval = GREEN_DURATION; 
        }
        break;
        
      case STATE_YELLOW:
        transitionTo(STATE_RED, RED_DURATION);
        break;
        
      case STATE_RED:
        if (crosswalkRequested) {
          transitionTo(STATE_CROSSWALK, CROSSWALK_DURATION);
        } else {
          transitionTo(STATE_GREEN, GREEN_DURATION);
        }
        break;
        
      case STATE_CROSSWALK:
        crosswalkRequested = false; // Clear the request
        transitionTo(STATE_GREEN, GREEN_DURATION);
        break;
    }
  }
}

void transitionTo(LightState newState, unsigned long duration) {
  // Turn off all lights first to prevent short overlaps
  digitalWrite(RED_PIN, LOW);
  digitalWrite(YELLOW_PIN, LOW);
  digitalWrite(GREEN_PIN, LOW);
  
  switch (newState) {
    case STATE_GREEN:
      digitalWrite(GREEN_PIN, HIGH);
      Serial.println("State: GREEN");
      break;
    case STATE_YELLOW:
      digitalWrite(YELLOW_PIN, HIGH);
      Serial.println("State: YELLOW");
      break;
    case STATE_RED:
      digitalWrite(RED_PIN, HIGH);
      Serial.println("State: RED");
      break;
    case STATE_CROSSWALK:
      digitalWrite(RED_PIN, HIGH);
      Serial.println("State: CROSSWALK ACTIVE");
      break;
  }
  
  currentState = newState;
  currentInterval = duration;
}

Debugging: First Three Checks & Exact Error Strings

When your stoplight fails, don't start rewriting code. Hardware and configuration errors account for 90% of embedded failures. Run this diagnostic sequence.

The First Three Hardware Checks

  1. Verify Common Ground Continuity: Set your multimeter to continuity/resistance mode. Place one probe on the Arduino GND pin and the other on the LED cathode ground rail. You must read < 1 ohm. If it reads open (OL), your LEDs are floating, which causes ghosting, dim illumination, or erratic logic levels.
  2. Check Resistor Presence and Value: Pull the USB cable. Measure across each resistor in-circuit. You should see roughly 220Ω. If you forgot the resistor, the LED will draw excessive current, potentially triggering the R4 Minima's internal thermal shutdown or permanently damaging the GPIO pin.
  3. Confirm Button Pull-Up Configuration: If the pedestrian button triggers randomly without being pressed, your pin is floating. Ensure your code explicitly uses INPUT_PULLUP and that the button is wired between the pin and GND, not VCC. For more on LED electrical characteristics and forward voltage, see the Adafruit LED Guide.

Exact IDE Error Strings & Ranked Causes

Error String: error: expected unqualified-id before '{' token
  • Cause 1 (Most Likely): Missing semicolon at the end of a #define statement or a class declaration right before a curly brace block.
  • Cause 2: Accidentally placing a semicolon immediately after an if condition (e.g., if (x == true); {), which breaks the block scope.
Error String: avrdude: stk500_getsync() attempt 1 of 10: not in sync: resp=0x00
  • Cause 1 (Most Likely): The IDE is trying to upload to the wrong COM port. Go to Tools > Port and select the port that explicitly says "Arduino Uno R4 Minima".
  • Cause 2: A wiring short on Pin 0 (RX) or Pin 1 (TX). The R4 uses a native USB architecture, but if you are using a legacy Uno R3, components wired to D0/D1 will block the bootloader handshake. Move them to D2-D12.
  • Cause 3: The board is in a hung state. Double-tap the physical reset button on the R4 Minima rapidly to force it into bootloader mode, then click Upload again.

Extending or Simplifying Your Traffic Controller

Once your baseline intersection is stable, you will inevitably want to change the scope. Here is how to adapt the build based on your end goal.

How to Simplify (The "Toy" Route)

If you are building a simple diorama for a child and do not care about button responsiveness, strip out the millis() logic and the enum state machine. Replace the entire loop() with a sequential blocking script:

digitalWrite(GREEN_PIN, HIGH); delay(5000);
digitalWrite(GREEN_PIN, LOW); digitalWrite(YELLOW_PIN, HIGH); delay(2000);
digitalWrite(YELLOW_PIN, LOW); digitalWrite(RED_PIN, HIGH); delay(5000);
digitalWrite(RED_PIN, LOW);

Warning: This renders the pedestrian button useless unless you implement hardware interrupt routines, which defeats the purpose of simplifying.

How to Extend (The "Smart City" Route)

  • Add a 4-Way Intersection: Duplicate the LED outputs to Pins 4, 5, and 6 for the cross-street. Modify the state machine so that when the main street is STATE_GREEN, the cross-street is forced into STATE_RED. You must add a 1-second "all-red" clearance interval between state changes to prevent cross-traffic collisions.
  • Integrate Night-Mode Flashing: Add an I2C Real Time Clock (RTC) module like the DS3231. If the RTC reads between 01:00 and 05:00, bypass the standard state machine and enter a STATE_NIGHT_FLASH where the main road blinks yellow and the cross-street blinks red.
  • Upgrade to High-Power Loads: If you want to drive actual 12V automotive LED clusters instead of 5mm breadboard LEDs, do not wire them directly to the Arduino. Use a TIP120 Darlington transistor or an IRLZ44N Logic-Level MOSFET per channel, driven by the Arduino GPIO, with a 12V external power supply and a flyback diode across the load.
Final Recommendation: Build the discrete 5mm non-blocking version first. Master the millis() state machine logic, as it is the exact same architectural pattern used in industrial PLCs and commercial IoT traffic controllers. Only move to shift registers or MOSFET drivers once your software logic is proven stable on the bench.