Skip to content

About

⚽ Unofficial, non-commercial fan project for friends having fun: a season-long football picks league. Not affiliated with, endorsed by, or sponsored by any league, club, or brand; club names are for identification only, no logos/trademarks are owned by this project. Vanilla JS PWA + Firebase.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PrimePicks Arena ⚽

A season-long football prediction league for friend groups, built because every fantasy league I've ever been in was dead by week 12.

Live in production for 50+ players across multiple private groups, covering the 2026-27 season in the EPL, La Liga, Bundesliga, Serie A, and Ligue 1. Access is gated: an account or Google sign-in is required, and there is no public entry point. No frameworks, no build step, no server to maintain: a static site talking to Firebase, with live results syncing themselves from real match data.

It started as a six-person league during the World Cup and grew by word of mouth through a live testing period, which is where most of what follows came from: the scoring rules, the data-integrity work, and the write path were all shaped by players filing complaints against a running season.

The problem it solves

Prediction and fantasy leagues don't die from bad features; they die from mid-season boredom (one player runs away with it, everyone else checks out) and broken trust (wrong fixtures, missing results, "I'll update the spreadsheet later"). Every design decision here attacks one of those two failure modes.

Against boredom: the season is split into calendar-month races with their own trophies, so a bad month never buries you. Weekly auto-paired head-to-head duels give every gameweek personal stakes on a separate ladder. Bold-call bonuses (+1 for a correct outright pick ≤25% of the group dared to make) reward going against the crowd. The final month is a Championship where each monthly trophy banks a +2 head start: mid-table players stay alive until May.

Against broken trust: fixtures were audited game-by-game against live sports data before launch (108 kickoff corrections and one home/away reversal in the official feed caught and fixed). Results sync automatically from ESPN's live scoreboard on every app open plus a 2-minute matchday ticker. A 30-day watch detects TV reschedules and postponements, moves the fixture, carries your pick over, and notifies you. Picks lock server-side at each match's kickoff and stay hidden from other players until then, enforced by Firestore security rules, not the UI, so no client can cheat around it.

Architecture

index.html + app.js      UI: vanilla JS single-page app, phone-first, installable PWA
engine.js                pure scoring engine: no DOM, no network, fully unit-tested
firebase-api.js          data layer (swappable: demo.js provides a fake backend)
data-*.js                1,752 fixtures across 5 leagues (ids are stable row-indices)
data-teams.js            96 clubs: ESPN ids + curated colours + kit-clash resolver
firestore.rules          the fair-play law: kickoff locks, hidden picks, admin gates
qa/                      300 automated checks + a GO/NO-GO preflight gate

Deliberate choices, not accidents:

  • No build step / no framework. The app is ~4 files of plain JS pinned to CDN'd Firebase compat SDK. Anyone can read it, deploy it by drag-and-drop, and nothing bit-rots in a bundler. For a two-screen app with one maintainer, a framework buys complexity, not capability.
  • Pure scoring engine. engine.js recomputes everything from raw picks + results on every render (memoized). No score is ever stored, so no score can ever be wrong in a way that can't be fixed by correcting the inputs.
  • Optimistic writes with real guarantees. Taps paint instantly; Firebase catches up in the background. Rapid taps coalesce into one write of the final choice. Pending writes flush the moment the app is backgrounded, Firestore offline persistence journals them through app kills, and a rejected write visibly rolls back to the last confirmed state; the UI never lies about what saved.
  • Every failure is loud. Result sync reports what it did in the admin panel; postponements surface in a notification bell with each player's pick fate; locked cards say why they're locked.

Design system

Every visual decision resolves to a token in :root: surfaces, text tiers, accents with fixed meanings (green = your pick landed, pink = failure or live, gold = silverware, never decoration), an 8-step type scale, 4 radii, a 4px spacing rhythm. No component rule may hard-code a colour, radius or font size, and the suite fails the build if one does.

That constraint is what made it cheap to prototype four complete visual identities against real data and switch between them live before committing to one. DESIGN-HISTORY.md records the chosen direction, the rejected ones and why, and a step-by-step revert to the previous look, so a design can be rolled back from a file rather than from memory.

Guarding fixture identity

A fixture id is comp-rowIndex, so row position in the data files is identity. Insert, delete or reorder one row and every stored pick silently re-attaches to a different match: no error, no crash, just quietly wrong scores nobody can reconstruct afterwards.

qa/fixture-lock.json pins what all 1,752 ids mean (gameweek|home|away, deliberately not kickoff times, which legitimately move) and qa/lock-test.js fails loudly on any drift. preflight.js runs it alongside the other suites and refuses a GO verdict if identity has changed. It was proven by deleting a single row and watching it catch 376 fixtures shifting meaning.

Data integrity war stories

