A kiosk-style leaderboard for a 32" TV, built with Next.js (App Router), Tailwind CSS, and Supabase. Designed to run 24/7 on a Raspberry Pi browser.
/— the full-screen TV board. Dark theme, no scrollbars, auto-refreshes every 20s, and force-reloads once a day (4 AM local) to bound any long-running browser memory growth. A footer control switches the time interval (All Time / Last 7 Days / Last 3 Days); it can also be set from the URL — see Time interval./admin— password-protected CRUD UI for categories, games, players, and scores./api/leaderboard,/api/addScore,/api/addPinballScore,/api/refreshPinballScore,/api/heartbeat— the public API surface (see below).
- Create a project at supabase.com.
- In the SQL editor, run
supabase/migrations/0001_init.sql. This creates the schema (game_categories,game_names,users,leaderboard), enables Row Level Security with no policies (the app only ever talks to Supabase server-side with the service-role key, which bypasses RLS — the anon key is never used), and seeds the Table Games and Card Games categories/games from the spec. Pinball machines are created on the fly the first time a score is submitted for them. - Grab your Project URL and service_role key from Project Settings → API.
Copy .env.example to .env.local and fill in:
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
ADMIN_PASSWORD=a-real-password
ADMIN_SESSION_TOKEN=$(openssl rand -hex 32)SUPABASE_SERVICE_ROLE_KEY and ADMIN_SESSION_TOKEN are secrets — never commit .env.local (it's already gitignored) and never expose them to the browser.
npm install
npm run dev # http://localhost:3000npm run build && npm start # production| Table | Purpose |
|---|---|
game_categories |
Top-level board blocks: Table Games, Pinball, Card Games. |
game_names |
An individual game/machine within a category. Carries sort_direction (asc/desc — which score wins) and top_n (how many rows the board shows). |
users |
Registered household/regular players. |
leaderboard |
One row per score. Either user_id or custom_username is set (never neither) — pinball walk-up guests use custom_username and skip the users table entirely. game_category_id is denormalized onto the row for simple filtering. |
Ranking per the spec is entirely data-driven via game_names.sort_direction / top_n, not hardcoded. Note that for Table Games and Card Games the ranked value is each player's summed score over the selected time window, not a single entry — see Time interval:
| Category | Game | Ranking | Shown |
|---|---|---|---|
| Table Games | Shuffleboard | Ascending | Top 10 |
| Table Games | Air Hockey | Ascending | Top 10 |
| Table Games | Dominoes | Descending | Top 10 |
| Table Games | Guesstures | Ascending | Top 10 |
| Table Games | Catchphrase | Ascending | Top 10 |
| Pinball | (created dynamically per machine) | Ascending | Top 3 |
| Card Games | Skyjo | Descending | Top 10 |
| Card Games | Five Kings | Descending | Top 10 |
| Card Games | Blitz | Ascending | Top 10 |
Adjust any of this later from /admin → Games (or add new categories/games entirely) — the board reflects it immediately.
The board can be filtered to a rolling time window based on each score's created_at:
| Interval | interval value |
Meaning |
|---|---|---|
| All Time | all (default) |
Every entry, no lower bound. |
| Last 7 Days | 7d |
Scores from the last 7 × 24 h. |
| Last 3 Days | 3d |
Scores from the last 3 × 24 h. |
Only the entries inside the window are ranked, so a game's top N reflects just that period.
Table Games and Card Games are additive over the window: each player gets a single row whose score is the sum of all their scores for that game in the selected period, then ranked per the game's sort_direction. Switching to Last 7 / 3 Days narrows which scores are summed. Pinball is unchanged — it still ranks individual attempts (best single score). The additive categories are matched by slug (table, cards) in ADDITIVE_CATEGORY_SLUGS in src/lib/leaderboard.ts.
Setting it:
- Footer control on
/— clicking a range refetches immediately and rewrites the URL (?interval=7d, or the param is dropped forall) so the choice survives the daily reload and can be bookmarked or shared. - URL parameter — load
/?interval=7d(or3d, orall) to start on that interval. This is the value the kiosk should be pointed at if you want it to boot into a specific window. An unknown or missing value falls back toall. - API —
GET /api/leaderboard?interval=7dreturns the same filtered payload (see below).
Returns every category, its games, and each game's top N entries (ranked per that game's sort_direction), plus an updatedAt timestamp and the interval the payload was filtered to. Powers both the board's initial server render and its polling refresh.
Optional query param ?interval= — all (default), 7d, or 3d — filters entries by created_at to that rolling window before ranking (see Time interval). Unknown values fall back to all.
Submits a score for a registered player against an existing game.
{ "gameNameId": "uuid", "score": 42, "displayName": "Alex" }Provide either userId (existing player) or displayName (looked up case-insensitively, or created if new). Returns 201 { entry }.
Submits a score for a walk-up guest on a pinball machine. If the named machine hasn't been played before, it's created automatically (top 3, ascending) under the Pinball category — this is how "Various Games" grows over time.
{ "gameName": "Medieval Madness", "customUsername": "Guest 1", "score": 12 }gameNameId may be used instead of gameName to target an existing machine. Returns 201 { entry, game }.
Wipes every existing score for one pinball machine and replaces it with the full list provided — for resyncing a machine's whole scoreboard at once (e.g. from an external tracker), rather than adding one new score at a time.
{
"gameName": "Medieval Madness",
"scores": [
{ "customUsername": "Alex", "score": 12 },
{ "customUsername": "Jordan", "score": 9 }
]
}gameNameId may be used instead of gameName; the machine is auto-created if it doesn't exist yet, same as /api/addPinballScore. scores may be an empty array to just clear the machine. Returns 200 { game, deletedCount, entries }.
Records the latest "the score feed is alive" timestamp. Send an empty body to stamp the server's now(), or pass a specific moment:
{ "timestamp": "2026-08-28T14:00:00Z", "source": "pinball-sync" }Both fields are optional. Returns 200 { beatAt, source, ageMs, stale, staleAfterMs }.
Returns the same { beatAt, source, ageMs, stale, staleAfterMs } shape for the latest heartbeat. The board polls this every 15s and shows a red Pinball Sync Error in the footer once the latest beat is more than 90 seconds old (staleAfterMs).
Enforced in src/proxy.ts (Next.js 16's middleware.ts successor), before any request reaches a page or API route:
Password. An httpOnly session cookie (set on successful POST /api/admin/login, checked against ADMIN_SESSION_TOKEN) gates the entire /admin surface except the login page itself.
Once in, four tabs give full CRUD over categories, games, users, and scores — including correcting a bad score or retiring a pinball machine (set it inactive rather than deleting it, to keep history).
Caveat: there is no network-level restriction on
/admin— anyone who can reach the server can reach the login form. Keep the Pi on a trusted LAN with no port-forwarding, and use a strongADMIN_PASSWORD.
- Point the Pi's browser (Chromium in kiosk mode) at
/, e.g.chromium-browser --kiosk --noerrdialogs --disable-session-crashed-bubble http://<host>:3000. Append?interval=7d(or3d) to the URL to boot into a specific time interval instead of All Time. - The board force-reloads once daily at 4 AM to bound memory growth from a browser tab that's open for weeks; adjust
DAILY_RELOAD_HOURinsrc/components/LeaderboardBoard.tsxif the game room is used around the clock. - Pinball cards lay out across the category row automatically; if you accumulate many machines, use
/admin→ Games to set stale onesis_active = falseso the board keeps fitting a 1080p screen without scrolling.