A self-hosted scoreboard for sports clubs. Drives a TV next to the pitch or in the clubhouse, controlled from a phone or tablet on the sideline. One public board, one password-protected control panel, one tiny Node service — no database, no cloud, no subscription.
Built originally for our own amateur football club (referred to as SVC throughout this README — substitute your own club where you see it). The building blocks are generic: two teams, a score, a clock, halves.
This particular build is football-flavoured end to end — green-grass
background, football crests, IFAB Rule 7 phase model, footballs flying
across the screen on a goal celebration. But every football-specific layer
is intentionally a thin one and easy to adapt to other team sports
where two sides face off against a clock: handball, basketball, volleyball,
ice hockey, field hockey, water polo, fistball, floorball, futsal, rugby,
American football, lacrosse — anything matching the same shape works after
swapping crests, background and the few phase labels in
lib/app.js.
Most amateur clubs have a flat-screen TV — in the clubhouse, on the terrace, or under a sun-roof by the pitch — that goes unused during home matches. Commercial scoreboard software is expensive, locked to proprietary hardware, or both. This project is the minimum viable alternative:
- One Node.js process behind Traefik (or any reverse proxy).
- Any browser-capable TV as the display — no app store, no signage box.
- Any phone or tablet as the remote — just open
/configand log in. - Scores, clubs and clock survive a power cut (state on disk, atomic writes).
- Hosted on your own hardware, on your own network — no telemetry, no external service.
It was developed and proven in production at our amateur football club (SVC). The TV validation below was run on the real hardware in use there.
- Crests, names, score, phase, match clock, wall-clock — full-screen, designed to be readable from across the clubhouse or from the far end of the spectator stand.
- Smooth clock driven by
requestAnimationFrame, server-corrected on every poll so it never drifts. - Manrope + Teko as the only two webfonts (numeric Teko with
tabular-numsso digit changes never jitter horizontally). - Connection indicator in the corner: live / stale / offline.
- Server-authoritative
{ baseMs, startedAtMs, running }. Clients reconstruct display time locally; no per-frame HTTP traffic. - IFAB Rule 7 compliant phase model: half-time and pre-ET freeze the clock; ET1 starts at 90:00, ET2 at 105:00; penalty shootout is untimed.
- Stoppage time toggle (
45+N) with separate minute input. Automatically resets when entering a new half.
Operates over the same /api/state polling loop as the TV.
-
Live preview of the TV display inside an iframe — see the change before the TV does.
-
Score buttons (⚽ Tor / −1 correction) for each team.
-
Phase grid with one-tap transitions, including IFAB-correct clock side effects.
-
Match-clock controls: Start / Pause / Reset / Set (
MM:SS). -
Aktionen card with two unified action-fields:
- Status line — free text under the score for narration
(
⚽ 78' Müller). Persists until cleared. - Full-screen overlay — big diagonal banner across the TV (e.g.
„Heimsieger"). Auto-fits the viewport for any character count using
SVG
viewBox+getBBox()— never overflows, never sits in awkward whitespace.
Both fields share the same UX: a toggle button that highlights when the field is live on the TV (click again to deactivate), an inline clear-X in the input (local-only, no server call), and a history pill row of the last 20 distinct values you have used (
localStorage, deduplicated, newest first; click a pill to recall it, ×-button to remove an entry). - Status line — free text under the score for narration
(
-
„Tor feiern" celebration animation: ~60 footballs spawn at the bottom edge over 4.5 s, fly along cubic-Bezier paths with comic-style speed-line trails, and exit the top, left, or right. Trigger with one tap; the animation lasts about 8 s on the TV.
-
New match reset: one button clears score, clock, phase, status, overlay, team names and crest assignments.
- Upload PNG, JPEG, WebP or SVG (≤ 4 MB). SVG keeps its vector data and bypasses the bitmap pipeline below.
- Optional white-background removal (bitmaps only) via flood-fill from the image edges with multi-layer halo erosion — kills JPEG anti-aliasing fringes while preserving dark contours.
- Auto-crop to the non-transparent bounding box with 2 % padding so bitmap crests sit uniformly in their display box.
- viewBox auto-trim for SVG crests: the renderer reads each SVG's bounding box at load time and tightens the viewBox to the visible content, so two crests with very different built-in padding still appear at the same optical size on the TV.
- Alphabetical sorting of the crest list and the team dropdowns, by label.
- Import from fussball.de: paste a team or club URL; the server scrapes
the club ID and fetches the largest available logo variant (
format/19, typically ~890×890), pre-fills a label suggestion from the page title, and feeds the result through the same upload pipeline.
- Single shared password (
ADMIN_PASSWORD) protects/configand all write endpoints. - HMAC-signed session cookie, rolling 90-day validity.
- Failed-login throttle with exponential backoff.
- No password = dev mode (use only on private networks).
git clone https://github.com/YOUR-FORK/svc-scoreboard.git
cd svc-scoreboard
cp .env.example .env
# Edit .env — at minimum set ADMIN_PASSWORD
docker compose up -d --build
docker compose logs -f appThen open:
http://your-host:8080/on the TV.http://your-host:8080/configon your phone (login withADMIN_PASSWORD).
The docker-compose.yml ships with Traefik labels for reverse-proxy
deployment; remove them if you run direct.
This project deliberately keeps Node off the host. All Node toolchain commands
go through the ./tools/dev wrapper, which runs them inside node:22-alpine
with node_modules in a named Docker volume (see Development → Dependency
management). To run the dev server locally:
./tools/dev node server.js # or any other entry point you needIf you have Node 20+ installed natively and prefer to skip Docker, plain
npm install + npm start works too — but it's not the recommended path.
| Path | Purpose | Auth |
|---|---|---|
/ |
Public TV display (crests, score, phase, match clock, time) | public |
/config |
Operator panel (score, phase, clock, crests, overlay) | login |
/test |
TV stress-test harness (FPS, rAF drift, heap, visibility) | public |
/login /logout |
Auth pages | public |
/api/state |
Current state (GET) / patch fields (POST) | GET open |
/api/clock |
start / pause / reset / set the match clock |
login |
/api/phase |
Phase change with auto-clock side effects | login |
/api/celebrate |
Trigger the goal-celebration animation on all displays | login |
/api/match/new |
Reset everything to defaults | login |
/api/crests |
Upload / rename / delete crests | login |
/api/fussballde-logo |
Import a club logo via a fussball.de URL | login |
/crests/… |
Uploaded crest files | public |
/fonts/{manrope,teko}/… |
Self-hosted webfont files | public |
/healthz |
Liveness probe (200 ok) |
public |
Single-process Node + Express. State is a JSON file
($DATA_DIR/state.json); crests are files in $DATA_DIR/crests/. Writes go
through a serialized withState() mutator with atomic tmp + rename.
Clients poll /api/state (~1.2 s in the control panel, longer on the
TV). The response includes serverNowMs; clients compute the offset to
Date.now() so the local clock renders smoothly with requestAnimationFrame
between fetches.
[ Browser TV (/) ] ←─ poll /api/state ─┐
├──→ [ Node/Express + state.json ]
[ Operator (/config) ] ─ POST /api/* ──┘
That's the entire data flow. No WebSocket, no SSE, no Redis, no Postgres.
PREMATCH, HZ1, HALF, HZ2, FT, PRE_ET, ET1, ETHALF, ET2,
PEN. Defined in PHASE_TRANSITIONS in
lib/app.js. Each phase sets:
baseMs— the value the match clock snaps to.nullmeans leave alone (used byFT,PRE_ET,PEN) so the final time onFTkeeps the actual90+3and is not snapped back to90:00.running— whether the clock runs after the transition.
Entering HZ1 / HZ2 / ET1 / ET2 automatically resets the stoppage-time
toggle.
Trigger: POST /api/celebrate writes celebrationAt = Date.now(). Each
client picks up the change on its next poll and fires the animation
locally. Stale timestamps (> 10 s old) are ignored, so reconnecting clients
don't replay an old goal. The renderer
(public/js/celebration.js) spawns 60 balls along
cubic-Bezier trajectories with rotated trail elements; total visible duration
is ~8 s.
All settings are environment variables (see
.env.example and docker-compose.yml):
| Variable | Default | Purpose |
|---|---|---|
ADMIN_PASSWORD |
(empty) | Password for /config and write endpoints. Empty disables auth. |
SESSION_SECRET |
(auto-generated) | HMAC key for the session cookie. Persisted to $DATA_DIR/.session-secret. |
DATA_DIR |
/data |
Where state.json and uploaded crests live. |
PORT |
8080 |
HTTP listen port. |
TZ |
Europe/Berlin |
Container timezone. |
PUID / PGID |
1000 / 1000 |
UID/GID the container runs as. Must own the data volume. |
APP_NAME |
svc-anzeige |
Traefik router name. |
APP_DOMAIN |
(none) | Traefik Host(...) rule. |
TRAEFIK_CERTRESOLVER |
resolver-gandi |
Cert resolver name in your Traefik config. |
Self-hosted via @fontsource/*, mounted as Express static at
/fonts/manrope and /fonts/teko. Two families only:
- Manrope (400 / 600 / 700 / 800) for all non-numeric text — team names, status line, wall clock, phase label, control UI, login screen, overlay.
- Teko (400 / 600 / 700) for every numeric display — score, match
clock, crest placeholder initials — with
font-variant-numeric: tabular-numsso digit changes don't shift horizontally.
No external CDN. Declarations live in public/css/fonts.css.
Both Manrope and Teko are released under the SIL Open Font License 1.1 (via Google Fonts); the OFL explicitly allows bundling and redistribution inside projects of any license — including MIT — so the font files can be shipped with the source without any extra paperwork.
Before any UI work, the project shipped a stress-test harness at /test
(public/test.html, self-documenting in-browser). It is a
passive page: open it in the TV's browser and leave it running — there
is no background service. As long as the tab is foregrounded, the page
measures and reports:
- FPS / min-FPS over 10 s — does the browser throttle
requestAnimationFramein full-screen? - rAF drift vs.
Date.now()over hours — is the tab being silently suspended? - JS heap growth — any leak in the render loop?
- Visibility / online / offline events — does the TV browser ever background the tab?
- Long-task duration — single GC pauses / repaints exceeding 50 ms.
- Fetch success rate and latency against
/healthz.
The traffic-light thresholds and reasoning are described directly inside the page.
Result for the reference hardware (Sony BRAVIA KD-75X81J with SonyCEBrowser / Chromium 136), 5h 19m overnight run: min-FPS 59.9 over any 10 s window, rAF drift +5 ms, heap stable at 9.5 MB, 12 459 successful fetches with zero failures, no visibility transitions, no rAF throttling. The TV is suitable for permanent operation as a web-driven scoreboard.
If you deploy on different hardware, open /test on the TV in full-screen
the night before and check the panel after 12–24 h.
The software is only one half of the equation; whether the scoreboard is legible depends on the screen you put it on. Things to plan for:
- Screen size vs. viewing distance. For a clubhouse TV viewed at 3–6 m, 55–75 inches is a comfortable starting point. For a pitch-side TV read from 10–20 m, plan for at least 75 inches and consider a high-brightness or signage-grade panel.
- Brightness and reflection. A normal living-room TV (≈ 300–500 nits) works indoors and under shade. Behind a sun-roof or near a window, look for ≥ 700 nits and an anti-reflective matte panel; bright sunlight makes consumer TVs effectively unreadable.
- Contrast. The design uses a dark green background with light text on purpose — high contrast and no white flash. If you adapt the colours for another sport, keep that ratio.
- Web runtime. Any modern Chromium-based browser works (the reference
TV runs SonyCEBrowser / Chromium 136). Smart-TV browsers vary wildly in
quality — run
/testfirst. - Continuous-on duty cycle. TVs intended for home use are typically rated for 8–10 h/day. For all-day public-display use, prefer a model that lists a higher rating, or be ready to power-cycle the TV daily.
Tests run inside a cached Node container — no host Node required:
./tools/dev npm testA single test file:
./tools/dev node --test tests/integration/login-flow.test.jsIf you have Node 20+ installed locally, npm test works directly.
Stack:
node --test(Node 20+ built-in runner)supertestfor HTTP / cookie integration testshappy-domfor DOM-level frontend logic tests
No headless browser, no Playwright. Tests run in well under a minute.
tests/
unit/ pure-function tests (auth-token, secret-store)
integration/ express app + supertest (login, throttling, overlay, …)
dom/ happy-dom rendering tests (config UI, scoreboard)
helpers/ app-factory and DOM-bootstrap helpers
Node never runs on the host. The split between source and build artefacts:
| File / dir | Lives on host + Git | Lives in Docker volume |
|---|---|---|
package.json |
✓ | |
package-lock.json |
✓ | |
node_modules/ (content) |
✓ (test_node_modules) |
./tools/dev mounts the repo into the container as a bind-mount and overlays
/app/node_modules with the named volume svc-scoreboard-test-node-modules.
Reads/writes to node_modules go to the volume; reads/writes to
package.json and package-lock.json land on the host filesystem and show up
in git status for committing.
Update or add a dep — all on-host artefacts (package.json,
package-lock.json) update naturally; the volume's node_modules gets
populated in lock-step:
./tools/dev npm install <package> # add a new dep
./tools/dev npm install <package>@latest # bump a specific dep
./tools/dev npm update # bump all deps within their semver ranges
./tools/dev npm outdated # report what's behind
./tools/dev npm audit # security audit
./tools/dev npm audit fix # auto-fix what npm canThen git diff package.json package-lock.json to review and commit.
Production images use npm ci --omit=dev against the committed lockfile, so
container builds always pin exactly what local development tested against.
A note on the host-side node_modules/ mountpoint: Docker creates an
empty node_modules/ directory on the host (root-owned, 512 bytes) the first
time ./tools/dev runs. This is the mount target for the named volume — an
unavoidable side effect of combining a bind-mount with a volume mounted on a
sub-path. It's harmless, ignored by Git and Docker, and contains no real
files. To reset everything from scratch:
docker volume rm svc-scoreboard-test-node-modules
docker run --rm -v "$PWD:/app" alpine rm -rf /app/node_modules- Multer 1.x deprecation. Currently pinned for the crest-upload endpoint;
upstream advisories recommend the 2.x line. Migration touches the upload
handler in
lib/app.jsand the multipart contract inpublic/js/config.js. - Empty
node_modules/mount-point directory. Cosmetic only; see the Dependency-management note above. - DOM test coverage for action fields is overlay-only. The status-line toggle/pill behaviour shares the same code path but has no dedicated test yet.
server.js Express bootstrap
lib/
app.js routes, state, phase logic, auth wiring
auth-middleware.js cookie verification + throttling
auth-token.js HMAC sign/verify
secret-store.js on-disk SESSION_SECRET persistence
public/
scoreboard.html TV display (/)
config.html operator panel (/config)
test.html stress-test harness (/test)
login.html auth page (/login)
favicon.svg
assets/ static images (e.g. soccer balls for celebration)
css/ fonts.css, scoreboard.css, config.css, login.css
js/
scoreboard.js polling + render loop
scoreboard-preview.js iframe live preview
config.js operator UI + crest upload pipeline
celebration.js goal-celebration animation
login.js login form helper
tools/
dev docker-wrapped npm/node for hostless dev
fetch-logo.py diagnostic probe for fussball.de logo variants
tests/ see "Development"
Dockerfile node:20-alpine, runs as `node` user
docker-compose.yml Traefik labels, volume bind
MIT.
Originally built for the home-match TV at SVC, our amateur football club. The code is generic enough that any club running any team sport with the "two sides, a score, a clock" structure should be able to fork it. Pull requests welcome.

