Build a goal counter and scoreboard for table football

Two IR sensors in printed holders spot every goal. The 4-digit display shows the score, the buzzer cheers, and a web page on your phone works as a live scoreboard with corrections and the last results.
  • Level:Advanced
  • Duration: 2 h 30 min
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Build a goal counter and scoreboard for table football – EEMENTOR STEM™ lesson

In table football, the score is easy to lose in the excitement. This capstone counts it for you. An IR sensor sits in each goal and sees the ball drop in. The ESP32 adds the goal, the buzzer cheers, and the 4-digit display shows the score, blue on the left and red on the right. The first team to 10 wins: the winning side blinks and a fanfare plays. A web page on your phone shows the score in big numbers, lets you fix a wrong count and start a new game, and remembers the last five results. No table football? Build goals from cardboard for a finger-flick football game or a marble run race.

What you'll learn

  • How to plan a bigger project in stages and test each one
  • How to use an IR sensor as a "beam" that notices a passing ball
  • How to drive a 4-digit display, a buzzer and a web server at the same time
  • How to play a melody without stopping the rest of the program

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • 4-digit 7-segment display, common cathode (ESP32 IoT Starter Kit)
  • 4 resistors, 220 Ω (ESP32 IoT Starter Kit)
  • 2 IR obstacle avoidance sensor modules (45-in-1 Sensor Kit)
  • Passive buzzer (ESP32 IoT Starter Kit)
  • 1 push button (ESP32 IoT Starter Kit)
  • Long female-to-male jumper wires for the goals (about 1 m)
  • A table football game, or two goals made from boxes
  • A 2.4 GHz Wi-Fi network
  • The printed parts from the 3D printing box below (goal sensor holders and scoreboard frame), or the cardboard version described there
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Build a goal counter and scoreboard for table football
Breadboard view. Check each connection against the wiring table below.
From To
Display segments a, b, c, d ESP32 GPIO 23, 22, 21, 19
Display segments e, f, g, dp ESP32 GPIO 18, 5, 17, 16
Display digits D1, D2, D3, D4 220 Ω resistor each, then ESP32 GPIO 25, 26, 27, 14
Blue goal IR sensor OUT ESP32 GPIO 34
Red goal IR sensor OUT ESP32 GPIO 35
Both IR sensors + and GND 3V3 and GND
Passive buzzer + ESP32 GPIO 33
Passive buzzer − GND
Push button, one leg ESP32 GPIO 32
Push button, other leg GND

The display is wired exactly as in the countdown lesson. GPIO 34 and 35 can only read, which is all a sensor needs. "Blue" is the team whose goal is on the left; a ball in the red goal is a point for blue.

Build it in stages

  1. Display. Wire the display first and upload the sketch: it shows 0.0 with the dot between the two scores. If segments look wrong, check the eight segment wires.
  2. Goals. Add one IR sensor. Hold your hand in front of it: its light comes on. Drop a ball past it: the score goes up, and the buzzer beeps. Then add the second one.
  3. Button. Add the button. A short press starts a new game; holding it for two seconds undoes the last goal.
  4. Web page. Open the address from the Serial Monitor on your phone and play a game while watching it.

The sketch

Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).

#include <WiFi.h>
#include <WebServer.h>

const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const int WIN = 10;                         // first to 10 wins

const int SEG_PINS[8] = {23, 22, 21, 19, 18, 5, 17, 16};   // a, b, c, d, e, f, g, dp
const int DIGIT_PINS[4] = {25, 26, 27, 14};
const byte NUMBERS[10] = {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F};
const int GOAL_PINS[2] = {35, 34};          // ball in the red goal = point for blue (team 0)
const int BUZZER_PIN = 33;
const int BUTTON_PIN = 32;

WebServer server(80);
int score[2] = {0, 0};
int lastScorer = -1;
int winner = -1;
unsigned long goalBlockedUntil[2] = {0, 0};
String results[5];
int resultCount = 0;

// ----- display: one digit every 2 ms, never waits -----
byte shown[4];
int current = 0;
unsigned long lastSwitch = 0;
void refresh() {
  if (micros() - lastSwitch < 2000) return;
  lastSwitch = micros();
  digitalWrite(DIGIT_PINS[current], HIGH);
  current = (current + 1) % 4;
  for (int s = 0; s < 8; s++) digitalWrite(SEG_PINS[s], bitRead(shown[current], s));
  digitalWrite(DIGIT_PINS[current], LOW);
}
void updateDisplay() {
  bool hide = (millis() / 300) % 2;          // the winner's side blinks
  for (int team = 0; team < 2; team++) {
    int s = score[team];
    byte tens = s >= 10 ? NUMBERS[s / 10] : 0;
    byte ones = NUMBERS[s % 10];
    if (winner == team && hide) tens = ones = 0;
    shown[team * 2] = tens;
    shown[team * 2 + 1] = ones;
  }
  shown[1] |= 0x80;                          // dot between the two scores
}