The three bugs that shaped the pipeline, each found by cross-checking against a second source rather than trusting the first:

  1. The +10h timezone bug. The fixture feed served Australian-Eastern times labeled as UTC. Every La Liga result silently failed to match (kickoffs were "wrong" by 10 hours). Fix: shifted 380 kickoffs, and rebuilt the matcher to pair teams first and use kickoff time only as a tiebreaker.
  2. The reversed fixture. ESPN's audit revealed the feed had PSG @ Rennes flipped (wrong home team). Swapping it broke a round-robin integrity test (because the return leg then duplicated), which caught that both legs needed flipping. The test suite catching the knock-on effect of a manual fix is exactly why the test suite exists.
  3. The phantom lock. A postponed match still carried its old date, showing LOCKED for a game a week away. Fix: postponement healing. When live data shows a matched fixture is actually in the future, the kickoff self-corrects and picks reopen.
  4. The silent feed death. The upstream scoreboard stopped accepting hyphenated date ranges and began returning HTTP 400. Because the fetch failure was swallowed by a bare catch, the app degraded invisibly: no live scores, no results, no reschedule detection, and nothing on screen said so. Fix: month-format queries, a stricter both-teams-must-match rule, and failure counts surfaced in the admin panel so the next outage is loud on day one.

Security model

The client is untrusted. Every rule that matters for fairness is enforced in Firestore security rules, server-side, not in the UI: picks become immutable at each match's kickoff (validated against a server-held timestamp, not a client clock), picks are unreadable by other players until that kickoff, group deletion is impossible through the API, and member and profile documents are shape-validated so nothing arbitrary can be written into them.

Shared match facts — fixtures, kickoff corrections, results — are global, so the right to write them is deliberately not self-service: it requires membership of an allowlist that can only be edited from the Firebase console. An earlier revision gated this on "has this user created a group", which any signed-in user could satisfy for themselves; that is exactly the class of mistake this model is meant to make visible, and it was replaced.

engine.js persists no scores. Every total, trophy and duel is recomputed from raw picks plus results, so there is no derived state for a bad write to hide in, and correcting an input corrects the history.

Hardening still on the roadmap — App Check, per-group isolation of shared facts — is tracked privately rather than enumerated here. See SECURITY.md for how to report something.

Legal and attribution

This is an unofficial, non-commercial fan project built for a private group of friends.

  • Not affiliated with, endorsed by, or sponsored by any football league, club, broadcaster or data provider. Club names appear solely to identify real fixtures; no trademarks, crests or logos are owned by this project, and none are redistributed in this repository.
  • Fixture and result data is read at runtime from third-party endpoints that are not covered by a commercial agreement. Anyone deploying this is responsible for reviewing the terms of whichever data source they point it at, and for respecting rate limits.
  • No money, no wagering, no prizes. Picks score points for bragging rights only. This is not a betting product and must not be operated as one.
  • Provided as-is under the MIT licence, with no warranty. If you run your own instance, the data in it and the obligations that come with it are yours.

Run it

git clone <repo> && cd arena
python3 -m http.server 8080    # → http://localhost:8080 in demo mode (fake players, no backend)

To go live: create a free Firebase project, copy config.example.js → config.js with your keys, publish firestore.rules, follow SETUP.md (~10 minutes), drag the folder to Netlify. setup-check.html verifies every step and tells you what you forgot.

Tests: cd qa && npm i jsdom && node preflight.js: one command, one verdict (GO / HOLD / NO-GO) across 300 checks covering scoring edge cases (double-banker voiding, catch-up allowances, tie handling), full-season data integrity (round-robin correctness, kit-clash-proof colours for all 1,752 fixtures, chronological ordering of all 182 gameweeks), and UI behavior in jsdom (optimistic write coalescing, rollback on rejection, settings-aware rendering).

SAFE-CHANGES.md is the companion: a risk-tiered protocol for changing a running season, built around the asymmetry that code rolls back and data does not.

What I'd do differently

TypeScript from day one: the engine's shape-passing is exactly where types pay for themselves. Real-device test coverage: the one bug that shipped through a fully green suite (laggy pick taps) was a mobile-network-feel problem jsdom can't see. Per-group result isolation in the schema from the start, since retrofitting it is the roadmap's hardest item. And push notifications ("your picks lock in 2 hours") before any other feature: retention is the product's whole thesis, and reminders are retention's sharpest tool.

Honest footnote

Built in collaboration with AI (Anthropic's Claude) over roughly two months: the architecture, phasing, QA direction, fact-checking discipline, and product design are mine, and came from iterating on real failures against a live season with real players, who are ruthless bug reporters. Every line has been read, every tradeoff can be defended.

MIT licensed. Not affiliated with any football league, club, or data provider.

About

⚽ Unofficial, non-commercial fan project for friends having fun: a season-long football picks league. Not affiliated with, endorsed by, or sponsored by any league, club, or brand; club names are for identification only, no logos/trademarks are owned by this project. Vanilla JS PWA + Firebase.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages