Building a Traffic Light Controller and Morse Encoder on an FPGA
There’s a particular satisfaction in writing a few hundred lines of Verilog, hitting compile, and watching a piece of programmable silicon become exactly the circuit you described. I recently spent some time designing and testing three digital systems on an Intel Cyclone V FPGA: a pedestrian-activated traffic light controller, a countdown-timer extension to it, and a Morse code encoder. This post walks through the design decisions, the things I learned about FSM design and FPGA timing, and what each circuit looks like running on real hardware.
Why FPGAs?
A Field-Programmable Gate Array (FPGA) is a chip you can reconfigure after manufacture. Where a microcontroller runs sequential instructions on a fixed processor, an FPGA becomes the circuit you describe in a hardware description language like Verilog — every logic gate, every flip-flop, all of it happening in physical parallelism. The same chip can be a traffic light controller one minute and a Morse encoder the next, just by changing a configuration file.
The target board was the Terasic DE1-SoC, built around an Intel Cyclone V (5CSEMA5F31C6) with 32,070 Adaptive Logic Modules, 397 embedded memory blocks, and 6 PLLs. A 50 MHz crystal oscillator drives everything — 20 ns per clock cycle.
The full development flow uses Intel’s Quartus Prime: write Verilog, run synthesis to convert it into a gate-level netlist, let the fitter place and route those gates onto the physical fabric, run static timing analysis, then push the resulting bitstream to the board over JTAG.
Design 1: A Pedestrian Traffic Light Controller
The first circuit was a finite state machine (FSM) controlling a pedestrian crossing. Vehicle signals (green/yellow/red), pedestrian signals (green/red), one push-button to request a crossing, and another for asynchronous reset.
I started with the obvious three-state FSM: G (vehicle green, pedestrian red) → Y (yellow, 5 s) → R (vehicle red, pedestrian green, 10 s) → back to G. It compiled cleanly and ran on the board first time.
And then I found the bug — not in the code, but in the design. With three states, a pedestrian who pressed the request button the instant the system returned to G would trigger another full cycle immediately. Repeated presses could starve the vehicle lane of green time indefinitely. In a textbook this is fine; in a real intersection it would cause a traffic jam.
The fix was to add a fourth state, W (Wait), inserted between R and G. State W asserts the same outputs as G — vehicle green, pedestrian red — but ignores pedestrian requests for 10 seconds. From the road user’s perspective the lights look identical; internally, the FSM is enforcing a minimum green-time guarantee.
This was the design lesson I keep coming back to:
Two states can produce identical outputs but encode entirely different behaviour. The distinction lives in which transitions they permit.
Collapsing G and W into one state with some kind of timer flag would have been messier and harder to verify. Keeping them as separate states in the FSM made the lockout behaviour structural rather than incidental.
The final four-state design consumed 32 ALMs — less than 0.1% of the FPGA’s capacity — and 33 registers, mostly belonging to the 29-bit cycle counter.
A Timing Lesson I Didn’t Expect
Static timing analysis turned up something unwelcome: a setup-slack violation of −2.976 ns at the worst-case corner. In other words, the longest combinational path through the design needed about 22.98 ns to settle, but the clock period was only 20 ns.
The culprit was a 29-bit equality comparator. To detect when the counter had reached 500,000,000 (10 seconds at 50 MHz), the synthesised logic was comparing all 29 bits against a constant — a long chain of LUTs whose worst-case delay exceeded the clock period. The circuit worked fine in practice because typical room-temperature silicon runs faster than the pessimistic slow-corner model assumes, but the violation was a clear signal that the architecture wasn’t right.
This led directly to the design choice in the next circuit.
Design 2: Adding a Live Countdown Timer
The countdown extension does what the name says: shows the seconds remaining for each timed state on two seven-segment displays. Five seconds for yellow, ten for red and wait, and blank during green.
But I treated this as more than a cosmetic upgrade — it was a chance to fix the timing violation properly. Instead of one monolithic 29-bit counter doing everything, I split timing into two levels:
- A 26-bit tick counter generates a one-second pulse every 50 million clock cycles.
- A 4-bit seconds counter increments on each pulse and drives the state transitions.
State transitions now compare against tiny 4-bit constants — seconds == 4'd9 for ten seconds, for example — instead of a 29-bit terminal-count match. The critical-path comparator dropped from ~5 levels of carry logic to ~2 levels, comfortably inside the 20 ns budget. As a free bonus, the time-remaining figure for the display is just max_duration - seconds, a trivial 4-bit subtraction.
always @(*) begin
case (state)
Y: time_remaining = 4'd5 - seconds;
R: time_remaining = 4'd10 - seconds;
W: time_remaining = 4'd10 - seconds;
default: time_remaining = 4'd0;
endcase
end
The seven-segment decode is a straightforward case-statement lookup, with leading-zero blanking on the tens digit. The whole extension cost 25 extra ALMs and 14 extra output pins — a fair trade for visible feedback and a clean timing report.
Here’s the full sequence running on the board:
Design 3: A Morse Code Encoder
The third circuit was a complete change of direction: a Morse code encoder for letters A through H. Three slide switches select the letter, a button triggers transmission, and an LED flashes the dot-dash pattern. Dots are 0.5 s on, dashes are 1.5 s on, with a 0.5 s gap between symbols.
The interesting design question was: how do you store the patterns? The naive approach would be a sprawling FSM with a dedicated branch for each letter. The cleaner approach — and the one I ended up using — is a shift register plus a lookup table:
- A combinational case statement maps the 3-bit letter selector to a 4-bit pattern (1 = dash, 0 = dot, MSB first) and a length count.
- A five-state FSM (IDLE → LOAD → SYMBOL → GAP → DONE) walks through the pattern, shifting left after each symbol and checking the new MSB to decide whether the next pulse should be a dot or a dash.
3'd0: begin // A: .-
init_pattern = 4'b0100;
init_length = 3'd2;
end
3'd1: begin // B: -...
init_pattern = 4'b1000;
init_length = 3'd4;
end
// ... C through H
Extending this to the full 26-letter alphabet wouldn’t require any FSM changes at all — just widen the shift register and grow the lookup table. Separating what to encode from how to encode it kept the design modular and easy to reason about.
The encoder runs here, cycling through the letters:
Comparing the Three Designs
| Design | ALMs | Registers | Pins |
|---|---|---|---|
| Traffic Light Controller (4-state) | 32 | 33 | 8 |
| TLC + Countdown Timer | 57 | 45 | 22 |
| Morse Encoder | 35 | 51 | 7 |
All three sit well under 1% of the FPGA’s logic capacity. The same physical chip becomes three completely different machines depending on which Verilog file and which pin-assignment file I push to it — and the iteration cycle from “change one line of code” to “circuit running on hardware” is about a minute and a half.
Reflections
A few things that stuck with me from this project:
- Architecture choices show up in timing reports. The 29-bit comparator wasn’t wrong — it was just structured in a way the synthesis tool couldn’t optimise around. Splitting the counter into a tick-and-seconds hierarchy wasn’t only cleaner, it shaved the critical path enough to make timing close. Hierarchy in counters maps almost directly to hierarchy in propagation delay.
- Behavioural HDL hides a lot, but it doesn’t hide everything. Verilog lets you describe circuits at a high level, but the synthesiser is still making decisions about gate-level structure that affect performance. Reading the compilation reports — ALM counts, slack values, fitter logs — is where the abstraction meets reality.
- The biggest weakness of this work was the lack of simulation. I verified everything by watching LEDs, which works for designs this simple but wouldn’t scale. A proper testbench-driven flow with cycle-accurate verification would have caught edge cases (reset mid-countdown, letter change during transmission) far more reliably than my eyes did.
Building these three circuits gave me a feel for the full FPGA design flow — from behavioural specification through synthesis, fitting, timing analysis, and hardware verification — that I don’t think I could have got from reading about it. The fact that 32,070 ALMs are sitting on the same chip, mostly idle, suggests there’s a lot more room to explore.