Project Verdict & Parts Decision Tree
For a standard 4-way intersection, the default pick is the Arduino Uno R3 (ATmega328P) paired with 12 discrete 5mm LEDs and 220Ω resistors. While shift registers or LED matrices can reduce pin count, discrete wiring on a half-size breadboard offers the best balance of physical visibility, debugging ease, and educational value for traffic state machines.
Use the decision path below to confirm this is the right architecture for your specific use case:
| If your project requires... | Then choose this architecture... | Concrete Part Pick |
|---|---|---|
| A simple 1-way road sequence (3 lights) | Minimalist microcontroller | Arduino Nano (ATmega328P) + 3 LEDs |
| A 4-way intersection with pedestrian buttons | Standard I/O-heavy board | Arduino Uno R3 + 12 LEDs + 2 Pushbuttons |
| Networked smart traffic control (MQTT/Cloud) | WiFi-enabled SoC | ESP32 DevKit V1 + 12 LEDs + Relay Module |
| Scaling to 8+ intersections on one desk | Pin multiplexing | Arduino Mega 2560 + MAX7219 LED drivers |
Default Recommendation: Proceed with the Arduino Uno R3 4-way build detailed below. It uses all 12 available digital I/O pins (D2-D13), leaving the analog pins free for future sensor or button integration.
Hardware Spec Sheet & Pin Mapping
Before cutting wires, verify your components against this spec sheet. Using the wrong resistor wattage or LED forward voltage will result in dim lights or burnt-out GPIO pins.
| Component | Exact Variant / Spec | Qty | Est. Price (2026) |
|---|---|---|---|
| Microcontroller | Arduino Uno R3 (Rev3, ATmega328P, DIP) | 1 | $27.00 |
| Red LEDs | 5mm, 2.0Vf, 20mA, diffused | 4 | $1.00 |
| Yellow LEDs | 5mm, 2.1Vf, 20mA, diffused | 4 | $1.00 |
| Green LEDs | 5mm, 2.2Vf, 20mA, diffused | 4 | $1.00 |
| Current Limiting Resistors | 220Ω, 1/4W, 5% tolerance, carbon film | 12 | $0.50 |
| Prototyping | Half-size 400-tie-point breadboard + M-M jumpers | 1 | $8.00 |
Map your physical wires exactly to this table. North/South (NS) and East/West (EW) roads are paired to mimic real-world intersection logic.
| Traffic Direction | Light Color | Arduino Digital Pin | Resistor Required? |
|---|---|---|---|
| North | Red | D2 | Yes (220Ω) |
| North | Yellow | D3 | Yes (220Ω) |
| North | Green | D4 | Yes (220Ω) |
| South | Red | D8 | Yes (220Ω) |
| South | Yellow | D9 | Yes (220Ω) |
| South | Green | D10 | Yes (220Ω) |
| East | Red | D5 | Yes (220Ω) |
| East | Yellow | D6 | Yes (220Ω) |
| East | Green | D7 | Yes (220Ω) |
| West | Red | D11 | Yes (220Ω) |
| West | Yellow | D12 | Yes (220Ω) |
| West | Green | D13 | Yes (220Ω) |
Step-by-Step Wiring Procedure
- Place the Resistors: Insert one leg of a 220Ω resistor into the digital pin row for D2, and the other leg into an empty tie-point row on the breadboard.
- Insert the LEDs: Take a North Red LED. Insert the anode (long leg) into the same row as the resistor's empty leg. Insert the cathode (short leg, flat edge) into the breadboard's ground (blue/black) rail.
- Repeat for All 12 Lights: Follow the pin mapping table strictly. Keep the physical layout on the breadboard matching the geographical layout of your intersection (North LEDs at the top, South at the bottom) to prevent wiring spaghetti.
- Bus the Grounds: Use a jumper wire to connect the breadboard's ground rail to one of the Arduino's
GNDpins. If your breadboard has split ground rails (common in the middle of half-size boards), jumper them together. - Verify Before Powering: Do a continuity check with your multimeter in beep mode. Place one probe on the Arduino 5V pin and the other on the ground rail. It should read OL (Open Loop). If it beeps, you have a short circuit that will trip the Arduino's onboard polyfuse or damage your USB port.
Non-Blocking Intersection Code
This code targets the Arduino Uno R3 (AVR architecture). It uses a millis()-based state machine instead of delay(). This is critical: delay() blocks the CPU, meaning you cannot read pedestrian buttons or telemetry sensors while a light is red. The official Arduino millis() reference explains the underlying timer mechanics.
The code includes a verifyPins() function to catch basic configuration faults at boot.
// Target Board: Arduino Uno R3 (ATmega328P)
// Architecture: AVR
// --- PIN DEFINITIONS ---
const int NS_RED = 2;
const int NS_YELLOW = 3;
const int NS_GREEN = 4;
const int EW_RED = 5;
const int EW_YELLOW = 6;
const int EW_GREEN = 7;
const int S_RED = 8;
const int S_YELLOW = 9;
const int S_GREEN = 10;
const int W_RED = 11;
const int W_YELLOW = 12;
const int W_GREEN = 13;
const int ALL_PINS[] = {NS_RED, NS_YELLOW, NS_GREEN, EW_RED, EW_YELLOW, EW_GREEN, S_RED, S_YELLOW, S_GREEN, W_RED, W_YELLOW, W_GREEN};
const int PIN_COUNT = 12;
// --- TIMING (milliseconds) ---
const unsigned long GREEN_DURATION = 5000;
const unsigned long YELLOW_DURATION = 2000;
const unsigned long ALL_RED_DURATION = 500; // Safety clearance interval
// --- STATE MACHINE ---
enum TrafficPhase {
NS_GREEN_PHASE,
NS_YELLOW_PHASE,
CLEARANCE_1,
EW_GREEN_PHASE,
EW_YELLOW_PHASE,
CLEARANCE_2
};
TrafficPhase currentPhase = NS_GREEN_PHASE;
unsigned long previousMillis = 0;
void setup() {
Serial.begin(115200);
// Configure all pins as OUTPUT
for (int i = 0; i < PIN_COUNT; i++) {
pinMode(ALL_PINS[i], OUTPUT);
}
verifyPins();
setAllRed();
previousMillis = millis();
Serial.println(F("Traffic Light Intersection Initialized."));
}
void loop() {
unsigned long currentMillis = millis();
unsigned long interval = 0;
switch (currentPhase) {
case NS_GREEN_PHASE:
interval = GREEN_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = NS_YELLOW_PHASE;
previousMillis = currentMillis;
}
break;
case NS_YELLOW_PHASE:
interval = YELLOW_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = CLEARANCE_1;
previousMillis = currentMillis;
}
break;
case CLEARANCE_1:
interval = ALL_RED_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = EW_GREEN_PHASE;
previousMillis = currentMillis;
}
break;
case EW_GREEN_PHASE:
interval = GREEN_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = EW_YELLOW_PHASE;
previousMillis = currentMillis;
}
break;
case EW_YELLOW_PHASE:
interval = YELLOW_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = CLEARANCE_2;
previousMillis = currentMillis;
}
break;
case CLEARANCE_2:
interval = ALL_RED_DURATION;
if (currentMillis - previousMillis >= interval) {
currentPhase = NS_GREEN_PHASE;
previousMillis = currentMillis;
}
break;
}
updateLights();
}
void updateLights() {
// Default all to RED for safety
digitalWrite(NS_RED, HIGH); digitalWrite(S_RED, HIGH);
digitalWrite(EW_RED, HIGH); digitalWrite(W_RED, HIGH);
digitalWrite(NS_GREEN, LOW); digitalWrite(S_GREEN, LOW);
digitalWrite(EW_GREEN, LOW); digitalWrite(W_GREEN, LOW);
digitalWrite(NS_YELLOW, LOW); digitalWrite(S_YELLOW, LOW);
digitalWrite(EW_YELLOW, LOW); digitalWrite(W_YELLOW, LOW);
switch (currentPhase) {
case NS_GREEN_PHASE:
digitalWrite(NS_GREEN, HIGH); digitalWrite(S_GREEN, HIGH);
break;
case NS_YELLOW_PHASE:
digitalWrite(NS_YELLOW, HIGH); digitalWrite(S_YELLOW, HIGH);
digitalWrite(NS_RED, LOW); digitalWrite(S_RED, LOW);
break;
case EW_GREEN_PHASE:
digitalWrite(EW_GREEN, HIGH); digitalWrite(W_GREEN, HIGH);
digitalWrite(EW_RED, LOW); digitalWrite(W_RED, LOW);
break;
case EW_YELLOW_PHASE:
digitalWrite(EW_YELLOW, HIGH); digitalWrite(W_YELLOW, HIGH);
digitalWrite(EW_RED, LOW); digitalWrite(W_RED, LOW);
break;
// CLEARANCE phases leave all RED (default state above)
}
}
void setAllRed() {
for (int i = 0; i < PIN_COUNT; i++) {
digitalWrite(ALL_PINS[i], LOW);
}
digitalWrite(NS_RED, HIGH); digitalWrite(S_RED, HIGH);
digitalWrite(EW_RED, HIGH); digitalWrite(W_RED, HIGH);
}
void verifyPins() {
// Basic sanity check to ensure pins aren't stuck or shorted to VCC
// Note: AVR GPIO pins don't easily read back OUTPUT state without reading PINx registers,
// so we toggle and read the port register to verify configuration.
for (int i = 0; i < PIN_COUNT; i++) {
digitalWrite(ALL_PINS[i], LOW);
// If a pin is physically shorted to 5V, this won't catch it via digitalRead on OUTPUT,
// but it ensures the DDR (Data Direction Register) is set correctly.
if (digitalRead(ALL_PINS[i]) != LOW) {
Serial.print(F("FAULT: Pin "));
Serial.print(ALL_PINS[i]);
Serial.println(F(" failed LOW state verification. Check for short to 5V."));
}
}
}
Debugging: First 3 Things to Check When It Fails
When your intersection fails to sequence correctly, do not rewrite the code immediately. Hardware faults and minor syntax errors cause 90% of traffic light project failures. Follow this exact troubleshooting sequence.
1. The Compilation Error: Syntax in the State Machine
Exact Error String: expected ';' before '}'
Ranked Causes:
- Missing semicolon after a state assignment: In the
switch(currentPhase)block, writingcurrentPhase = NS_YELLOW_PHASEwithout the trailing semicolon. Fix: Add the semicolon. - Missing break statement: Forgetting
break;at the end of a case block causes the compiler to misinterpret the closing brace. Fix: Ensure every case has a break or explicit fall-through comment.
2. The Hardware Fault: Pin 13 Ghosting
Symptom: The West Green LED (on D13) stays dimly lit even when commanded LOW, or flickers out of sync with the state machine.
Ranked Causes:
- Onboard LED Interference: The Arduino Uno R3 has a built-in LED tied to Pin 13. The op-amp buffer driving this onboard LED draws a small amount of current, which can alter the voltage threshold for your external LED if your resistor value is too high. Fix: Ensure you are using exactly a 220Ω resistor on D13, not a 1kΩ or higher. Alternatively, move the West Green LED to D14 (A0) and update the code.
- Missing Ground Connection: The breadboard ground rail for the bottom half isn't jumpered to the top half. Fix: Run a physical jumper wire across the center trench of the breadboard.
3. The Timing Glitch: Rapid Flickering
Symptom: The lights cycle through all phases in less than a second, appearing as a rapid flicker rather than holding solid states.
Ranked Causes:
- Millis Rollover Bug: You attempted to write your own timer using
if (currentMillis > previousMillis + interval). Whenmillis()rolls over after 49 days, this math breaks catastrophically. Fix: Always use subtraction:if (currentMillis - previousMillis >= interval)as shown in the provided code. - Accidental Delay Mixing: You added a
delay(10)inside theloop()for button debouncing, which starves the state machine of CPU cycles and desynchronizes the timing logic. Fix: Remove alldelay()calls. Handle debouncing via non-blocking timers.
1. Is the IDE throwing a syntax error on the state assignments?
2. Is Pin 13 behaving weirdly due to the onboard LED?
3. Are you using subtraction for
millis() math instead of addition?Extending or Simplifying the Build
Once the base 4-way intersection is stable, you will likely need to adapt it for a specific classroom demo, escape room prop, or smart-city model. Here is how to pivot the architecture without scrapping your work.
How to Simplify (The 2-Way Road)
If you only need to model a single road (North/South) and don't care about cross-traffic, cut the hardware in half.
- Hardware: Remove all East/West LEDs (D5, D6, D7, D11, D12, D13). You now only need 3 pins (D2, D3, D4) for North, and 3 pins (D8, D9, D10) for South.
- Code: Delete the
EW_andW_variables. Reduce the state machine to justNS_GREEN_PHASE,NS_YELLOW_PHASE, andNS_RED_PHASE. This frees up CPU cycles and makes the code readable for absolute beginners.
How to Extend (Pedestrian Crosswalks & IoT)
To turn this from a static model into an interactive system:
- Add Pedestrian Buttons: Wire two momentary pushbuttons to D14 (A0) and D15 (A1) with 10kΩ pull-down resistors. Because the provided code uses
millis()instead ofdelay(), you can add acheckButtons()function inside the mainloop()that instantly flags a booleanpedestrianRequested = true. Modify the state machine to truncate theGREEN_DURATIONif that boolean is true. - Upgrade to ESP32 for Telemetry: If you want to log traffic flow to a dashboard, swap the Uno R3 for an ESP32 DevKit V1. The ESP32 is 3.3V logic, so you must recalculate your resistors. For a 2.0Vf Red LED on a 3.3V pin: (3.3V - 2.0V) / 0.02A = 65Ω. Use a standard 68Ω or 82Ω resistor instead of 220Ω to maintain brightness. You can then use the
WiFi.hlibrary to push state changes via MQTT to a Home Assistant instance.
By starting with the robust, non-blocking architecture detailed above, your traffic light Arduino project remains a stable foundation whether it ends up as a simple desk toy or a node in a larger IoT network.






