Extract the leaderboard into a generic, backend-agnostic package and add rolling Today/Week/Month/All-Time windows, a web component, and local-first development against the Firestore emulator. Package (leaderboard/): - LeaderBoardService: backend-agnostic core via a storage-adapter seam, with Firebase and Supabase adapters (Supabase client injected, no added dep) - Rolling time windows (last 24h / 7d / 30d) with hover tooltips; top-N by score - <cozy-leaderboard> web component built on web-component-base: compose the UI in HTML, configure the backend once in JS - Every user-facing string is configurable (labels, tooltips, empty/loading/ error messages, anonymous name) so i18n lives in the app; README + CONFIGURATION App (mnswpr.com): - Compose the board declaratively in index.html via <cozy-leaderboard> - Nickname + randomized greeting bar; score submission through the element - Legends: the current all-time leaders frozen into a static /legends page - Firebase config and leaderboard namespace via Vite env vars; emulator-first local dev (VITE_FIRESTORE_EMULATOR) Firebase schema-as-code: - firebase.json, .firebaserc, firestore.rules (public reads, create-only scores, no client updates/deletes, namespace-generalized), empty indexes (rolling windows need none) - prod (mw-*) vs dev/test (mw-test-*) separation by collection namespace - emulator config, seed script, and docs (firebase-leaderboards.md, leaderboard-env-migration.md, AYO.md) Utils/tests: UTC date-bucket helper (retained as metadata) with Vitest coverage.
9.4 KiB
Firebase & Leaderboards
How the mnswpr.com leaderboards are stored, how to deploy the schema, and what still has to be touched in the Firebase Console.
Overview
Leaderboards are powered by Firestore (firebase/firestore/lite) through the
reusable, game-agnostic @cozy-games/leaderboard
package. The app wires minesweeper's specifics (finish-time as the score,
ascending sort, time formatting) in app/main.js.
The board offers four time windows — Today (default), Week, Month, All Time — selected by tabs. Each played game that qualifies is written to a new, queryable collection; the four tabs are just different filters over it.
Data model
Collections (all under namespace mw):
| Path | Purpose | Access |
|---|---|---|
mw-scores/{level}/games/{id} |
Ranked entries for the live boards | public read, validated create-only |
mw-all/{browserId}/games/{session} |
Per-browser archive of every game | read/write |
mw-config/configuration |
Server config (passingStatus, message) |
public read, no client write |
mw-leaders/{level}/games/{id} |
Legacy all-time board, now frozen into Legends | read-only |
Each mw-scores entry:
{
name, // display nickname
playerId, // browser fingerprint (UserService)
score, // ranked value — minesweeper: finish time in ms
category, // segmentation — minesweeper: level id
time_stamp, // when the game finished — drives the rolling time windows
day, // "2026-07-03" (UTC calendar day) — retained, not queried
week, // "2026-W27" (ISO week) — retained, not queried
month, // "2026-07" (UTC calendar month) — retained, not queried
meta // optional extras — minesweeper: { isMobile }
}
How the time windows work
The windows are rolling, based on time_stamp, and strictly nested:
- Today →
where('time_stamp', '>=', now - 24h)— last 24 hours - Week →
where('time_stamp', '>=', now - 7d)— last 7 days - Month →
where('time_stamp', '>=', now - 30d)— last 30 days - All Time → no time filter,
orderBy('score')+limit(10)
Because Firestore requires the inequality field (time_stamp) to sort first, the
adapter fetches the in-window rows and ranks them by score client-side (Supabase
does it server-side). A score in the 7-day window is always in the 30-day window,
so a player can be top-10 for the week but rank out of a busier month — that's
competition, not a boundary quirk.
The
day/week/monthbucket keys are still written to each entry (and validated by the rules) but are no longer used for querying — they're kept as denormalized metadata and for a possible calendar-bucket mode.
Schema as code
The Firestore schema (security rules + indexes) lives in the repo:
firebase.json— points at the rules and indexes files..firebaserc— project aliases:prod(default, live site) anddev(secure-moment-188701).firestore.rules— access + validation rules.firestore.indexes.json— empty: rolling windows (time_stamp >=) and all-time (orderBy('score')) use Firestore's automatic single-field indexes, so no composite indexes are needed.
Environments (dev vs production)
There is one Firebase project (secure-moment-188701). Production and test
data are separated not by project but by collection namespace, chosen with
the VITE_LB_NAMESPACE env var read in app/main.js:
| Environment | VITE_LB_NAMESPACE |
Collections |
|---|---|---|
| Production (Netlify) | mw |
mw-scores, mw-all, mw-config |
Local pnpm dev / previews |
mw-test |
mw-test-scores, mw-test-all, mw-test-config |
The Firebase web config (VITE_FIREBASE_*) is identical in both — same
project — so the only difference is the namespace. app/main.js defaults to the
test namespace, so a missing/misconfigured var can never write into the
production board; production must set VITE_LB_NAMESPACE=mw explicitly.
Dev config lives in the committed app/.env.development
(the keys are public). Production sets VITE_FIREBASE_* and
VITE_LB_NAMESPACE=mw as Netlify environment variables. .env.production and
.env*.local stay gitignored.
The full rationale and rollout steps are in leaderboard-env-migration.md.
One-time CLI setup
The Firebase project already exists — no need to create it.
npx firebase login
Deploy rules + indexes
Deploys go to production by default. Target a project explicitly with
--project:
# production (default alias 'prod')
npx firebase deploy --only firestore:rules,firestore:indexes --project prod
# development database
npx firebase deploy --only firestore:rules,firestore:indexes --project dev
⚠️ Deploying replaces whatever rules currently live in the Console. The committed
firestore.rulesis written to cover every collection the app uses, so a deploy is safe — but review it first.
No composite indexes are required — the rolling-window and all-time queries use Firestore's automatic single-field indexes.
Local Firestore emulator (default for local dev)
Local development runs against the Firebase Local Emulator Suite by default —
no cloud, no deploy, no auth, no permission-denied — and it loads the committed
firestore.rules and firestore.indexes.json
locally (so you validate them before deploying). The flag
VITE_FIRESTORE_EMULATOR=1 is set in app/.env.development.
Prerequisite: the Firestore emulator is a Java process, so you need a JDK (11+) installed.
firebase-toolsis fetched on demand vianpx.
Everyday dev loop:
pnpm emulators # terminal 1: Firestore emulator (+ Emulator UI) on :8080
pnpm seed:emulator # terminal 2, once: fill it with sample scores
pnpm dev # terminal 2: app runs against the local emulator
Wiring: app/main.js passes { emulator: { host, port } } to FirebaseAdapter,
which calls connectFirestoreEmulator. If the emulator isn't running the board
just shows "unavailable" (a refused connection) — start it, or opt out.
Opting out (no Java, or you want the real cloud mw-test): create
app/.env.local with VITE_FIRESTORE_EMULATOR= (empty) — .env.local overrides
.env.development and is gitignored.
The emulator is disposable — its data is gone on stop unless you use Firebase's
--import/--export-on-exit.
Console fallback
Everything above is doable via the CLI. If you can't use it:
- Indexes: nothing to do — the queries use automatic single-field indexes. (If you ever add a query that needs a composite index, Firestore prints a console error with a direct link to create it.)
- Rules: edit them directly under Firestore → Rules in the Console
(paste from
firestore.rules). - Server config:
mw-config/configuration(passingStatus,message) is always managed by hand in the Console — it is read-only to clients and has no code representation.passingStatusis thestatusvalue that makes a game eligible for the board (minesweeper:"win").
Legends (frozen hall of fame)
The old all-time leaders are preserved as a fully-rendered static page —
app/legends.html. The records are baked straight into the
HTML with times pre-formatted; there is no JavaScript and no Firebase at page
load. The data never changes.
The page is generated by scripts/export-legends.js,
which snapshots mw-leaders/{level}/games and writes the whole legends.html.
It only needs re-running if you ever want to regenerate. Because firebase is an
app-workspace dependency, run it so Node can resolve it:
(cd app && node ../scripts/export-legends.js)
The Firebase config for this one-off is hardcoded in the script (public keys).
Reusing the leaderboard for another game
@cozy-games/leaderboard knows nothing about minesweeper or Firebase — the
ranked value is a generic score and all storage goes through an injected
adapter. Another game supplies its own adapter and config:
import { LeaderBoardService } from '@cozy-games/leaderboard/leader-board.js'
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
new LeaderBoardService({
adapter: new FirebaseAdapter({ firebaseConfig, namespace: 'yourgame' }),
scoreOrder: 'desc', // 'desc' when higher is better (points); 'asc' for time
formatScore: v => `${v} pts`,
qualifies: entry => true // default: server passingStatus vs entry.status
})
To run on Supabase instead, swap in SupabaseAdapter — nothing else in the
game changes. The adapter interface and the Supabase table/SQL schema are
documented in leaderboard/README.md.
Then submit { name, playerId, score, category, time_stamp, status?, meta? }
and render with render(category, title, duration).
No composite indexes needed. Rolling windows filter on time_stamp and
all-time sorts by score — both use Firestore's automatic single-field indexes,
in either sort direction, so a desc-scored game needs no extra index work.