Build a Live World Cup Scoreboard
Build a polling-then-SSE server that streams live football scores to browsers.
Introduction
30 Second Summary
Every time you check a live score on your phone during a football match, a server somewhere is pushing that update to your screen the instant it happens. That seamless, real-time experience relies on a specific architecture pattern that professional sports apps use every day.
In this project, you will build a live FIFA World Cup 2026 scoreboard that polls an external football API for score data and pushes updates to connected browsers using Server-Sent Events.
What You'll Build
A Node.js server with three layers: a poller that queries API-Football for live match data, a cache/diff engine that detects score changes, and an SSE endpoint that streams only the updates to all connected browsers.
By the end of this project, you'll have:
- A polling-then-SSE server that queries API-Football every 60 seconds, detects score changes through an in-memory cache, and pushes only the differences to all connected browsers.
- A change detection layer that compares incoming data against cached state and generates typed events (goal, status change, new match) only when something actually changes.
- A responsive frontend that displays live scores with real-time updates, goal flash animations, and automatic reconnection using the EventSource API.
- Secret Mission: Add goal audio notifications using Web Audio API and client-side group/team filtering with localStorage persistence.
Are there any prerequisites?
You should be comfortable with JavaScript and Node.js basics (writing functions, using npm, reading JSON). You should also understand how HTTP requests and responses work at a high level.
Set Up the Project
With your API-Football key ready, you have the data source for your live scoreboard. The next piece is the server that sits between that API and your browser.
In this step, you'll scaffold a Node.js project with Express, create the project file structure, and get a basic server running at localhost:3000.
In this step, get ready to:
- Initialize a Node.js project and install Express.
- Create the project file structure with a static HTML page.
- Configure and start the Express server on port 3000.
Initialize the Node.js project
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir live-scoreboard
cd live-scoreboard
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to skip all the interactive questions and create a package.json file with sensible defaults. This file tracks your project's metadata and dependencies.
- You should see output confirming that package.json was created, including a JSON object with name, version, and other fields.
- Install Express as your project's web framework by running:
npm install express@4.19.2
Why Express?
Express is a minimal web framework for Node.js. It handles HTTP routing, serves static files, and will host your SSE endpoint later. The @4.19.2 suffix pins the exact version so your project stays reproducible.
- You should see output ending with added 1 package (or similar) confirming Express was installed.
Seeing a network error?
If you see ETIMEDOUT or ENOTFOUND, check your internet connection and try running the install command again. If you're behind a corporate proxy, you may need to configure npm's proxy settings.
help me fix an npm install network error
Create the project files
Your project needs a handful of files: a server script, an environment config, and a public/ folder for the frontend assets the browser will load.
- Create the folder structure and empty files by running these commands:
mkdir public
touch .env server.js public/index.html public/style.css
- Confirm the files exist by running:
ls
- You should see node_modules, package.json, package-lock.json, .env, and server.js in the output.
touch command not found?
On Windows without Git Bash, touch may not be available. Use this alternative instead:
New-Item .env, server.js, public/index.html, public/style.css -ItemType File
help me create files on Windows without the touch command
Now open the project in VS Code so you can edit these files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the live-scoreboard folder, and click Open.
- In the VS Code Explorer sidebar, click on .env to open it.
- Add your API-Football key to the file by pasting this line, replacing your-api-key-here with the actual key from your API-Football dashboard:
API_FOOTBALL_KEY=your-api-key-here
Why a .env file?
Storing your API key in a separate .env file keeps credentials out of your main code. This is a security best practice. If you ever share your code, the .env file stays private.
- Save .env with Cmd+S (macOS) or Ctrl+S (Windows).
- In the Explorer sidebar, click on public/index.html to open it.
- Paste the following HTML into the file:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Copa do Mundo 2026 - Placar Ao Vivo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Copa do Mundo FIFA 2026</h1>
</body>
</html>
What does this HTML do?
This is a minimal HTML page that loads style.css and displays a heading. It acts as a placeholder to confirm your server can serve static files. You'll expand it into the full scoreboard interface in a later step.
- Save public/index.html.
- In the Explorer sidebar, click on public/style.css to open it.
- Add this placeholder comment:
/* styles will be added in a later step */
- Save public/style.css.
Configure and start the Express server
The server file is the heart of your application. It creates an Express app that serves the static files from public/ and reads your API key from the environment.
- In the Explorer sidebar, click on server.js to open it.
- Paste the following code into server.js:
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
const API_KEY = process.env.API_FOOTBALL_KEY || 'your-api-key-here';
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
});
What does this code do?
- express and path are imported. Express handles HTTP requests. Path helps build file paths that work on any operating system.
- API_KEY reads from the environment variable you set in .env. The fallback string is only used if the variable is missing.
- express.static() tells Express to serve any file inside the public/ folder directly to browsers. When someone visits /, they get index.html.
- app.listen() starts the server on port 3000 and logs a confirmation message.
- Save server.js.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Start the server by running:
API_FOOTBALL_KEY=your-api-key-here node server.js
Why pass the key inline?
Node.js does not automatically load .env files. Passing the variable inline before the node command sets it for that session. In the next step you'll add polling logic that uses this key to call the API.
- You should see this message in your terminal:
Live Scoreboard running at http://localhost:3000
- Open your browser and navigate to http://localhost:3000.
- You should see the heading "Copa do Mundo FIFA 2026" displayed on the page.
Don't see the heading in your browser?
Make sure server.js is saved and the terminal shows the "Live Scoreboard running" message. If you see an error about the port being in use, another process is already using port 3000. Stop it or change PORT to 3001 in server.js.
help me troubleshoot my Express server not serving files
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
.env
API_FOOTBALL_KEY=your-api-key-here
server.js
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
const API_KEY = process.env.API_FOOTBALL_KEY || 'your-api-key-here';
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
});
public/index.html
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Copa do Mundo 2026 - Placar Ao Vivo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Copa do Mundo FIFA 2026</h1>
</body>
</html>
public/style.css
/* styles will be added in a later step */
Your project skeleton is running. Next up, you'll build the polling layer that fetches live match data from API-Football every 60 seconds.
Build the Polling Layer
Your server is running and serving static files. Now it needs a way to actively fetch live match data from the outside world.
In this step, you will build the polling layer. This is the part of the system that reaches out to the API-Football endpoint on a timer, pulls down the latest live scores, and formats them into a clean internal structure your server can work with.
In this step, get ready to:
- Add API constants and a data normalizer that transforms raw API responses into a clean format.
- Build an async polling function that fetches live scores with error handling.
- Wire up automatic polling every 60 seconds on server start.
Add the API constants and data normalizer
The API-Football response contains deeply nested objects with more data than your scoreboard needs. A normalizer function extracts only the fields that matter and returns them in a flat, predictable shape.
- In server.js, find the line that starts with const API_KEY. Add these two constants directly below it:
const API_BASE = 'https://v3.football.api-sports.io';
const POLL_INTERVAL = 60000; // 60 seconds
- Below the app.use(express.static(...)) line, add the normalizeFixtures function:
// --- Normalize API response ---
function normalizeFixtures(apiResponse) {
if (!apiResponse || !apiResponse.response) return [];
return apiResponse.response.map(item => ({
fixtureId: item.fixture.id,
homeTeam: item.teams.home.name,
awayTeam: item.teams.away.name,
homeLogo: item.teams.home.logo,
awayLogo: item.teams.away.logo,
homeGoals: item.goals.home,
awayGoals: item.goals.away,
elapsed: item.fixture.status.elapsed,
status: item.fixture.status.short,
statusLong: item.fixture.status.long,
league: item.league.name,
round: item.league.round
}));
}
What does this code do?
The two constants configure where and how often to poll:
- API_BASE stores the base URL for API-Football v3.
- POLL_INTERVAL sets a 60-second delay between polls. The free plan allows 100 requests per day, so 60 seconds gives you about an hour of live monitoring per session.
The normalizeFixtures function maps each fixture in the API response to a flat object. It extracts team names, logos, goals, match elapsed time, and status. If the API returns nothing or the response array is missing, it safely returns an empty array.
- Save server.js.
You will not see a visible change yet. The normalizer will be called by the polling function you build next.
Implement the polling function
The polling function is the engine of your live scoreboard. It makes an HTTP request to API-Football, handles errors gracefully, and logs what it finds.
- In server.js, add the pollLiveScores function directly below normalizeFixtures:
// --- Polling ---
async function pollLiveScores() {
try {
const response = await fetch(`${API_BASE}/fixtures?live=all`, {
headers: { 'x-apisports-key': API_KEY }
});
if (!response.ok) {
console.error(`API error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const fixtures = normalizeFixtures(data);
console.log(`[${new Date().toISOString()}] Polled ${fixtures.length} live fixtures`);
} catch (error) {
console.error(`[${new Date().toISOString()}] Poll error:`, error.message);
}
}
What does this code do?
- fetch() calls the /fixtures?live=all endpoint with the x-apisports-key header to authenticate. Node.js 22 includes fetch natively, so no extra package is needed.
- If the HTTP status is not OK (e.g. rate limited or server error), the function logs the status and returns early instead of crashing.
- On success, it normalizes the response and logs how many live fixtures were found.
- The outer try/catch handles network failures like DNS errors or timeouts so the server keeps running even if a single poll fails.
- Save server.js.
You will not see output yet because nothing is calling this function. The next substep connects it to the server startup.
Seeing a red underline on fetch?
If your editor flags fetch as undefined, your editor's TypeScript settings might not recognize Node.js 22's built-in globals. The code will still run correctly. You can ignore the warning.
help me understand why my editor shows fetch as undefined in Node.js
Start polling on server launch
The polling function exists but nothing triggers it. You need to call it once immediately when the server starts, then repeat it every 60 seconds.
- In server.js, find the app.listen block at the bottom of your file. It currently looks like this:
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
});
- Add two lines after the console.log line, so the block looks like this:
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
pollLiveScores();
setInterval(pollLiveScores, POLL_INTERVAL);
});
What does this code do?
- pollLiveScores() fires immediately so you see results as soon as the server starts, without waiting 60 seconds.
- setInterval(pollLiveScores, POLL_INTERVAL) repeats the poll every 60 seconds after that.
- Save server.js.
- Stop your server if it is running by pressing Ctrl+C in the terminal.
- Restart the server by running:
API_FOOTBALL_KEY=your-api-key-here node server.js
You should see output similar to this in your terminal:
Live Scoreboard running at http://localhost:3000
[2026-06-19T14:30:00.000Z] Polled 0 live fixtures
If there are live matches happening right now, the number will be greater than 0. If no matches are live, you will see Polled 0 live fixtures. Both are correct.
Seeing an API error in the terminal?
If you see API error: 403, your API key is incorrect or missing. Double-check that the value in your .env file matches the key from your API-Football dashboard (Account > My Access).
If you see Poll error: fetch failed, check your internet connection. The server will automatically retry on the next 60-second interval.
help me debug my API-Football polling errors
Why poll every 60 seconds?
API-Football updates live match data every 15 seconds. The free plan gives you 100 requests per day. At one request every 60 seconds, you can monitor live matches for about 100 minutes before hitting the daily limit.
This is a deliberate tradeoff: you accept slightly delayed updates (up to 60 seconds behind real time) in exchange for staying within the free tier.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and your terminal shows the polled fixtures log message on startup.
ⓧ I'd like to double check the full code
Here is the complete server.js at this point:
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
const API_KEY = process.env.API_FOOTBALL_KEY || 'your-api-key-here';
const API_BASE = 'https://v3.football.api-sports.io';
const POLL_INTERVAL = 60000;
app.use(express.static(path.join(__dirname, 'public')));
function normalizeFixtures(apiResponse) {
if (!apiResponse || !apiResponse.response) return [];
return apiResponse.response.map(item => ({
fixtureId: item.fixture.id,
homeTeam: item.teams.home.name,
awayTeam: item.teams.away.name,
homeLogo: item.teams.home.logo,
awayLogo: item.teams.away.logo,
homeGoals: item.goals.home,
awayGoals: item.goals.away,
elapsed: item.fixture.status.elapsed,
status: item.fixture.status.short,
statusLong: item.fixture.status.long,
league: item.league.name,
round: item.league.round
}));
}
async function pollLiveScores() {
try {
const response = await fetch(`${API_BASE}/fixtures?live=all`, {
headers: { 'x-apisports-key': API_KEY }
});
if (!response.ok) {
console.error(`API error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const fixtures = normalizeFixtures(data);
console.log(`[${new Date().toISOString()}] Polled ${fixtures.length} live fixtures`);
} catch (error) {
console.error(`[${new Date().toISOString()}] Poll error:`, error.message);
}
}
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
pollLiveScores();
setInterval(pollLiveScores, POLL_INTERVAL);
});
Your polling layer is working. The server now reaches out to API-Football every 60 seconds and logs what it finds. Next up, you will add a cache layer that detects when scores actually change, so you only push real updates to connected browsers.
Build the Cache and Change Detection Layer
Your poller fetches fresh data from the API every 60 seconds, but right now it just logs everything it receives.
The problem is that most of the time, nothing changes between polls. If you blindly push the full state to every connected browser on every poll, you waste bandwidth and flood clients with duplicate data. The solution is a change detection layer that compares new data against a cache, identifies what actually changed (a goal, a status update), and only pushes the differences.
In this step, get ready to:
- Create an in-memory cache that stores the last known state of each live match.
- Build a change detection function that compares new data against the cache and generates typed events.
- Integrate the detector into your polling loop so only real changes trigger downstream actions.
Implement the in-memory cache and change detection function
A Map is the ideal data structure here because it allows O(1) lookups by fixture ID. Each time the poller runs, you compare the incoming data against what the Map stores from the previous poll.
- In server.js, find the line const POLL_INTERVAL = 60000; and add the cache declaration directly below it:
const cache = new Map();
- Now add the detectChanges function below the normalizeFixtures function by copying this code:
// --- Change detection ---
function detectChanges(fixtures) {
const events = [];
for (const fixture of fixtures) {
const cached = cache.get(fixture.fixtureId);
if (!cached) {
// First time seeing this match - add to cache
cache.set(fixture.fixtureId, { ...fixture });
events.push({ type: 'new_match', data: fixture });
continue;
}
// Check if goals changed
if (cached.homeGoals !== fixture.homeGoals || cached.awayGoals !== fixture.awayGoals) {
events.push({ type: 'goal', data: fixture });
}
// Check if match status changed (e.g. 1H -> HT)
if (cached.status !== fixture.status) {
events.push({ type: 'status_change', data: fixture });
}
// Update cache with latest state
cache.set(fixture.fixtureId, { ...fixture });
}
return events;
}
What does this code do?
The detectChanges function iterates through every fixture returned by the API and compares it against what the cache holds.
- If a fixture ID is not in the cache yet, it is a new match. The function stores it and emits a new_match event.
- If the home or away goals differ from the cached values, it emits a goal event.
- If the match status code changed (e.g. from 1H to HT), it emits a status_change event.
- After comparison, the cache is always updated with the latest state using the spread operator.
Why detect changes instead of pushing everything?
Professional live score systems serve thousands of concurrent clients. Pushing the full state on every poll (even when nothing changed) multiplies bandwidth by the number of connected clients. Diffing at the server means each client only receives a tiny event payload when something meaningful happens.
Integrate change detection into the polling loop
The detector exists but the poller does not call it yet. You need to wire them together so the flow becomes: poll, normalize, detect changes, broadcast.
- First, add two placeholder functions below detectChanges. These will be fully implemented in the next step when you build the SSE endpoint:
function broadcast(event) {
console.log('broadcast placeholder:', event.type);
}
function broadcastFullState(fixtures) {
console.log('broadcastFullState placeholder, fixtures:', fixtures.length);
}
- Now replace your existing pollLiveScores function with this updated version that calls the change detection and broadcast functions:
async function pollLiveScores() {
try {
const response = await fetch(`${API_BASE}/fixtures?live=all`, {
headers: { 'x-apisports-key': API_KEY }
});
if (!response.ok) {
console.error(`API error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const fixtures = normalizeFixtures(data);
console.log(`[${new Date().toISOString()}] Polled ${fixtures.length} live fixtures`);
// Detect what changed since last poll
const changes = detectChanges(fixtures);
if (changes.length > 0) {
console.log(`[${new Date().toISOString()}] Detected ${changes.length} changes`);
for (const change of changes) {
broadcast(change);
}
}
// Always broadcast full state for reference
broadcastFullState(fixtures);
} catch (error) {
console.error(`[${new Date().toISOString()}] Poll error:`, error.message);
}
}
What does this code do?
- After normalizing fixtures, the poller passes them through detectChanges(fixtures) which returns only the events that represent actual differences.
- If changes exist, each one is passed to broadcast() individually. This keeps events granular so clients can handle each type differently.
- broadcastFullState(fixtures) is called unconditionally so newly connected clients always receive the latest complete snapshot.
- Save server.js.
- Restart your server by pressing Ctrl+C in the terminal to stop it, then run:
node server.js
- Watch the terminal output. On the first poll, every fixture appears as a new_match event because the cache starts empty. On the second poll (60 seconds later), you should see 0 changes if no scores changed in between.
Not seeing any change detection output?
- If there are no live matches at this moment, the API returns an empty array. You will see "Polled 0 live fixtures" and no change events. This is correct behavior.
- Make sure the detectChanges function is placed between normalizeFixtures and pollLiveScores in your file. If it is defined after pollLiveScores, the function will not be callable when the poller runs.
- Check that you replaced the entire pollLiveScores function and did not accidentally leave the old version alongside the new one.
Help me debug why my change detection isn't logging output.
Verify diff detection with a simulated change
If there are no live matches right now, you can verify your diff logic works by manually pre-loading the cache with stale data. This forces the next poll to see a "change" between the cached state and what the API returns.
- In server.js, find the line const cache = new Map(); and temporarily add a fake entry directly below it to simulate an old cached score:
// TEMPORARY: simulate a stale cache entry to test diff detection
cache.set(99999, { fixtureId: 99999, homeTeam: 'Test A', awayTeam: 'Test B', homeGoals: 0, awayGoals: 0, status: '1H' });
What does this test prove?
By seeding the cache with a fixture ID that does not exist in the live API response, the next poll will not include fixture 99999. Meanwhile, any real live fixtures the API returns will appear as new_match events because they are not yet in the cache. This confirms the "not in cache" branch works.
- Save server.js and restart the server.
- Watch the terminal. If there are live matches, you should see "Detected N changes" with broadcast placeholder: new_match logged for each one.
- Once confirmed, remove the temporary cache.set(99999, ...) line you added. Save server.js again.
Still not seeing change events?
- If "Polled 0 live fixtures" appears, no matches are happening right now. The diff logic is still correct. It will emit events as soon as there are live fixtures to compare.
- Check that you are not hitting the 100 requests/day limit. If the API returns a 429 status, wait until the next day or check your dashboard at dashboard.api-football.com to see remaining requests.
Help me verify my change detection is working correctly.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and removed the temporary test line.
ⓧ I'd like to double check the full code
Here is the complete server.js at this point in the project:
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
const API_KEY = process.env.API_FOOTBALL_KEY || 'your-api-key-here';
const API_BASE = 'https://v3.football.api-sports.io';
const POLL_INTERVAL = 60000;
const cache = new Map();
app.use(express.static(path.join(__dirname, 'public')));
function normalizeFixtures(apiResponse) {
if (!apiResponse || !apiResponse.response) return [];
return apiResponse.response.map(item => ({
fixtureId: item.fixture.id,
homeTeam: item.teams.home.name,
awayTeam: item.teams.away.name,
homeLogo: item.teams.home.logo,
awayLogo: item.teams.away.logo,
homeGoals: item.goals.home,
awayGoals: item.goals.away,
elapsed: item.fixture.status.elapsed,
status: item.fixture.status.short,
statusLong: item.fixture.status.long,
league: item.league.name,
round: item.league.round
}));
}
function detectChanges(fixtures) {
const events = [];
for (const fixture of fixtures) {
const cached = cache.get(fixture.fixtureId);
if (!cached) {
cache.set(fixture.fixtureId, { ...fixture });
events.push({ type: 'new_match', data: fixture });
continue;
}
if (cached.homeGoals !== fixture.homeGoals || cached.awayGoals !== fixture.awayGoals) {
events.push({ type: 'goal', data: fixture });
}
if (cached.status !== fixture.status) {
events.push({ type: 'status_change', data: fixture });
}
cache.set(fixture.fixtureId, { ...fixture });
}
return events;
}
function broadcast(event) {
console.log('broadcast placeholder:', event.type);
}
function broadcastFullState(fixtures) {
console.log('broadcastFullState placeholder, fixtures:', fixtures.length);
}
async function pollLiveScores() {
try {
const response = await fetch(`${API_BASE}/fixtures?live=all`, {
headers: { 'x-apisports-key': API_KEY }
});
if (!response.ok) {
console.error(`API error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const fixtures = normalizeFixtures(data);
console.log(`[${new Date().toISOString()}] Polled ${fixtures.length} live fixtures`);
const changes = detectChanges(fixtures);
if (changes.length > 0) {
console.log(`[${new Date().toISOString()}] Detected ${changes.length} changes`);
for (const change of changes) {
broadcast(change);
}
}
broadcastFullState(fixtures);
} catch (error) {
console.error(`[${new Date().toISOString()}] Poll error:`, error.message);
}
}
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
pollLiveScores();
setInterval(pollLiveScores, POLL_INTERVAL);
});
Your server now knows exactly when a goal is scored or a match status changes. Next up, you will build the SSE endpoint that pushes these events to every connected browser in real time.
Build the SSE Endpoint
Your poller detects changes and your diff layer identifies goals and status updates. Right now, though, those events just get logged to the console. You need a way to push them to every connected browser in real time.
Server-Sent Events (SSE) lets the server hold an HTTP connection open and stream data to the client whenever it wants. In this step, you'll build the SSE endpoint that delivers score updates to every connected browser simultaneously.
In this step, get ready to:
- Replace the placeholder broadcast functions with real SSE message delivery.
- Create the /api/scores/stream endpoint with proper SSE headers and client management.
- Add heartbeat and reconnection support for reliable delivery.
Add SSE state and implement broadcast functions
The broadcast system needs shared state: an incrementing counter for event IDs, a bounded buffer to replay missed events on reconnection, and a registry of all connected clients.
- In server.js, find the line const cache = new Map(); and add these four lines directly below it:
let eventIdCounter = 0;
const eventBuffer = [];
const EVENT_BUFFER_MAX = 100;
const clients = new Set();
What do these variables do?
- eventIdCounter gives each SSE message a unique, incrementing ID. Clients use this to tell the server which events they missed during a disconnection.
- eventBuffer stores the last 100 messages so the server can replay them for reconnecting clients.
- clients is a Set that tracks every active SSE connection. When a change is detected, the server iterates this set to push data to all browsers.
Now you'll replace the two placeholder broadcast functions with real implementations that format SSE messages and write them to all connected clients.
- Find the placeholder broadcast function (the one that logs 'broadcast placeholder:') and the broadcastFullState function below it. Replace both functions with this code:
// Send a single change event to all connected clients
function broadcast(event) {
eventIdCounter++;
const id = eventIdCounter;
// Format as SSE: id, event type, and JSON data
const sseMessage = `id: ${id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
eventBuffer.push({ id, message: sseMessage });
if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
for (const client of clients) {
client.res.write(sseMessage);
}
}
// Send the full current scoreboard to all connected clients
function broadcastFullState(fixtures) {
eventIdCounter++;
const id = eventIdCounter;
const sseMessage = `id: ${id}\nevent: score_update\ndata: ${JSON.stringify(fixtures)}\n\n`;
eventBuffer.push({ id, message: sseMessage });
if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
for (const client of clients) {
client.res.write(sseMessage);
}
}
What does this code do?
Each SSE message follows the format defined by the SSE specification: an id: field for reconnection tracking, an event: field for the event type, and a data: field with the JSON payload. A double newline terminates each event.
The broadcast() function sends individual change events like goals or status changes. The broadcastFullState() function sends the entire scoreboard as a score_update event after every poll, so all clients stay in sync.
- Save server.js.
Seeing a syntax error when you save?
Make sure you deleted both the old broadcast and broadcastFullState placeholder functions entirely before pasting the new ones. Duplicate function names cause a runtime error.
Help me fix a duplicate function error in my SSE broadcast code.
Create the SSE endpoint
The endpoint holds the HTTP connection open indefinitely, streams events as they arrive, and handles reconnection gracefully when clients lose connectivity.
- In server.js, find the line async function pollLiveScores() and add this route directly above it:
// --- SSE Endpoint ---
app.get('/api/scores/stream', (req, res) => {
// Set SSE headers to keep the connection open
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// Send current scoreboard state immediately on connect
const currentState = Array.from(cache.values());
const initMessage = `id: ${eventIdCounter}\nevent: score_update\ndata: ${JSON.stringify(currentState)}\n\n`;
res.write(initMessage);
// Replay missed events if the client is reconnecting
const lastEventId = parseInt(req.headers['last-event-id'] || '0', 10);
if (lastEventId > 0) {
const missed = eventBuffer.filter(e => e.id > lastEventId);
for (const event of missed) {
res.write(event.message);
}
}
// Register this client
const client = { id: Date.now(), res };
clients.add(client);
console.log(`Client connected. Total: ${clients.size}`);
// Send a heartbeat every 30 seconds to keep the connection alive
const heartbeat = setInterval(() => {
res.write(':keepalive\n\n');
}, 30000);
// Clean up when the client disconnects
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(client);
console.log(`Client disconnected. Total: ${clients.size}`);
});
});
What does this endpoint do?
- The SSE headers (Content-Type: text/event-stream) tell the browser this is a streaming connection, not a regular response.
- On connect, the server immediately sends the full current state from the cache. This means a new client sees the scoreboard right away without waiting for the next poll.
- The Last-Event-ID header is sent by the browser automatically when it reconnects after a dropped connection. The server uses it to replay only the events the client missed.
- The heartbeat comment (:keepalive) every 30 seconds prevents proxies and load balancers from closing the connection due to inactivity.
Why SSE instead of WebSockets?
A live scoreboard is a one-way data flow: the server pushes updates, and the client only listens. SSE is built exactly for this pattern. It runs over standard HTTP, reconnects automatically via the browser's EventSource API, and works through proxies and CDNs without special configuration.
WebSockets add bidirectional communication, which is unnecessary here and adds complexity (custom reconnection logic, separate protocol upgrade, load balancer configuration).
Finally, update the server startup log so you can quickly find the SSE stream URL when testing.
- In server.js, find this line inside app.listen:
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
- Add this line directly below it:
console.log(`SSE stream available at http://localhost:${PORT}/api/scores/stream`);
- Save server.js.
Server won't start after adding the endpoint?
Check that the app.get('/api/scores/stream', ...) route is placed before async function pollLiveScores() and after the broadcastFullState function. The route handler references cache, eventIdCounter, eventBuffer, and clients, which must all be declared above it.
Help me debug why my SSE endpoint won't start.
Test the SSE stream
Time to confirm the endpoint is working. You'll open the stream directly in the browser to see raw SSE events flowing.
- Stop your server if it's running by pressing Ctrl+C in the terminal.
- Restart your server by running:
API_FOOTBALL_KEY=your-api-key-here node server.js
- Open your browser and navigate to http://localhost:3000/api/scores/stream.
You should see raw SSE text appearing in the browser. The first message is the initial state (an empty array if no matches are live). Every 30 seconds, a :keepalive comment appears to keep the connection open.
- Open a second browser tab and navigate to the same URL http://localhost:3000/api/scores/stream.
- Check your terminal. You should see two log messages confirming both clients connected, like Client connected. Total: 2.
Not seeing any text in the browser?
Make sure the URL is exactly http://localhost:3000/api/scores/stream with no trailing slash. If you see a blank page, check your terminal for error messages. The server must be running without crashes.
Also confirm the Content-Type header is set correctly. Some browsers display SSE streams as plain text, while others may prompt a download if the content type is wrong.
Help me debug why my SSE stream shows nothing in the browser.
✔️ Awesome, I've got everything!
Great. Make sure you've saved server.js and that both browser tabs show SSE text.
ⓧ I'd like to double check the full code
Here is the complete server.js at this point:
const express = require('express');
const path = require('path');
const app = express();
const PORT = 3000;
const API_KEY = process.env.API_FOOTBALL_KEY || 'your-api-key-here';
const API_BASE = 'https://v3.football.api-sports.io';
const POLL_INTERVAL = 60000;
const cache = new Map();
let eventIdCounter = 0;
const eventBuffer = [];
const EVENT_BUFFER_MAX = 100;
const clients = new Set();
app.use(express.static(path.join(__dirname, 'public')));
function normalizeFixtures(apiResponse) {
if (!apiResponse || !apiResponse.response) return [];
return apiResponse.response.map(item => ({
fixtureId: item.fixture.id,
homeTeam: item.teams.home.name,
awayTeam: item.teams.away.name,
homeLogo: item.teams.home.logo,
awayLogo: item.teams.away.logo,
homeGoals: item.goals.home,
awayGoals: item.goals.away,
elapsed: item.fixture.status.elapsed,
status: item.fixture.status.short,
statusLong: item.fixture.status.long,
league: item.league.name,
round: item.league.round
}));
}
function detectChanges(fixtures) {
const events = [];
for (const fixture of fixtures) {
const cached = cache.get(fixture.fixtureId);
if (!cached) {
cache.set(fixture.fixtureId, { ...fixture });
events.push({ type: 'new_match', data: fixture });
continue;
}
if (cached.homeGoals !== fixture.homeGoals || cached.awayGoals !== fixture.awayGoals) {
events.push({ type: 'goal', data: fixture });
}
if (cached.status !== fixture.status) {
events.push({ type: 'status_change', data: fixture });
}
cache.set(fixture.fixtureId, { ...fixture });
}
return events;
}
function broadcast(event) {
eventIdCounter++;
const id = eventIdCounter;
const sseMessage = `id: ${id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
eventBuffer.push({ id, message: sseMessage });
if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
for (const client of clients) {
client.res.write(sseMessage);
}
}
function broadcastFullState(fixtures) {
eventIdCounter++;
const id = eventIdCounter;
const sseMessage = `id: ${id}\nevent: score_update\ndata: ${JSON.stringify(fixtures)}\n\n`;
eventBuffer.push({ id, message: sseMessage });
if (eventBuffer.length > EVENT_BUFFER_MAX) eventBuffer.shift();
for (const client of clients) {
client.res.write(sseMessage);
}
}
app.get('/api/scores/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
const currentState = Array.from(cache.values());
const initMessage = `id: ${eventIdCounter}\nevent: score_update\ndata: ${JSON.stringify(currentState)}\n\n`;
res.write(initMessage);
const lastEventId = parseInt(req.headers['last-event-id'] || '0', 10);
if (lastEventId > 0) {
const missed = eventBuffer.filter(e => e.id > lastEventId);
for (const event of missed) {
res.write(event.message);
}
}
const client = { id: Date.now(), res };
clients.add(client);
console.log(`Client connected. Total: ${clients.size}`);
const heartbeat = setInterval(() => {
res.write(':keepalive\n\n');
}, 30000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(client);
console.log(`Client disconnected. Total: ${clients.size}`);
});
});
async function pollLiveScores() {
try {
const response = await fetch(`${API_BASE}/fixtures?live=all`, {
headers: { 'x-apisports-key': API_KEY }
});
if (!response.ok) {
console.error(`API error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const fixtures = normalizeFixtures(data);
console.log(`[${new Date().toISOString()}] Polled ${fixtures.length} live fixtures`);
const changes = detectChanges(fixtures);
if (changes.length > 0) {
console.log(`[${new Date().toISOString()}] Detected ${changes.length} changes`);
for (const change of changes) {
broadcast(change);
}
}
broadcastFullState(fixtures);
} catch (error) {
console.error(`[${new Date().toISOString()}] Poll error:`, error.message);
}
}
app.listen(PORT, () => {
console.log(`Live Scoreboard running at http://localhost:${PORT}`);
console.log(`SSE stream available at http://localhost:${PORT}/api/scores/stream`);
pollLiveScores();
setInterval(pollLiveScores, POLL_INTERVAL);
});
Your SSE endpoint is live and streaming events to all connected browsers. Next up, you'll build the frontend that turns this raw data stream into a beautiful live scoreboard.
Build the Live Scoreboard Frontend
Your server is now polling scores, detecting changes, and broadcasting events to anyone who connects. The problem is that right now, the only way to see those events is by opening the raw SSE endpoint in a browser tab.
In this step, you will build the visual scoreboard that displays live match cards, connects to your SSE stream using the browser's EventSource API, and reacts to goal events with animations.
In this step, get ready to:
- Build the scoreboard HTML structure with match cards showing team names, scores, and game time.
- Connect to the SSE stream using EventSource and render matches dynamically.
- Add a goal flash animation and pulsing live badge for active matches.
Create the match card HTML and responsive CSS
The scoreboard needs a header with a connection status indicator and a grid of match cards. Each card displays team logos, names, the current score, elapsed time, and the match status.
- In VS Code, open public/index.html.
- Replace the entire file content with this HTML structure:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Copa do Mundo 2026 - Placar Ao Vivo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Copa do Mundo FIFA 2026</h1>
<p class="subtitle">Placar Ao Vivo</p>
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Conectando...</span>
</div>
</header>
<main>
<div id="noMatches" class="no-matches">
<p>Nenhuma partida ao vivo no momento.</p>
<p class="hint">Os placares aparecerao automaticamente quando houver jogos em andamento.</p>
</div>
<div id="matchesGrid" class="matches-grid"></div>
</main>
</body>
</html>
What does this HTML do?
- The connection-status div shows a colored dot and text so the user knows if the SSE stream is active.
- The noMatches div appears when no live games are available.
- The matchesGrid div is an empty container that JavaScript will fill with match cards dynamically.
Now let's style the scoreboard. The CSS uses a dark theme with a CSS Grid layout for responsive match cards.
- Open public/style.css.
- Replace the placeholder comment with these base styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0f1c;
color: #ffffff;
min-height: 100vh;
}
header {
text-align: center;
padding: 2rem 1rem;
background: linear-gradient(135deg, #1a1f3a 0%, #0d1225 100%);
border-bottom: 2px solid #2a3055;
}
header h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.subtitle {
color: #8b95b8;
margin-top: 0.25rem;
font-size: 0.95rem;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.4rem 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
font-size: 0.8rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #00e676;
animation: pulse 2s infinite;
}
.status-dot.reconnecting {
background: #ffa726;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.no-matches {
text-align: center;
padding: 4rem 1rem;
color: #8b95b8;
}
.no-matches .hint {
font-size: 0.85rem;
margin-top: 0.5rem;
color: #5a6380;
}
What does this CSS do?
- The dark background (#0a0f1c) gives the scoreboard a professional sports-app feel.
- The pulse keyframe creates a breathing animation that the status dot and live badge will use.
- The connection status pill has a rounded shape so it looks like a status indicator chip.
- Add the match card and grid styles below the existing CSS in the same file:
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.match-card {
background: linear-gradient(145deg, #1a2040 0%, #141830 100%);
border: 1px solid #2a3055;
border-radius: 12px;
padding: 1.25rem;
transition: transform 0.2s, box-shadow 0.3s;
}
.match-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.match-card.goal-flash {
animation: goalFlash 3s ease-out;
}
@keyframes goalFlash {
0% { border-color: #00e676; box-shadow: 0 0 20px rgba(0, 230, 118, 0.4); }
100% { border-color: #2a3055; box-shadow: none; }
}
.match-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.live-badge {
background: #e53935;
color: white;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.5px;
animation: pulse 2s infinite;
}
.match-time {
color: #8b95b8;
font-size: 0.85rem;
}
.match-teams {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.team {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.team-name {
font-size: 0.8rem;
text-align: center;
color: #c8d0e8;
}
.match-score {
padding: 0 1rem;
}
.score {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 2px;
}
.match-info {
text-align: center;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #5a6380;
}
@media (max-width: 480px) {
.matches-grid {
grid-template-columns: 1fr;
}
header h1 {
font-size: 1.4rem;
}
}
What does this CSS do?
- The matches-grid uses CSS Grid with auto-fill to automatically wrap cards on smaller screens.
- The goalFlash keyframe makes a card's border glow green for 3 seconds when a goal is scored.
- The live-badge pulses using the same pulse animation as the status dot, drawing attention to active matches.
- Save public/style.css.
- Save public/index.html.
Page looks unstyled?
- Check that the <link rel="stylesheet" href="style.css"> tag in your HTML matches the filename exactly (case-sensitive).
- Confirm both files are inside the public/ folder.
Why isn't my CSS loading in the browser?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0f1c;
color: #ffffff;
min-height: 100vh;
}
header {
text-align: center;
padding: 2rem 1rem;
background: linear-gradient(135deg, #1a1f3a 0%, #0d1225 100%);
border-bottom: 2px solid #2a3055;
}
header h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.subtitle {
color: #8b95b8;
margin-top: 0.25rem;
font-size: 0.95rem;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.4rem 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
font-size: 0.8rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connected {
background: #00e676;
animation: pulse 2s infinite;
}
.status-dot.reconnecting {
background: #ffa726;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.no-matches {
text-align: center;
padding: 4rem 1rem;
color: #8b95b8;
}
.no-matches .hint {
font-size: 0.85rem;
margin-top: 0.5rem;
color: #5a6380;
}
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.match-card {
background: linear-gradient(145deg, #1a2040 0%, #141830 100%);
border: 1px solid #2a3055;
border-radius: 12px;
padding: 1.25rem;
transition: transform 0.2s, box-shadow 0.3s;
}
.match-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.match-card.goal-flash {
animation: goalFlash 3s ease-out;
}
@keyframes goalFlash {
0% { border-color: #00e676; box-shadow: 0 0 20px rgba(0, 230, 118, 0.4); }
100% { border-color: #2a3055; box-shadow: none; }
}
.match-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.live-badge {
background: #e53935;
color: white;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.5px;
animation: pulse 2s infinite;
}
.match-time {
color: #8b95b8;
font-size: 0.85rem;
}
.match-teams {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.team {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.team-name {
font-size: 0.8rem;
text-align: center;
color: #c8d0e8;
}
.match-score {
padding: 0 1rem;
}
.score {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 2px;
}
.match-info {
text-align: center;
margin-top: 0.75rem;
font-size: 0.75rem;
color: #5a6380;
}
@media (max-width: 480px) {
.matches-grid {
grid-template-columns: 1fr;
}
header h1 {
font-size: 1.4rem;
}
}
Implement SSE connection and dynamic rendering
The browser has a built-in API called EventSource that connects to an SSE endpoint and automatically handles reconnection. You will use it to receive live score events from your server.
- In public/index.html, add this <script> block just before the closing </body> tag:
<script>
// --- DOM references ---
const matchesGrid = document.getElementById('matchesGrid');
const noMatches = document.getElementById('noMatches');
const connectionStatus = document.getElementById('connectionStatus');
let matches = {};
// --- Update the connection indicator text and dot color ---
function setConnectionStatus(status) {
const dot = connectionStatus.querySelector('.status-dot');
const text = connectionStatus.querySelector('.status-text');
dot.className = 'status-dot ' + status;
if (status === 'connected') text.textContent = 'Ao Vivo';
else if (status === 'reconnecting') text.textContent = 'Reconectando...';
else text.textContent = 'Desconectado';
}
// --- Render all matches into the grid ---
function renderMatches() {
const matchList = Object.values(matches);
if (matchList.length === 0) {
noMatches.style.display = 'block';
matchesGrid.style.display = 'none';
return;
}
noMatches.style.display = 'none';
matchesGrid.style.display = 'grid';
matchesGrid.innerHTML = matchList.map(m => `
<div class="match-card" id="match-${m.fixtureId}">
<div class="match-status">
<span class="live-badge">${m.status === '1H' || m.status === '2H' || m.status === 'ET' ? 'AO VIVO' : m.statusLong || m.status}</span>
<span class="match-time">${m.elapsed ? m.elapsed + "'" : ''}</span>
</div>
<div class="match-teams">
<div class="team home">
<img src="${m.homeLogo}" alt="${m.homeTeam}" class="team-logo" onerror="this.style.display='none'">
<span class="team-name">${m.homeTeam}</span>
</div>
<div class="match-score">
<span class="score">${m.homeGoals ?? 0} - ${m.awayGoals ?? 0}</span>
</div>
<div class="team away">
<img src="${m.awayLogo}" alt="${m.awayTeam}" class="team-logo" onerror="this.style.display='none'">
<span class="team-name">${m.awayTeam}</span>
</div>
</div>
<div class="match-info">${m.round || ''}</div>
</div>
`).join('');
}
// --- Flash a match card green when a goal is scored ---
function highlightGoal(fixtureId) {
const card = document.getElementById('match-' + fixtureId);
if (card) {
card.classList.add('goal-flash');
setTimeout(() => card.classList.remove('goal-flash'), 3000);
}
}
// --- Connect to the SSE stream and listen for events ---
function connectSSE() {
const source = new EventSource('/api/scores/stream');
source.addEventListener('open', () => { setConnectionStatus('connected'); });
source.addEventListener('score_update', (e) => {
const fixtures = JSON.parse(e.data);
if (Array.isArray(fixtures)) {
fixtures.forEach(f => { matches[f.fixtureId] = f; });
renderMatches();
}
});
source.addEventListener('goal', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
highlightGoal(fixture.fixtureId);
});
source.addEventListener('status_change', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
});
source.addEventListener('new_match', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
});
source.addEventListener('error', () => { setConnectionStatus('reconnecting'); });
}
// --- Start the connection on page load ---
connectSSE();
</script>
What does this script do?
- The matches object stores the latest state of every match, keyed by fixture ID.
- The renderMatches() function uses template literals to build HTML for each match card and inserts it into the grid.
- The connectSSE() function creates an EventSource pointing at /api/scores/stream and registers a listener for each event type your server emits.
- When the browser loses connection, EventSource fires an error event and automatically attempts to reconnect. No manual retry logic needed.
Why EventSource instead of fetch?
You could poll the server with repeated fetch() calls, but that wastes bandwidth and adds latency. EventSource keeps a single HTTP connection open and receives events the instant the server pushes them.
It also handles reconnection and the Last-Event-ID header automatically, so your client picks up where it left off after a network drop.
- Save public/index.html.
- In your terminal, start the server by running:
node server.js
- Open http://localhost:3000 in your browser.
- You should see the dark scoreboard page with the connection status showing "Ao Vivo" (green dot pulsing). If no matches are live, the "Nenhuma partida ao vivo no momento" message appears.
Status dot stays gray or says Reconectando?
- Confirm your server is running. Check the terminal for the "Live Scoreboard running" message.
- Open your browser's DevTools console and look for connection errors. A 404 means the SSE endpoint path does not match.
- Make sure the <script> block is inside <body> and before </body>.
My EventSource connection is not working. How can I debug it?
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Copa do Mundo 2026 - Placar Ao Vivo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Copa do Mundo FIFA 2026</h1>
<p class="subtitle">Placar Ao Vivo</p>
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Conectando...</span>
</div>
</header>
<main>
<div id="noMatches" class="no-matches">
<p>Nenhuma partida ao vivo no momento.</p>
<p class="hint">Os placares aparecerao automaticamente quando houver jogos em andamento.</p>
</div>
<div id="matchesGrid" class="matches-grid"></div>
</main>
<script>
const matchesGrid = document.getElementById('matchesGrid');
const noMatches = document.getElementById('noMatches');
const connectionStatus = document.getElementById('connectionStatus');
let matches = {};
function setConnectionStatus(status) {
const dot = connectionStatus.querySelector('.status-dot');
const text = connectionStatus.querySelector('.status-text');
dot.className = 'status-dot ' + status;
if (status === 'connected') text.textContent = 'Ao Vivo';
else if (status === 'reconnecting') text.textContent = 'Reconectando...';
else text.textContent = 'Desconectado';
}
function renderMatches() {
const matchList = Object.values(matches);
if (matchList.length === 0) {
noMatches.style.display = 'block';
matchesGrid.style.display = 'none';
return;
}
noMatches.style.display = 'none';
matchesGrid.style.display = 'grid';
matchesGrid.innerHTML = matchList.map(m => `
<div class="match-card" id="match-${m.fixtureId}">
<div class="match-status">
<span class="live-badge">${m.status === '1H' || m.status === '2H' || m.status === 'ET' ? 'AO VIVO' : m.statusLong || m.status}</span>
<span class="match-time">${m.elapsed ? m.elapsed + "'" : ''}</span>
</div>
<div class="match-teams">
<div class="team home">
<img src="${m.homeLogo}" alt="${m.homeTeam}" class="team-logo" onerror="this.style.display='none'">
<span class="team-name">${m.homeTeam}</span>
</div>
<div class="match-score">
<span class="score">${m.homeGoals ?? 0} - ${m.awayGoals ?? 0}</span>
</div>
<div class="team away">
<img src="${m.awayLogo}" alt="${m.awayTeam}" class="team-logo" onerror="this.style.display='none'">
<span class="team-name">${m.awayTeam}</span>
</div>
</div>
<div class="match-info">${m.round || ''}</div>
</div>
`).join('');
}
function highlightGoal(fixtureId) {
const card = document.getElementById('match-' + fixtureId);
if (card) {
card.classList.add('goal-flash');
setTimeout(() => card.classList.remove('goal-flash'), 3000);
}
}
function connectSSE() {
const source = new EventSource('/api/scores/stream');
source.addEventListener('open', () => { setConnectionStatus('connected'); });
source.addEventListener('score_update', (e) => {
const fixtures = JSON.parse(e.data);
if (Array.isArray(fixtures)) {
fixtures.forEach(f => { matches[f.fixtureId] = f; });
renderMatches();
}
});
source.addEventListener('goal', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
highlightGoal(fixture.fixtureId);
});
source.addEventListener('status_change', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
});
source.addEventListener('new_match', (e) => {
const fixture = JSON.parse(e.data);
matches[fixture.fixtureId] = fixture;
renderMatches();
});
source.addEventListener('error', () => { setConnectionStatus('reconnecting'); });
}
connectSSE();
</script>
</body>
</html>
Test goal flash animation and automatic reconnection
Your scoreboard now has all the visual features of a professional live score app. The goal flash, pulsing live badge, and connection indicator all work together to keep the viewer informed in real time.
- Open your browser's DevTools console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
- Test the goal flash animation by pasting this into the console:
// Simulate a match card appearing and flashing
matches['test-1'] = { fixtureId: 'test-1', homeTeam: 'Brasil', awayTeam: 'Argentina', homeGoals: 1, awayGoals: 0, elapsed: 45, status: '1H', statusLong: 'First Half', homeLogo: '', awayLogo: '', round: 'Group A' };
renderMatches();
setTimeout(() => highlightGoal('test-1'), 500);
- You should see a match card appear with "Brasil 1 - 0 Argentina" and a green glow animation that fades over 3 seconds.
- The card should display a red "AO VIVO" badge that pulses, since the status is 1H (first half).
How does the goal animation work?
When the server broadcasts a goal event, the highlightGoal() function adds the goal-flash CSS class to the match card. This triggers the goalFlash keyframe animation that glows the card's border green.
After 3 seconds, setTimeout removes the class so the animation can fire again on the next goal. Without removing it, subsequent goals on the same match would not re-trigger the animation.
Now test the automatic reconnection that EventSource provides.
- In your terminal, stop the server with Ctrl+C.
- Watch the browser. The status dot should turn orange and display "Reconectando..." within a few seconds.
- Restart the server by running:
node server.js
- The status dot should turn green again and show "Ao Vivo" without you refreshing the page.
Why does reconnection work automatically?
The EventSource API is designed for exactly this scenario. When the connection drops, the browser waits a few seconds and retries. It sends the Last-Event-ID header so the server can replay any events the client missed during the outage.
Card doesn't flash green?
- Verify the .match-card.goal-flash rule exists in your style.css and references the goalFlash keyframe.
- Check the console for JavaScript errors. A typo in highlightGoal or renderMatches will prevent the animation from running.
My goal flash animation is not triggering. Help me debug.
Secret mission
Notificacao Sonora de Gol e Filtro por Grupo
Your live scoreboard pushes score updates, but a real fan experience goes further. What if you could hear a goal the instant it happens? What if you could filter the board down to just your group or your team? In this secret mission, you will add audio notifications using the Web Audio API and client-side filtering with localStorage persistence.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses the free tier of API-Football. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Node.js project folder (server.js, public/, node_modules/, .env).
- Free API-Football account (no cost, remains free).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the live scoreboard.
- Your Node.js server continues running on port 3000. No cloud resources or ongoing costs are involved.
- Your API-Football free account stays active. The 100 requests/day limit resets daily at no charge.
Pause - I'll come back to this later
Shut down the running server to free up your terminal. Your project files stay on disk, ready to restart anytime.
- Switch to the terminal where your server is running and press Ctrl+C to stop the process.
- Your project folder and API-Football account remain intact. To restart later, navigate to your project folder and run node server.js.
Delete - I don't want to use this again
Remove all project files from your machine. Your API-Football account can be kept for future projects or deleted separately.
- Stop the server by pressing Ctrl+C in the terminal where it is running.
- Navigate to your Desktop (or wherever you created the project folder) and delete the project folder by running:
cd ~/Desktop
rm -rf live-scoreboard
Replace with your folder name
If you named your project folder something different, replace live-scoreboard with the actual folder name you used in Step 1.
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force live-scoreboard
- To delete your API-Football account, sign in to dashboard.api-football.com and look for account deletion options in your profile settings. The account is free, so keeping it has no cost impact.
Nice Work!
Nice Work!
You just built a production-pattern live scoreboard for the FIFA World Cup 2026. Your system demonstrates the same polling-then-SSE architecture used by professional sports apps to deliver real-time score updates to thousands of connected browsers.
You learned how to:
- Build a Node.js server implementing the polling-then-SSE architecture pattern, with a polling layer that queries API-Football every 60 seconds and a change detection cache that generates typed events only when scores change.
- Create an SSE endpoint with a client registry, 30-second heartbeat, Last-Event-ID reconnection support, and immediate state delivery so new clients see the current scoreboard on connect.
- Build a responsive frontend using the EventSource API that renders live scores with visual feedback on goal events, connection status indicators, and automatic reconnection.
- Secret Mission: Added audio notifications for goals via the Web Audio API and client-side group/team filtering with localStorage persistence.
Ready to quiz yourself?