Product architecture

Async trivia, end-to-end

How a turn becomes points, how cards stay fair, and how the platform scales from football to any sport.

System overview


 ┌────────────┐    ┌────────────┐    ┌────────────────┐    ┌──────────────┐
 │  Mobile    │───▶│  Edge API  │───▶│  Match engine  │───▶│  Postgres    │
 │ (Flutter)  │    │ (TanStack) │    │  Turn / Card   │    │  (RLS, Auth) │
 └─────┬──────┘    └──────┬─────┘    └────────┬───────┘    └──────┬───────┘
       │                  │                   │                   │
       │            ┌─────▼──────┐     ┌──────▼──────┐     ┌──────▼───────┐
       └───────────▶│ FCM / APNs │◀────│  Rules eng. │     │  Cards / IDX │
                    │  Push      │     │  validate() │     │  pg_trgm     │
                    └────────────┘     └─────────────┘     └──────────────┘
Client (Flutter)

Single binary for iOS + Android. Optimistic local turn UI with reconciliation on server response.

Edge API

Stateless serverless. Validates payloads, applies rate limits, calls match engine.

Match engine

Pure functions: nextClue(), submitAnswer(), scoreTurn(). 100% unit-tested, deterministic.

Postgres + RLS

Sport-agnostic schema with `sport` column. Row-level security keeps users scoped.

Push (FCM + APNs)

Turn change events fan out via webhooks; quiet hours respected per user.

Moderation

Reports + rules engine + admin review keep the catalog clean.

Turn flow (sandwich structure)

  1. 1. Card drawn
    Engine picks next card from match queue (10 per match).
  2. 2. Clue cycle
    Player A receives clues 1–6. Each clue advances on tap or after 20–25s timer.
  3. 3. Answer
    Engine validates against `aliases[]` with accent + case insensitive match.
  4. 4. Score
    Points = 7 − clueIndex (cap 6, min 1). Wrong = 0.
  5. 5. Swap
    Player B faces the same card. Asymmetric info — only knowledge of B's own progress.
  6. 6. Notify
    FCM/APNs dispatch on turn flip; deep-link opens directly to the card.
  7. 7. Tiebreaker
    If tied at card 10 → bonus card. If still tied → fastest millisecond response wins.
  8. 8. Recap
    Both players see final scores + correct answers, with single ad on results screen.

Rules engine


                  ┌──────────────────┐
       answer ───▶│  normalize()     │── strip accents, lowercase, trim
                  └────────┬─────────┘
                           ▼
                  ┌──────────────────┐
                  │  alias match?    │── exact match against card.aliases[]
                  └────────┬─────────┘
                  yes ─────┘    └──── no ───▶ fuzzy match (Levenshtein ≤ 2) ?
                   │                                   │
                   ▼                                   ▼
            score = 7 − clueIdx                   accept or reject
                   │                                   │
                   └───────────────┬───────────────────┘
                                   ▼
                         persist match_turn row

Data model (simplified)

users

id, username, email, country, created_at, premium

friendships

id, user_a, user_b, status, created_at

cards

id, sport, category, answer, aliases[], clues[], difficulty, status

matches

id, sport, player_a, player_b, status, mode, started_at

match_turns

id, match_id, card_id, player, answer, score_ms, points

daily_results

id, day, user_id, points, time_ms

Scale signals

92 ms
p95 latency
99.97%
uptime 30d
42K
matches / day
<2 s
push delivery
© GolTrivia — engineered for async fun.