// ----- melodies that play while everything else keeps running -----
const int GOAL_TUNE[] = {1047, 80, 1319, 80, 1568, 160};
const int WIN_TUNE[] = {523, 150, 659, 150, 784, 150, 1047, 300, 784, 150, 1047, 500};
const int* tune = nullptr;
int tuneLength = 0, tuneStep = 0;
unsigned long nextNote = 0;
void play(const int* notes, int length) {
  tune = notes;
  tuneLength = length;
  tuneStep = 0;
  nextNote = millis();
}
void music() {
  if (tune == nullptr || millis() < nextNote) return;
  if (tuneStep >= tuneLength) {
    tune = nullptr;
    return;
  }
  tone(BUZZER_PIN, tune[tuneStep], tune[tuneStep + 1]);
  nextNote = millis() + tune[tuneStep + 1] + 30;
  tuneStep += 2;
}

// ----- the game -----
void newGame() {
  if (score[0] + score[1] > 0) {             // remember the result
    for (int i = 4; i > 0; i--) results[i] = results[i - 1];
    results[0] = "Blue " + String(score[0]) + " : " + String(score[1]) + " Red";
    if (resultCount < 5) resultCount++;
  }
  score[0] = score[1] = 0;
  winner = -1;
  lastScorer = -1;
  Serial.println("New game");
}

void goal(int team) {
  if (winner >= 0) return;                   // game over: wait for a new game
  score[team]++;
  lastScorer = team;
  Serial.printf("Goal for %s! %d : %d\n", team == 0 ? "blue" : "red", score[0], score[1]);
  if (score[team] >= WIN) {
    winner = team;
    play(WIN_TUNE, sizeof(WIN_TUNE) / sizeof(int));
  } else {
    play(GOAL_TUNE, sizeof(GOAL_TUNE) / sizeof(int));
  }
}

void undo() {
  if (lastScorer >= 0 && score[lastScorer] > 0) score[lastScorer]--;
  winner = -1;
  lastScorer = -1;
  tone(BUZZER_PIN, 300, 200);
}

void checkGoals() {
  for (int g = 0; g < 2; g++) {
    // the module's OUT goes LOW while something is in front of it
    if (digitalRead(GOAL_PINS[g]) == LOW && millis() > goalBlockedUntil[g]) {
      goalBlockedUntil[g] = millis() + 2000; // one goal per ball, not one per bounce
      goal(g);
    }
  }
}

void checkButton() {
  static unsigned long pressedAt = 0;
  static bool held = false;
  bool down = digitalRead(BUTTON_PIN) == LOW;
  if (down && pressedAt == 0) pressedAt = millis();
  if (down && !held && millis() - pressedAt > 2000) {   // long press: undo
    held = true;
    undo();
  }
  if (!down && pressedAt != 0) {
    if (!held && millis() - pressedAt > 50) newGame();   // short press: new game
    pressedAt = 0;
    held = false;
  }
}

// ----- web page -----
void handleScore() {
  String json = "{\"blue\":" + String(score[0]) + ",\"red\":" + String(score[1]) + ",\"winner\":" + String(winner) + ",\"results\":[";
  for (int i = 0; i < resultCount; i++) {
    if (i > 0) json += ",";
    json += "\"" + results[i] + "\"";
  }
  json += "]}";
  server.send(200, "application/json", json);
}
void handleAction() {
  String a = server.arg("a");
  if (a == "new") newGame();
  if (a == "undo") undo();
  if (a == "blue") goal(0);
  if (a == "red") goal(1);
  server.send(200, "text/plain", "ok");
}

