The delay() Trap: Why Beginner Code Fails at Scale

When makers first search for how to create a traffic light system with Arduino, they are almost universally met with tutorials relying on the delay() function. While this approach successfully blinks a few LEDs on a breadboard, it represents a critical anti-pattern in professional embedded systems. The delay() function is a blocking call; it halts the microcontroller's CPU, preventing it from reading sensors, processing pedestrian crosswalk buttons, or handling network communications. In 2026, with the maker community heavily integrating IoT and real-time sensor data into DIY infrastructure projects, a blocking traffic light sketch is essentially dead on arrival for any scalable application.

To build a robust intersection controller, we must shift our perspective from simple scripting to workflow optimization. This means adopting modular hardware design and non-blocking Finite State Machine (FSM) architectures. This guide details exactly how to create a traffic light system with Arduino using professional-grade workflows, ensuring your code and hardware can scale from a single desk toy to a multi-node smart city prototype.

Hardware Workflow: From Breadboard Spaghetti to Modular Harnesses

Optimizing your hardware workflow begins with abandoning loose Dupont jumper wires for permanent or semi-permanent deployments. Vibration, temperature changes, and oxidation will inevitably cause loose connections to fail. Instead, standardize your interconnects using JST-XH 2.54mm connectors. By crimping JST terminals onto your LED leads and routing them to a central terminal block on your Arduino Uno R4 Minima (currently retailing around $22), you create a modular harness. This allows you to swap out a damaged traffic light pole in seconds without needing a soldering iron on-site.

Precision Current-Limiting Resistor Calculation

A common beginner mistake is using a blanket 220Ω resistor for all LED colors. This ignores the specific forward voltage (Vf) of different semiconductor materials, leading to uneven brightness. To optimize your optical output, calculate exact resistor values based on the Arduino's 5V logic output and a target current of 20mA (0.02A) using Ohm's Law: R = (Vcc - Vf) / I.

  • Red LEDs (Vf ≈ 2.0V): (5.0 - 2.0) / 0.02 = 150Ω. Use a standard 150Ω resistor.
  • Yellow LEDs (Vf ≈ 2.1V): (5.0 - 2.1) / 0.02 = 145Ω. Use a standard 150Ω resistor.
  • Green LEDs (Vf ≈ 3.3V): (5.0 - 3.3) / 0.02 = 85Ω. Use a standard 100Ω resistor.

By matching the resistors to the specific chemistry of the 5mm diffused LEDs, you ensure uniform luminosity across the entire signal head without overdriving the microcontroller's GPIO pins, which are typically capped at 20mA per pin on the R4 Minima.

Software Workflow: Structuring for State Machines

Professional firmware development requires organized codebases. If you are still writing your entire sketch inside the default main.ino file, you are bottlenecking your own workflow. Leverage the multi-tab architecture in Arduino IDE 2.x to separate concerns. Create three distinct tabs:

  1. main.ino: Contains only the setup() and loop() functions, acting as the executive scheduler.
  2. hardware_config.h: Defines all GPIO pin mappings, timing constants, and hardware-specific macros.
  3. state_machine.cpp: Houses the FSM logic, state transition rules, and non-blocking timing evaluations.

This separation adheres to the principles outlined in the Barr Group Embedded C Coding Standard, which heavily advocates for modularity and strict separation of hardware dependencies from business logic. When you inevitably migrate your traffic light project from an Arduino Uno to an ESP32 for Wi-Fi connectivity, you only need to update the hardware_config.h tab, leaving your core FSM logic completely untouched.

Mastering Non-Blocking Timing with millis()

Instead of pausing the CPU, we track time using the millis() function. As detailed in the official Arduino millis() documentation, this function returns the number of milliseconds since the board began running the current program. By storing a 'previous' timestamp and comparing it to the 'current' timestamp, we can evaluate if a state's duration has elapsed without halting the main loop. Crucially, you must always use the unsigned long data type for these variables to prevent overflow errors when the 32-bit integer rolls over approximately every 49.7 days.

The Finite State Machine (FSM) Implementation

A traffic light is inherently a Finite State Machine. It exists in one distinct state at a time and transitions based on predefined timing or external triggers (like an inductive loop sensor). Below is the state transition matrix for a standard UK-style traffic sequence (which includes a Red+Yellow phase before Green).

Current State Active Outputs (GPIO HIGH) Duration (ms) Next State
STATE_RED Red LED 5000 STATE_RED_YELLOW
STATE_RED_YELLOW Red LED, Yellow LED 1500 STATE_GREEN
STATE_GREEN Green LED 5000 STATE_YELLOW
STATE_YELLOW Yellow LED 2000 STATE_RED

In your code, define these states using an enum to improve readability and prevent magic numbers from cluttering your logic. Inside the loop(), a switch statement evaluates the current state, checks if the millis() threshold has been crossed, and updates both the GPIO outputs and the state variable accordingly.

Edge Cases and Real-World Troubleshooting

Even with an optimized workflow, physical hardware introduces variables that pure software simulations miss. Here are specific failure modes to engineer against:

Floating Inputs on Pedestrian Buttons

If you add a pedestrian crosswalk button to interrupt the green phase, never leave the input pin floating. A floating pin acts as an antenna, picking up electromagnetic interference (EMI) from nearby power lines or motors, causing phantom button presses. Always use the microcontroller's internal pull-up resistor by configuring the pin as INPUT_PULLUP in your setup routine, and wire the button to pull the pin to Ground when pressed. For long wire runs exceeding 3 meters, supplement this with an external 10kΩ physical pull-up resistor and a 0.1µF ceramic bypass capacitor to debounce the signal at the hardware level.

Pro Tip: Never rely on software debouncing for critical safety interrupts in physical infrastructure. A 0.1µF hardware capacitor across the button terminals provides instant, zero-latency noise filtering.

Voltage Drop on Long Harnesses

If your traffic light pole is mounted more than a meter away from the Arduino enclosure using thin 28 AWG wire, you will experience voltage drop. The resistance of the wire will reduce the voltage reaching the LED, causing it to dim. To solve this, either step up to 22 AWG wire for your harness or drive the LEDs using N-channel MOSFETs (like the logic-level IRLZ44N) located at the base of the pole, using the Arduino only to send a low-current 5V logic signal to the MOSFET gate.

Conclusion

Learning how to create a traffic light system with Arduino is a rite of passage, but elevating that project from a beginner tutorial to a robust, scalable prototype requires intentional workflow optimization. By calculating exact component values, utilizing JST modularity, separating your IDE architecture, and implementing a non-blocking FSM, you build a foundation that can support complex, real-world embedded systems.