Community project
Stadium Attendance Counter
This project builds a ticket-scanning entry gate system for stadium attendance tracking. Using an ESP32 microcontroller, a barcode scanner reads tickets, an OLED display shows real-time status, and a servo motor controls a demonstration barrier. The system validates each ticket against a database of pre-scanned entries, lights up green for accepted attendees or red for duplicates, and sounds a buzzer to confirm each scan.
The guide includes a complete wiring diagram connecting the barcode scanner, OLED screen, servo barrier, status LEDs, buzzer, and reset button to the ESP32. You'll get a full parts list, step-by-step assembly instructions for mounting components in a protected test lane, and ready-to-upload firmware that manages ticket validation, attendance counting, and barrier control. A push button lets operators reset the system for testing or new events.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Mount the parts in a protected test lane
Place the ESP32, scanner, screen, lights, buzzer, and small servo behind the entry line in a dry enclosure. Attach a lightweight test arm to the servo horn; do not use this small servo to move a real stadium barrier.
- Keep the scanner window facing the ticket and shade it from direct sunlight so phone screens and printed codes read reliably.
- Do not use this hobby servo as a crowd-safety barrier; a real public entrance needs a certified gate or turnstile with its own safety controls.
2. Wire the ticket reader and screen
With power disconnected, connect scanner VCC to ESP32 3V3 (power), scanner GND to ESP32 GND (ground), and scanner TX to GPIO16 (ticket data). Connect OLED VCC to 3V3 (power), OLED GND to GND (ground), OLED SDA to GPIO21 (display data), and OLED SCL to GPIO22 (display clock).
- Use a reader explicitly labelled 3.3 V TTL. A 5 V TX output can damage the ESP32 input unless a level shifter is fitted.
- Make sure VCC and GND are not swapped — swapped power can damage the scanner or screen.
3. Wire the accepted and rejected lights
Connect GPIO18 to one leg of green_resistor_1, then connect the other resistor leg to the long leg of green_led_1; connect the green LED short leg to GND. Connect GPIO19 to one leg of red_resistor_1, then connect the other resistor leg to the long leg of red_led_1; connect the red LED short leg to GND.
- The LED long leg is positive. Each LED needs its own 220 ohm resistor so it does not burn out.
- Do not connect either LED directly to a GPIO; without its resistor the LED and the ESP32 output can be damaged.
4. Wire the sounder and service reset button
Connect buzzer SIGNAL to GPIO26 (sound control) and buzzer GND to GND (ground). Connect one reset_button_1 terminal to GPIO27 (service-button signal) and the other terminal to GND (ground). Keep this reset button inside the staff enclosure.
- Holding the service button for five seconds clears the local count and used-ticket list, so label it clearly and restrict access.
- A public-facing reset button could erase attendance records, so never leave it where attendees can press it.
5. Connect the demonstration barrier motor
Connect the servo signal wire to GPIO25 (barrier control). Connect the servo red wire to lane_supply_1 +5V (motor power), the servo brown or black wire to GND, and connect lane_supply_1 GND to the ESP32 GND (shared ground).
- Use a regulated 5 V adapter rated for at least 2 A. The motor supply ground and ESP32 ground must meet, otherwise the servo cannot read its control signal reliably.
- Never power the servo from the ESP32 3V3 pin; moving it can overload the board and make scans fail.
6. Set up and test the controlled entry process
Power the ESP32 from its USB connector, then plug in the separate 5 V servo adapter. Press Deploy in Schematik. Test a unique printed code such as STAD-00001: the green light, beep, counter increase, and short barrier movement mean one entry was recorded. Scan the same code again: the red light and no count increase show duplicate blocking.
- For a real venue, retain each signed scan event in one central server with ticket ID, gate ID, operator/device ID, and time. Compare the central accepted-entry total with payment settlement, not only this pilot's local display.
- Do not rely on separate local counts at multiple gates for revenue reconciliation; each gate must check and write to one shared, durable central ticket database.
Review all connections
1. Connections between "scanner_1" and "ESP32"
2. Connections between "display_1" and "ESP32"
3. Connections between "lane_supply_1" and "ESP32"
4. Connections between "barrier_servo_1" and "ESP32"
5. Connections between "green_resistor_1" and "ESP32"
6. Connections between "green_led_1" and "ESP32"
7. Connections between "red_resistor_1" and "ESP32"
8. Connections between "red_led_1" and "ESP32"
9. Connections between "buzzer_1" and "ESP32"
10. Connections between "reset_button_1" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ESP32Servo.h>
#include <Preferences.h>
// Forward declarations
bool isTicketUsed(uint16_t ticketNumber);
void setTicketUsed(uint16_t ticketNumber);
void saveAttendance();
void drawScreen();
void beep(uint16_t frequency, uint16_t durationMs);
void showResult(const String &message, bool accepted);
void openBarrier();
bool parseTicket(const String &raw, uint16_t &ticketNumber);
void processTicket(const String &raw);
void clearAttendance();
constexpr int SCANNER_RX_PIN = 16;
constexpr int OLED_SDA_PIN = 21;
constexpr int OLED_SCL_PIN = 22;
constexpr int SERVO_PIN = 25;
constexpr int BUZZER_PIN = 26;
constexpr int RESET_BUTTON_PIN = 27;
constexpr int GREEN_LED_PIN = 18;
constexpr int RED_LED_PIN = 19;
constexpr uint32_t SCANNER_BAUD = 9600;
constexpr uint16_t STADIUM_CAPACITY = 20000;
constexpr size_t USED_BYTES = (STADIUM_CAPACITY + 7) / 8;
constexpr uint32_t GATE_OPEN_MS = 1800;
constexpr uint32_t RESET_HOLD_MS = 5000;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Servo barrier;
Preferences preferences;
uint8_t usedTickets[USED_BYTES] = {};
uint16_t attendeeCount = 0;
String scanBuffer;
bool barrierOpen = false;
uint32_t barrierCloseAt = 0;
uint32_t resetPressedAt = 0;
String statusLine = "Ready to scan";
bool isTicketUsed(uint16_t ticketNumber) {
uint16_t index = ticketNumber - 1;
return (usedTickets[index / 8] & (1 << (index % 8))) != 0;
}
void setTicketUsed(uint16_t ticketNumber) {
uint16_t index = ticketNumber - 1;
usedTickets[index / 8] |= (1 << (index % 8));
}
void saveAttendance() {
preferences.putUShort("count", attendeeCount);
preferences.putBytes("used", usedTickets, USED_BYTES);
}
void drawScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.println("STADIUM ENTRY - GATE 1");
display.drawLine(0, 10, 127, 10, SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(0, 17);
display.printf("%u", attendeeCount);
display.setTextSize(1);
display.print(" / ");
display.println(STADIUM_CAPACITY);
display.setCursor(0, 43);
display.println(statusLine);
display.display();
}
void beep(uint16_t frequency, uint16_t durationMs) {
tone(BUZZER_PIN, frequency, durationMs);
}
void showResult(const String &message, bool accepted) {
statusLine = message;
digitalWrite(GREEN_LED_PIN, accepted ? HIGH : LOW);
digitalWrite(RED_LED_PIN, accepted ? LOW : HIGH);
beep(accepted ? 1600 : 300, accepted ? 100 : 350);
drawScreen();
}
void openBarrier() {
barrier.write(90);
barrierOpen = true;
barrierCloseAt = millis() + GATE_OPEN_MS;
}
bool parseTicket(const String &raw, uint16_t &ticketNumber) {
String code = raw;
code.trim();
if (code.length() != 10 || !code.startsWith("STAD-")) {
return false;
}
for (uint8_t i = 5; i < 10; i++) {
if (!isDigit(code[i])) {
return false;
}
}
long number = code.substring(5).toInt();
if (number < 1 || number > STADIUM_CAPACITY) {
return false;
}
ticketNumber = static_cast<uint16_t>(number);
return true;
}
void processTicket(const String &raw) {
uint16_t ticketNumber = 0;
if (!parseTicket(raw, ticketNumber)) {
showResult("INVALID TICKET", false);
return;
}
if (isTicketUsed(ticketNumber)) {
showResult("ALREADY USED", false);
return;
}
if (attendeeCount >= STADIUM_CAPACITY) {
showResult("VENUE FULL", false);
return;
}
setTicketUsed(ticketNumber);
attendeeCount++;
saveAttendance();
showResult("ENTRY ACCEPTED", true);
openBarrier();
Serial.printf("ACCEPTED,gate=1,ticket=%05u,count=%u\n", ticketNumber, attendeeCount);
}
void clearAttendance() {
memset(usedTickets, 0, USED_BYTES);
attendeeCount = 0;
saveAttendance();
statusLine = "COUNT CLEARED";
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(RED_LED_PIN, LOW);
beep(1000, 150);
drawScreen();
Serial.println("ADMIN_RESET,gate=1,count=0");
}
void setup() {
Serial.begin(115200);
Serial2.begin(SCANNER_BAUD, SERIAL_8N1, SCANNER_RX_PIN, -1);
pinMode(GREEN_LED_PIN, OUTPUT);
pinMode(RED_LED_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
pinMode(RESET_BUTTON_PIN, INPUT_PULLUP);
digitalWrite(GREEN_LED_PIN, LOW);
digitalWrite(RED_LED_PIN, LOW);
preferences.begin("stadium", false);
attendeeCount = preferences.getUShort("count", 0);
if (preferences.getBytesLength("used") == USED_BYTES) {
preferences.getBytes("used", usedTickets, USED_BYTES);
} else {
memset(usedTickets, 0, USED_BYTES);
saveAttendance();
}
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED initialization failed");
}
barrier.setPeriodHertz(50);
barrier.attach(SERVO_PIN, 500, 2400);
barrier.write(0);
drawScreen();
Serial.println("Gate 1 ready. Ticket format: STAD-00001 to STAD-20000");
}
void loop() {
while (Serial2.available()) {
char received = static_cast<char>(Serial2.read());
if (received == '\r' || received == '\n') {
if (scanBuffer.length() > 0) {
processTicket(scanBuffer);
scanBuffer = "";
}
} else if (scanBuffer.length() < 32) {
scanBuffer += received;
}
}
if (barrierOpen && millis() >= barrierCloseAt) {
barrier.write(0);
barrierOpen = false;
digitalWrite(GREEN_LED_PIN, LOW);
statusLine = "Ready to scan";
drawScreen();
}
if (digitalRead(RESET_BUTTON_PIN) == LOW) {
if (resetPressedAt == 0) {
resetPressedAt = millis();
} else if (millis() - resetPressedAt >= RESET_HOLD_MS) {
clearAttendance();
while (digitalRead(RESET_BUTTON_PIN) == LOW) {
delay(10);
}
resetPressedAt = 0;
}
} else {
resetPressedAt = 0;
}
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