const char PAGE[] = R"html(<!doctype html><meta name=viewport content='width=device-width'><title>Scoreboard</title>
<style>body{font-family:sans-serif;margin:0;padding:20px;text-align:center;background:#12285A;color:#fff}
.s{display:flex;justify-content:center;gap:16px}.t{flex:1;max-width:200px;padding:20px 0;border-radius:20px;font-size:84px;font-weight:bold}
#b{background:#2F6FDB}#r{background:#E63946}.w{outline:6px solid #F2C94C}
button{margin:6px;padding:12px 16px;border:0;border-radius:12px;font-size:16px}li{list-style:none;opacity:.8}</style>
<h1>Table football</h1><div class=s><div class=t id=b>0</div><div class=t id=r>0</div></div><p id=msg></p>
<p><button onclick="go('blue')">+1 Blue</button><button onclick="go('red')">+1 Red</button><button onclick="go('undo')">Undo</button><button onclick="go('new')">New game</button></p>
<h2>Last games</h2><ul id=res></ul>
<script>
function go(a) { fetch('/action?a=' + a).then(tick); }
async function tick() {
  try {
    const d = await (await fetch('/score')).json();
    const b = document.getElementById('b'), r = document.getElementById('r');
    b.textContent = d.blue; r.textContent = d.red;
    b.className = 't' + (d.winner == 0 ? ' w' : ''); r.className = 't' + (d.winner == 1 ? ' w' : '');
    document.getElementById('msg').textContent = d.winner == 0 ? 'Blue wins!' : d.winner == 1 ? 'Red wins!' : 'First to 10 wins';
    document.getElementById('res').innerHTML = d.results.map(x => '<li>' + x + '</li>').join('');
  } catch (e) {}
}
tick(); setInterval(tick, 1000);
</script>)html";

void setup() {
  Serial.begin(115200);
  for (int s = 0; s < 8; s++) pinMode(SEG_PINS[s], OUTPUT);
  for (int d = 0; d < 4; d++) {
    pinMode(DIGIT_PINS[d], OUTPUT);
    digitalWrite(DIGIT_PINS[d], HIGH);
  }
  pinMode(GOAL_PINS[0], INPUT);
  pinMode(GOAL_PINS[1], INPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.print(" connected. Open http://");
  Serial.println(WiFi.localIP());
  server.on("/", []() { server.send(200, "text/html", PAGE); });
  server.on("/score", handleScore);
  server.on("/action", handleAction);
  server.begin();
}

void loop() {
  server.handleClient();
  checkGoals();
  checkButton();
  music();
  updateDisplay();
  refresh();
}

Play

Open the Serial Monitor at 115200 baud: after connecting, the ESP32 prints its address, such as http://192.168.1.42. Open it in a browser on a phone or computer in the same Wi-Fi.

  1. Short-press the button to start a new game. The display shows 0.0.
  2. Every ball that drops into a goal adds a point for the other team, with a three-note cheer. The web page updates within a second.
  3. If a goal was counted wrongly, hold the button for two seconds, or tap Undo on the page. The page's +1 buttons add a goal the sensors missed.
  4. At 10 goals the winner's score blinks and a fanfare plays. The result moves into "Last games" when you start the next game.

How it works

The IR obstacle sensor shines invisible infrared light forward and watches for it to bounce back. A ball passing in front reflects the light, and the module's OUT pin goes LOW for a moment. A ball can bounce around in the goal, so after a goal that sensor is ignored for two seconds: one ball, one point.

The ESP32 has five jobs at once: refresh the display, watch two goals, watch the button, play music and answer the web page. None of them may wait, or the display would flicker and goals would be missed. So each job is a small function that checks millis() or micros(), does a tiny piece of work if it is time, and returns at once. loop() calls them all, thousands of times a second. The tunes are stored as pairs of numbers, frequency and length, and music() starts the next note only when the previous one is over.

The button tells a short press from a long one by remembering when it was pressed: held for over two seconds means undo; let go before that means a new game. The 50 ms minimum ignores contact bounce.

The page asks /score once a second and gets the score, the winner and the last results as JSON. Its buttons call /action, which uses the same goal(), undo() and newGame() as the sensors and the button.

Try this next

  • Let players type their names on the web page and show them in the results.
  • Measure how fast each shot was, using the time between the ball passing two sensors.
  • Add a "golden goal" mode: at 9 : 9, the next goal wins.

Troubleshooting

  • Goals are counted with no ball: the sensor sees the goal's back wall. Turn its screw anticlockwise until the light only comes on very close, or angle the holder.
  • A ball goes in but nothing is counted: the ball passes too fast or too far away. Move the sensor closer to where the ball drops, and turn the screw clockwise.
  • The display flickers when a goal is scored: check that nothing in the sketch uses delay().
  • The scores swap sides: swap the two numbers in GOAL_PINS.
  • It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
  • A printed part is too tight or too loose: printers differ by a few tenths of a millimetre. Sand or file a tight hole, and fix a loose part with a drop of hot glue or a strip of tape.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit