Product Architecture

Inside the Gaffr engine

Gaffr is built around a single mechanic — Blackjack scoring against a target — and a small, opinionated stack. Below is how data, rules and lifelines actually wire together.

Core season flow
1
Draft 5
User picks 5 players from the pool.
2
Lock
Roster locked for the full season.
3
Score
Custom rules score every fixture.
4
Watch
Total ticks toward 100.
5
Win or bust
Closest to 100 wins. Over → Bust pile.
System diagram

   ┌────────────────────┐     OAuth      ┌────────────────────┐
   │   Player / Admin   │ ─────────────▶ │  Supabase Auth      │
   └─────────┬──────────┘                └──────────┬──────────┘
             │ Next.js App Router (RSC + Edge)      │
             ▼                                       ▼
   ┌────────────────────┐    queries   ┌────────────────────┐
   │  Roster + Lock UI  │ ───────────▶ │  Postgres (RLS)    │
   │  Dashboard         │ ◀─────────── │   - rosters         │
   │  Leaderboard       │   realtime   │   - player_stats    │
   │  Monthly Challenge │              │   - gameweeks       │
   └─────────┬──────────┘              │   - scoring_rules   │
             │                          │   - events_raw      │
             ▼                          └─────────┬──────────┘
   ┌────────────────────┐                         │
   │ Scoring Engine     │ ◀───── pg_cron ─────────┘
   │ (rules x stats)    │
   └─────────┬──────────┘
             ▼
   ┌────────────────────┐    cron      ┌────────────────────┐
   │  Edge Functions    │ ───────────▶ │  FPL Public API    │
   │  /sync-bootstrap   │ ◀─────────── │  /bootstrap-static │
   │  /sync-live        │              │  /event/{id}/live  │
   └────────────────────┘              └────────────────────┘
Scoring engine

Points are not read from FPL. Raw events are ingested and re-scored against our own rule table. This means the same event can score differently in main vs monthly scope.

-- pseudo
SELECT u.id, SUM(r.points * s.count) AS total
FROM rosters u
JOIN player_stats s ON s.player_id = ANY(u.roster)
JOIN scoring_rules r
  ON r.key = s.metric AND r.scope = 'main' AND r.active
GROUP BY u.id;
Goal+1
Assist+1
Yellow+2
Red+3
Lifelines & locks
Roster lock
Once submitted, the season roster is read-only until the January window opens.
January switch
Between Jan 1–31, every user can swap exactly one player. Window enforced by a Postgres CHECK and a pg_cron job that flips a feature flag.
Bust state machine
alive  ──score>100 (pre-Feb)─▶  bust_recoverable
alive  ──score>100 (Feb+)──▶    bust_permanent
bust_recoverable ──switch─▶    alive
Data model
users
  • · id
  • · email
  • · role
  • · switch_used
  • · busted_permanently
rosters
  • · user_id
  • · scope (main/monthly)
  • · player_ids
  • · locked_at
players
  • · id
  • · name
  • · team_id
  • · position
  • · price
player_stats
  • · player_id
  • · gw
  • · goals
  • · assists
  • · yellow
  • · red
  • · tackles
  • · shots
  • · fouls
scoring_rules
  • · key
  • · label
  • · points
  • · scope
  • · active
gameweeks
  • · id
  • · deadline
  • · status
  • · fixtures
Sync pipeline
Pre-season
/sync-bootstrap

Seeds players, teams, gameweek calendar.

Every 60s during live GW
/sync-live?gw=X

Upserts raw events into events_raw. Triggers re-score.

Post-match
recompute_totals()

Materialised view refresh. Realtime push to /leaderboard subscribers.

Daily 04:00 UTC
reconcile()

Diff against FPL canonical totals. Logs anomalies for review.

Non-functional commitments
p95 dashboard load
< 600ms
Sync lag (live)
≤ 90s
RLS coverage
100% of tables
Mobile Lighthouse
≥ 95