Skip to content

Repository files navigation

svc-scoreboard

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.

Scoreboard on the TV

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.

Control panel on a phone

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.


Why this exists

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 /config and 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.


Features

Public TV display (/)

  • 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-nums so digit changes never jitter horizontally).
  • Connection indicator in the corner: live / stale / offline.

Match clock & stoppage time

  • 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.

Control panel (/config)

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).

  • „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.

Crest management

  • 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.

Authentication

  • Single shared password (ADMIN_PASSWORD) protects /config and 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).

Quickstart

With Docker (recommended)

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 app

Then open:

  • http://your-host:8080/ on the TV.
  • http://your-host:8080/config on your phone (login with ADMIN_PASSWORD).

The docker-compose.yml ships with Traefik labels for reverse-proxy deployment; remove them if you run direct.

Local development (via Docker)

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 need

If 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.


Routes

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

Architecture

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.

Phase model

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. null means leave alone (used by FT, PRE_ET, PEN) so the final time on FT keeps the actual 90+3 and is not snapped back to 90:00.
  • running — whether the clock runs after the transition.

Entering HZ1 / HZ2 / ET1 / ET2 automatically resets the stoppage-time toggle.

Celebration animation

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.


Configuration

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.

Fonts

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-nums so 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.


TV validation

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 requestAnimationFrame in 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.

Display hardware considerations

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 /test first.
  • 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.

Development

Running tests

Tests run inside a cached Node container — no host Node required:

./tools/dev npm test

A single test file:

./tools/dev node --test tests/integration/login-flow.test.js

If you have Node 20+ installed locally, npm test works directly.

Stack:

  • node --test (Node 20+ built-in runner)
  • supertest for HTTP / cookie integration tests
  • happy-dom for DOM-level frontend logic tests

No headless browser, no Playwright. Tests run in well under a minute.

Test layout

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

Dependency management

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 can

Then 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

Known limitations

  • 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.js and the multipart contract in public/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.

Directory layout

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

License

MIT.

Origin

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.

About

Self-hosted football scoreboard for the clubhouse TV — Node.js + Express, no database. Phone-controlled match clock with IFAB Rule 7 phases (HT, ET, PEN), crest upload incl. fussball.de import, full-screen overlay banner, animated goal celebration. Adapts to handball, basketball, volleyball and other team sports.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages