A "traffic light" is one of the most rewarding beginner projects because you learn almost everything that recurs in bigger projects later: neat wiring, resistors for LEDs, states (state machine), timing, and finally the question whether to bluntly use delay() or properly implement a non-blocking approach. Moreover, the project runs virtually identically on an Arduino (e.g., Uno/Nano) and on an ESP32 – you just need to keep a few electrical differences in mind.
Important upfront: This is a model traffic light for DIY purposes. Not for real traffic control, not for safety-critical applications.
What the Traffic Light Should "Do"
The classic vehicle traffic light has different phases depending on the country. For a model, usually this sequence is enough:
- Red
- Red + Yellow (announcement "soon Green")
- Green
- Yellow
- back to Red
This already forms a complete cyclic state machine. Optionally, you can add a pedestrian light (Red/Green) and later push buttons so that the pedestrian phase only occurs on demand.
Components and Why You Need Them
For the minimal version:
- 1× Arduino Uno/Nano or 1× ESP32 DevKit
- 3× LEDs: Red, Yellow, Green (standard 5 mm)
- 3× Resistors (typically 220–330 Ω)
- Breadboard + jumper wires
- USB cable for programming
Why use resistors? An LED is not a lightbulb. Without a resistor, nothing limits the current – which can destroy the LED and the microcontroller pin. The resistor ensures that "too much current" becomes a defined, safe current.
A rough calculation (just for a feel):
- Arduino pin: 5 V, red LED about 2 V → 3 V at the resistor. At 10 mA: 3 V / 0.01 A = 300 Ω → 330 Ω fits.
- ESP32 pin: 3.3 V, red LED about 2 V → 1.3 V at the resistor. At 5–8 mA typical resistor is 150–220 Ω, 220 Ω usually works well.
You can use 220–330 Ω for both platforms and be safe.
Arduino vs. ESP32: The Two Most Important Differences
1) Logic level / voltage:
- Arduino Uno: 5 V logic, pins output 5 V (HIGH)
- ESP32: 3.3 V logic, pins output 3.3 V (HIGH)
For single LEDs this is usually uncritical. For modules or external circuits, keep this in mind.
2) Pin selection on ESP32: Not all pins on the ESP32 are equally "simple". Some are bootstrapping pins or used during startup in a way that creates trouble if you connect LEDs "wrong". For a traffic light use the "safe" GPIOs, e.g., GPIO 16, 17, 18, 19, 21, 22, 23, 25, 26, 27, 32, 33 (depending on the board). This avoids surprises.
Wiring: Simple, Robust, Easy to Check
The standard wiring for each LED:
GPIO pin → resistor → LED anode (long leg) → LED cathode (short leg) → GND
This means the LED sits between the pin and ground, and a HIGH on the pin turns it on.
Example pins (can be changed as you like):
- Red: Pin 8 (Arduino) / GPIO 16 (ESP32)
- Yellow: Pin 9 (Arduino) / GPIO 17 (ESP32)
- Green: Pin 10 (Arduino) / GPIO 18 (ESP32)
If you work cleanly, test each LED individually during assembly: a small sketch that blinks the LED. This way you find wiring errors immediately instead of searching through logic later.
Software Approach 1: Fast and Simple with delay()
With delay() you get results very quickly. The downside: While delay() runs, the controller is "busy" – you cannot poll buttons, read sensors, handle WiFi, logging, everything is blocked. For a demo this is okay, but for "real" projects it becomes annoying sooner or later.
Still, as a start:
// Works on Arduino and ESP32 (Arduino IDE)
// Adjust pins!
#if defined(ESP32)
const int PIN_RED = 16;
const int PIN_YELLOW = 17;
const int PIN_GREEN = 18;
#else
const int PIN_RED = 8;
const int PIN_YELLOW = 9;
const int PIN_GREEN = 10;
#endif
void setup() {
pinMode(PIN_RED, OUTPUT);
pinMode(PIN_YELLOW, OUTPUT);
pinMode(PIN_GREEN, OUTPUT);
}
void loop() {
// Red
digitalWrite(PIN_RED, HIGH);
digitalWrite(PIN_YELLOW, LOW);
digitalWrite(PIN_GREEN, LOW);
delay(5000);
// Red + Yellow
digitalWrite(PIN_RED, HIGH);
digitalWrite(PIN_YELLOW, HIGH);
digitalWrite(PIN_GREEN, LOW);
delay(1500);
// Green
digitalWrite(PIN_RED, LOW);
digitalWrite(PIN_YELLOW, LOW);
digitalWrite(PIN_GREEN, HIGH);
delay(5000);
// Yellow
digitalWrite(PIN_RED, LOW);
digitalWrite(PIN_YELLOW, HIGH);
digitalWrite(PIN_GREEN, LOW);
delay(1500);
}
It is easy to read and perfect to verify wiring.
Software Approach 2: "Proper" with State Machine and millis() (Non-blocking)
Once you want a button (pedestrian) or more logic later, a non-blocking approach is worthwhile. The principle: Remember the current state and the timestamp when you entered it. Each loop iteration checks whether the duration has passed, and if so, transitions.
Theoretically sounds complicated, but is very convenient in practice: loop stays responsive, you can always check inputs or run other tasks in parallel.
// Non-blocking traffic light (state machine) for Arduino & ESP32
#if defined(ESP32)
const int PIN_RED = 16;
const int PIN_YELLOW = 17;
const int PIN_GREEN = 18;
#else
const int PIN_RED = 8;
const int PIN_YELLOW = 9;
const int PIN_GREEN = 10;
#endif
enum State {
RED,
RED_YELLOW,
GREEN,
YELLOW
};
State state = RED;
unsigned long stateStartMs = 0;
// Times in milliseconds
const unsigned long T_RED = 5000;
const unsigned long T_REDYELLOW = 1500;
const unsigned long T_GREEN = 5000;
const unsigned long T_YELLOW = 1500;
void setLights(bool r, bool y, bool g) {
digitalWrite(PIN_RED, r ? HIGH : LOW);
digitalWrite(PIN_YELLOW, y ? HIGH : LOW);
digitalWrite(PIN_GREEN, g ? HIGH : LOW);
}
void enterState(State s) {
state = s;
stateStartMs = millis();
switch (state) {
case RED: setLights(true, false, false); break;
case RED_YELLOW: setLights(true, true, false); break;
case GREEN: setLights(false, false, true ); break;
case YELLOW: setLights(false, true, false); break;
}
}
bool elapsed(unsigned long duration) {
return (millis() - stateStartMs) >= duration;
}
void setup() {
pinMode(PIN_RED, OUTPUT);
pinMode(PIN_YELLOW, OUTPUT);
pinMode(PIN_GREEN, OUTPUT);
enterState(RED);
}
void loop() {
switch (state) {
case RED:
if (elapsed(T_RED)) enterState(RED_YELLOW);
break;
case RED_YELLOW:
if (elapsed(T_REDYELLOW)) enterState(GREEN);
break;
case GREEN:
if (elapsed(T_GREEN)) enterState(YELLOW);
break;
case YELLOW:
if (elapsed(T_YELLOW)) enterState(RED);
break;
}
// Now you can do things anytime here:
// - poll buttons
// - read sensors
// - serial logging
// - on ESP32: WiFi/MQTT etc.
}
This code has three big advantages:
- Traffic light logic is clearly structured as a state machine.
- Times are centrally defined as constants.
- There's space in the loop for extensions without blocking everything.
Typical Errors – and How to Find Them Quickly
LED doesn't light at all: Usually the LED is inserted the wrong way around. The long leg is normally the anode (+). Also: GND must really be ground – breadboards sometimes have split rails.
LED glows dimly or flickers constantly: Pin possibly chosen wrong (ESP32 boot pins) or connection not solid. On ESP32 try other GPIOs.
Controller doesn't start (ESP32): Often a LED or module is connected to a pin that expects a certain level during boot. Solution: use "safe" GPIOs and avoid typical strapping pins.
Forgot resistors: Best case it just gets hot, worst case the pin dies. Always use resistors.
Useful Upgrades: From Demo to Small Project
Once the basic traffic light runs, typical next steps almost come automatically:
1) Pedestrian traffic light with button
Add a second LED group (red/green) and a button. The state machine can insert a pedestrian phase on appropriate button press at the next suitable time. With millis() this is cleanly doable without glueing logic together.
2) More realistic timing and all-red clear intervals You can for example insert a short "all red" phase after "yellow" (clearing time) to be more realistic.
3) More LEDs with few pins (shift register / I²C expander) If a traffic light becomes a junction, pins get scarce quickly. Then ICs like 74HC595 (shift register) or I²C port expanders (e.g., MCP23017) are very practical.
4) Power stage for real lamps As soon as no LEDs but bigger loads (relays, 12 V lamps, LED strips) are switched, you need transistors/MOSFETs and proper power supply. Microcontroller pins are not current sources for "real" loads.
5) ESP32: Web interface or MQTT With an ESP32 you can configure times live, display status via web UI, or integrate via MQTT into smart home systems. Non-blocking logic is practically mandatory for this.
Conclusion
An Arduino/ESP32 traffic light is more than blinking three LEDs: The project forces clean wiring, teaches timing basics, and is the perfect introduction to state machines. When you work directly with millis(), you have almost no refactoring later when adding buttons, sensors, or communication. That's why the traffic light is such a classic: simple enough to get started, yet "honest" enough to cultivate good technical habits.
If you like, I can write a next version with a pedestrian button (including debouncing) and optional "all red" clearing time as a next step.