Project, a Pedestrian Crossing
| Difficulty | Beginner |
|---|---|
| Category | Projects |
| Supported boards | Arduino Uno R3 and compatible ATmega328P boards |
| Time needed | 90 minutes |
| Prerequisites | LED Traffic Light Module |
| Tested board | Not yet verified |
| Tested on | Not yet verified |
| Arduino IDE version | Not yet verified |
| Library | Not yet verified |
What you will build
A working pedestrian crossing. The traffic light sits on green. Press the button and nothing happens for a moment, then amber, then red, a beep while the crossing is clear, and back to green after a sensible pause.
It is the first project in the library and it is deliberately modest. Three parts you already know and no new wiring tricks. What it teaches is the thing every project after it needs: how to make several components behave as one sequence without delay() freezing the button.
Before you begin
Parts
| Part | Siratek product | Its own tutorial |
|---|---|---|
| Arduino Uno | 2219 | Getting Started with Arduino Uno |
| LED traffic light module | 49920 | LED Traffic Light Module |
| Tactile push button | 49488 | Read a Push Button |
| Passive buzzer | 8051 | Play Tones with a Passive Buzzer |
| Breadboard and jumper wires | Using a Breadboard |
Build each of those three on its own first if you have not. This page assumes all three work separately, and the first stage below checks that assumption rather than trusting it.
What this project is really teaching
A crossing is a state machine. At any moment it is in exactly one of a handful of states, and each state knows what comes next and when.
That sounds grand for a set of lights. It matters because the obvious approach does not work:
// The obvious version, which cannot work.
if (buttonPressed()) {
digitalWrite(green, LOW);
digitalWrite(amber, HIGH);
delay(3000); // the button is ignored for three seconds
digitalWrite(amber, LOW);
digitalWrite(red, HIGH);
delay(8000); // and for eight more
// ...
}
During those eleven seconds the sketch is blind. It cannot beep in a pattern, cannot watch the button, cannot do anything. Real crossings do all of that at once.
The state machine solves it with no cleverness: a variable says which state we are in, a timestamp says when we entered it, and loop() asks each pass whether it is time to move on. It is the millis() pattern from Timing Without delay(), applied to a sequence rather than one LED.
How it works
Five states, and the rules between them:
| State | Lights | What ends it |
|---|---|---|
TRAFFIC_GREEN |
Green | A button press, and at least 5 s since the last crossing |
TRAFFIC_AMBER |
Amber | 3 seconds |
PEDESTRIAN_CROSS |
Red | 8 seconds, with the buzzer beeping |
PEDESTRIAN_CLEAR |
Red, buzzer faster | 4 seconds |
TRAFFIC_RETURN |
Amber | 2 seconds, then back to green |
Two design decisions are worth naming, because they are the difference between a toy and something that behaves properly.
The minimum green time. Without it, holding the button down cycles the crossing continuously and traffic never moves. Five seconds of guaranteed green after a crossing finishes is what a real installation does, and it is one comparison in the code.
The press is remembered, not acted on. Pressing during the red phase does not queue a second crossing. The request is recorded, and it is cleared when the crossing begins.
Build it in stages
Each stage ends with something you can see. Do not move on until you have seen it.
Stage 1: prove the three parts
Wire all three and run this. It does nothing clever; it proves the wiring before any logic exists.
// Siratek: stage 1. Each part, one at a time.
const int redPin = 8;
const int amberPin = 9;
const int greenPin = 10;
const int buttonPin = 2;
const int buzzerPin = 11;
void setup() {
pinMode(redPin, OUTPUT);
pinMode(amberPin, OUTPUT);
pinMode(greenPin, OUTPUT);
pinMode(buzzerPin, OUTPUT);
pinMode(buttonPin, INPUT_PULLUP);
Serial.begin(9600);
Serial.println("Siratek: red, amber, green, beep, then the button.");
int pins[] = {redPin, amberPin, greenPin};
for (int i = 0; i < 3; i++) {
digitalWrite(pins[i], HIGH);
delay(600);
digitalWrite(pins[i], LOW);
}
tone(buzzerPin, 1000, 300);
}
void loop() {
Serial.println(digitalRead(buttonPin) == LOW ? "pressed" : "up");
delay(300);
}
Each light in turn, one beep, then the button state printed. If any of those is wrong, fix it now: it will be far harder to find once there is a state machine on top.
Stage 2: the sequence, without the button
Replace loop() with a sequence that runs on a timer, so you can watch the timings and adjust them before the button complicates things.
// Siratek: stage 2. The sequence, triggered every 15 seconds.
enum State { TRAFFIC_GREEN, TRAFFIC_AMBER, PEDESTRIAN_CROSS, PEDESTRIAN_CLEAR, TRAFFIC_RETURN };
State state = TRAFFIC_GREEN;
unsigned long stateSince = 0;
void enter(State next) {
state = next;
stateSince = millis();
digitalWrite(redPin, state == PEDESTRIAN_CROSS || state == PEDESTRIAN_CLEAR);
digitalWrite(amberPin, state == TRAFFIC_AMBER || state == TRAFFIC_RETURN);
digitalWrite(greenPin, state == TRAFFIC_GREEN);
}
unsigned long elapsed() {
return millis() - stateSince;
}
void loop() {
switch (state) {
case TRAFFIC_GREEN:
if (elapsed() >= 15000) enter(TRAFFIC_AMBER);
break;
case TRAFFIC_AMBER:
if (elapsed() >= 3000) enter(PEDESTRIAN_CROSS);
break;
case PEDESTRIAN_CROSS:
if (elapsed() >= 8000) enter(PEDESTRIAN_CLEAR);
break;
case PEDESTRIAN_CLEAR:
if (elapsed() >= 4000) enter(TRAFFIC_RETURN);
break;
case TRAFFIC_RETURN:
if (elapsed() >= 2000) enter(TRAFFIC_GREEN);
break;
}
}
Watch a full cycle. Adjust the numbers until the rhythm feels right. Notice that enter() sets all three lights from the state, so no state can accidentally leave a light on: that is worth more than it looks.
Stage 3: the button and the buzzer
Now add the request, the minimum green time, and the beeping. That is the complete sketch below.
The complete sketch
// Siratek: a pedestrian crossing.
// Traffic runs on green until someone asks to cross.
const int redPin = 8;
const int amberPin = 9;
const int greenPin = 10;
const int buttonPin = 2;
const int buzzerPin = 11;
// Timings, in milliseconds. Adjust to taste.
const unsigned long AMBER_TIME = 3000;
const unsigned long CROSS_TIME = 8000;
const unsigned long CLEAR_TIME = 4000;
const unsigned long RETURN_TIME = 2000;
const unsigned long MINIMUM_GREEN = 5000; // traffic gets at least this long
// Beep rates: slow while crossing, urgent while clearing.
const unsigned long CROSS_BEEP = 700;
const unsigned long CLEAR_BEEP = 220;
enum State { TRAFFIC_GREEN, TRAFFIC_AMBER, PEDESTRIAN_CROSS, PEDESTRIAN_CLEAR, TRAFFIC_RETURN };
State state = TRAFFIC_GREEN;
unsigned long stateSince = 0;
bool requested = false;
// Button debouncing, as in Making a Button Behave.
int lastReading = HIGH;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE = 40;
unsigned long lastBeep = 0;
void enter(State next) {
state = next;
stateSince = millis();
digitalWrite(redPin, state == PEDESTRIAN_CROSS || state == PEDESTRIAN_CLEAR);
digitalWrite(amberPin, state == TRAFFIC_AMBER || state == TRAFFIC_RETURN);
digitalWrite(greenPin, state == TRAFFIC_GREEN);
noTone(buzzerPin);
Serial.print("state: ");
switch (state) {
case TRAFFIC_GREEN: Serial.println("traffic green"); break;
case TRAFFIC_AMBER: Serial.println("amber"); break;
case PEDESTRIAN_CROSS: Serial.println("cross"); break;
case PEDESTRIAN_CLEAR: Serial.println("clearing"); break;
case TRAFFIC_RETURN: Serial.println("returning"); break;
}
}
unsigned long elapsed() {
return millis() - stateSince;
}
void readButton() {
int reading = digitalRead(buttonPin);
if (reading != lastReading) {
lastChange = millis();
lastReading = reading;
return;
}
// Act on a settled press, once.
if (millis() - lastChange > DEBOUNCE && reading == LOW && !requested) {
requested = true;
Serial.println(" request noted");
}
}
void beepEvery(unsigned long interval) {
if (millis() - lastBeep >= interval) {
lastBeep = millis();
tone(buzzerPin, 1800, 80);
}
}
void setup() {
pinMode(redPin, OUTPUT);
pinMode(amberPin, OUTPUT);
pinMode(greenPin, OUTPUT);
pinMode(buzzerPin, OUTPUT);
pinMode(buttonPin, INPUT_PULLUP);
Serial.begin(9600);
Serial.println("Siratek: pedestrian crossing.");
enter(TRAFFIC_GREEN);
}
void loop() {
readButton(); // every pass, whatever state we are in
switch (state) {
case TRAFFIC_GREEN:
// Honour the request, but not before traffic has had its minimum.
if (requested && elapsed() >= MINIMUM_GREEN) {
enter(TRAFFIC_AMBER);
}
break;
case TRAFFIC_AMBER:
if (elapsed() >= AMBER_TIME) {
requested = false; // the request has been granted
enter(PEDESTRIAN_CROSS);
}
break;
case PEDESTRIAN_CROSS:
beepEvery(CROSS_BEEP);
if (elapsed() >= CROSS_TIME) {
enter(PEDESTRIAN_CLEAR);
}
break;
case PEDESTRIAN_CLEAR:
beepEvery(CLEAR_BEEP);
if (elapsed() >= CLEAR_TIME) {
enter(TRAFFIC_RETURN);
}
break;
case TRAFFIC_RETURN:
if (elapsed() >= RETURN_TIME) {
enter(TRAFFIC_GREEN);
}
break;
}
}
Why readButton() is called first, every pass
Because the button must work in every state, not only on green. Pressing during the crossing should be noticed and simply have no effect, rather than being missed entirely.
That is also why the request is cleared when the crossing starts, not when the button is read. A press during the amber phase is part of the crossing already under way; a press during the crossing itself sets the flag again and will be honoured next time.
Why tone() and not digitalWrite()
A passive buzzer needs a changing signal, and tone() with a duration is non-blocking: it starts the note and returns immediately. beepEvery() therefore costs nothing in the loop.
Remember that tone() uses Timer2, so PWM on pins 3 and 11 is unavailable. Pin 11 is the buzzer here, which is fine, but it is why the lights are on 8, 9 and 10.
Testing it
Work through these deliberately. Each one checks a decision rather than the wiring.
| Try this | It should |
|---|---|
| Leave it alone | Stay on green indefinitely |
| Press once | Amber, then red with beeping, then back to green |
| Press, then press again during red | Nothing extra; one crossing |
| Press immediately after a crossing ends | Wait for the minimum green, then go |
| Hold the button down | One crossing per minimum-green period, not continuous |
| Press during amber | No effect; the crossing is already starting |
The fifth is the one that catches a careless implementation, and the one a real crossing has to get right.
Making it yours
- A waiting light. A fourth LED lit from the moment the request is noted until the crossing begins. That is what tells a person their press registered, and it is one
digitalWrite()inreadButton()and one inenter(). - A night mode. Add an LDR, and after dark make the amber flash rather than running the full sequence. Measuring Light with an LDR has the threshold work.
- Two directions. A second light module for the other road, red when the first is green. The state machine barely changes;
enter()sets six pins instead of three. - A countdown. A 7-segment display showing the seconds left to cross. Driving a 4-Digit 7-Segment Display covers the multiplexing, and the number comes from
CROSS_TIME - elapsed().
Troubleshooting
| Symptom | Likely cause | Check or fix |
|---|---|---|
| A light stays on in the wrong state | Set outside enter() |
All three are set there, from the state |
| Button does nothing | Not INPUT_PULLUP, or wrong pin |
Stage 1 checks this |
| One press causes several crossings | Not debounced, or the flag not cleared | Clear requested when the crossing starts |
| Holding the button blocks traffic | No minimum green | That comparison is the fix |
| Buzzer silent | Active buzzer, not passive | tone() needs a passive one |
| Buzzer sounds but an LED stopped dimming | tone() has taken Timer2 |
Pins 3 and 11 lose PWM |
| Beeping is irregular | delay() crept back in |
Nothing in loop() may block |
| Sequence drifts over hours | Comparing millis() wrongly |
Always subtract, as elapsed() does |
| Lights dim | All three on at once, or no resistors | The module has its own; check the module tutorial |
| Works, then stops after seven weeks | millis() rollover |
Subtraction survives it; comparison does not |
Sources and attribution
All text, wiring guidance and code in this project are original Siratek work.
- The component behaviour is as described in LED Traffic Light Module, Read a Push Button and Play Tones with a Passive Buzzer, including the module’s onboard current-limiting resistors and the
tone()timer conflict.
The state machine and the timings here are a teaching arrangement, not a specification for a real road crossing, which is a regulated installation with requirements this project does not attempt to meet. No third-party tutorial text, diagrams, photographs or code has been reproduced here.

