[object Object]

← back to CelebritySignatures

mobile: port all 6 desktop games into GameScreen (was whose-only)

3d5785696f8bc0c0ac666320bd9bee603aac826d · 2026-08-05 17:08:48 -0700 · steve

Adds Who Signed This?, Match the Hand, Early or Late?, Date the Hand, and
Lightning Round alongside Whose Hand? — plus the game picker, 4 difficulty
tiers, category filter, per-game scoring/streak bonuses, results verdict +
misses, per-(game,diff) leaderboard submit, and native Share. Pure front-end
port; backend APIs (signatures/evolution/leaderboard) already supported it.
Validated vs live data: 1421 Artists, 7197 dated, 51 evolution people.

Files touched

Diff

commit 3d5785696f8bc0c0ac666320bd9bee603aac826d
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 17:08:48 2026 -0700

    mobile: port all 6 desktop games into GameScreen (was whose-only)
    
    Adds Who Signed This?, Match the Hand, Early or Late?, Date the Hand, and
    Lightning Round alongside Whose Hand? — plus the game picker, 4 difficulty
    tiers, category filter, per-game scoring/streak bonuses, results verdict +
    misses, per-(game,diff) leaderboard submit, and native Share. Pure front-end
    port; backend APIs (signatures/evolution/leaderboard) already supported it.
    Validated vs live data: 1421 Artists, 7197 dated, 51 evolution people.
---
 apps/mobile/screens/GameScreen.tsx | 1493 +++++++++++++++++++++---------------
 1 file changed, 894 insertions(+), 599 deletions(-)

diff --git a/apps/mobile/screens/GameScreen.tsx b/apps/mobile/screens/GameScreen.tsx
index 6c35d16..cc74310 100644
--- a/apps/mobile/screens/GameScreen.tsx
+++ b/apps/mobile/screens/GameScreen.tsx
@@ -1,33 +1,34 @@
 /**
- * GameScreen — "Whose signature is this?"
+ * GameScreen — "The Signature Games" (full 6-game suite, ported from the desktop
+ * web build at public/game.html).
  *
- * Usage (in App/navigation):
- *   import GameScreen from './screens/GameScreen';
- *   // Rendered inside GlassScene by the app shell — do NOT wrap here.
- *   <GameScreen />
+ * Games:
+ *   whose     Whose Hand?      A mystery signature, four names.
+ *   art       Who Signed This? A real museum artwork — which artist's hand made it?
+ *   match     Match the Hand   One signature, four artworks. Pick the one they signed.
+ *   early     Early or Late?   Two signatures, same person, years apart. Which came first?
+ *   century   Date the Hand    Read the ink — which century did this hand belong to?
+ *   lightning ⚡ Lightning Round A painting flashes up. Name the ARTIST, then the TITLE
+ *                              — 5 seconds each. Both right = bonus.
  *
- * Rules:
- *  • 10 questions per game, 4 choices each (1 correct + 3 wrong), shuffled.
- *  • Correct tap  → gold flash + haptic success + increment score.
- *  • Wrong tap    → haptic error + reveal correct name + end streak (next Q).
- *  • Game-over card shows score, name TextInput, Submit button → rank display.
- *  • Top-5 leaderboard rendered below at all times (fetched on mount + after submit).
+ * Flow: setup (pick game + difficulty + category) → prep (build rounds, fetch
+ * artworks for art/match/lightning) → play (10 rounds, tap to answer, Next to
+ * advance) → results (score, verdict, misses, best, leaderboard submit + share).
+ *
+ * Backend: all data comes from the existing celebsignatures.com JSON APIs via
+ * `api` (signatures, evolution, leaderboard). Public-domain artworks are fetched
+ * client-side from the Art Institute of Chicago (keyless, no CORS issue on native).
  */
 
-import React, {
-  useCallback,
-  useEffect,
-  useMemo,
-  useReducer,
-  useRef,
-  useState,
-} from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   ActivityIndicator,
   Animated,
   Image,
+  Linking,
   Pressable,
   ScrollView,
+  Share,
   StyleSheet,
   Text,
   TextInput,
@@ -35,34 +36,61 @@ import {
 } from 'react-native';
 import * as Haptics from 'expo-haptics';
 
-import { api, Signature, LeaderRow } from '../api';
+import { api, Signature, LeaderRow, Evolution, qidOf } from '../api';
 import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';
 
 // ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-const TOTAL_QUESTIONS = 10;
-const CHOICES = 4;
-const GAME_ID = 'whose';
-const DIFF_ID = 'icons';
-
-// ---------------------------------------------------------------------------
-// Types
+// Static game / option definitions (mirror public/game.html)
 // ---------------------------------------------------------------------------
 
-type Question = {
-  signature: Signature;          // correct answer
-  choices: string[];             // shuffled full_names (length === CHOICES)
-  correctIndex: number;          // index in choices that is correct
+type GameKey = 'whose' | 'art' | 'match' | 'early' | 'century' | 'lightning';
+
+const GAMES: { key: GameKey; title: string; desc: string }[] = [
+  { key: 'whose', title: 'Whose Hand?', desc: 'A mystery signature, four names. The classic.' },
+  { key: 'art', title: 'Who Signed This?', desc: "A real museum artwork — which artist's hand made it?" },
+  { key: 'match', title: 'Match the Hand', desc: 'One signature, four artworks. Pick the piece they signed.' },
+  { key: 'early', title: 'Early or Late?', desc: 'Two signatures, same person, years apart. Which came first?' },
+  { key: 'century', title: 'Date the Hand', desc: 'Read the ink — which century did this hand belong to?' },
+  { key: 'lightning', title: '⚡ Lightning Round', desc: 'A painting flashes up. Name the ARTIST, then the TITLE — 5s each. Both right = bonus.' },
+];
+
+const DIFFS: { key: string; label: string; note: string }[] = [
+  { key: 'icons', label: 'Icons', note: 'top 100' },
+  { key: 'scholar', label: 'Scholar', note: 'top 500' },
+  { key: 'curator', label: 'Curator', note: 'top 2,000' },
+  { key: 'deep', label: 'Deep Cuts', note: 'everyone' },
+];
+const DIFF_N: Record<string, number> = { icons: 100, scholar: 500, curator: 2000, deep: Infinity };
+
+const CATS: { key: string; label: string }[] = [
+  { key: 'All', label: 'Everything' },
+  { key: 'Artists', label: 'Artists' },
+  { key: 'Authors', label: 'Authors' },
+  { key: 'Politics', label: 'Politics' },
+  { key: 'Declaration of Independence', label: 'Declaration' },
+];
+
+const GAME_NAMES: Record<GameKey, string> = {
+  whose: 'Whose Hand?', art: 'Who Signed This?', match: 'Match the Hand',
+  early: 'Early or Late?', century: 'Date the Hand', lightning: '⚡ Lightning Round',
+};
+const PROMPTS: Record<GameKey, string> = {
+  whose: 'Whose hand wrote this?',
+  art: 'Who signed this piece of art?',
+  match: 'Which of these did they sign?',
+  early: 'Same hand, years apart — which is the EARLIER signature?',
+  century: 'Which century did this hand belong to?',
+  lightning: '⚡ Quick!',
 };
 
-type FeedbackState = 'idle' | 'correct' | 'wrong';
+const TOTAL = 10;
+const LIGHTNING_MS = 5000;
 
-type GamePhase = 'loading' | 'error' | 'playing' | 'over';
+// module-level best-score cache (no AsyncStorage dep; resets on app restart)
+const BEST: Record<string, number> = {};
 
 // ---------------------------------------------------------------------------
-// Helpers
+// Helpers (ported from game.html)
 // ---------------------------------------------------------------------------
 
 function shuffle<T>(arr: T[]): T[] {
@@ -74,99 +102,258 @@ function shuffle<T>(arr: T[]): T[] {
   return a;
 }
 
-/** Pick `n` distinct items from `pool`, excluding `exclude`. */
-function pickDistinct<T>(pool: T[], exclude: T, n: number): T[] {
-  const candidates = pool.filter((x) => x !== exclude);
-  return shuffle(candidates).slice(0, n);
+const notability = (r: Signature): number => {
+  const m = (r.reason_for_ranking || '').match(/(\d+) language/);
+  return m ? +m[1] : 0;
+};
+const centuryOf = (r: Signature): number | null => {
+  const y = parseInt(String(r.death_date).slice(0, 4), 10);
+  return isNaN(y) ? null : Math.ceil(y / 100);
+};
+const centuryLabel = (c: number): string => {
+  const suf = c % 10 === 1 && c !== 11 ? 'st'
+    : c % 10 === 2 && c !== 12 ? 'nd'
+    : c % 10 === 3 && c !== 13 ? 'rd' : 'th';
+  return `${c}${suf} century`;
+};
+
+type Artwork = { img: string; title: string; date: string; url: string };
+const ART_CACHE: Record<string, Artwork | null> = {};
+
+/** One public-domain artwork per artist via the Art Institute of Chicago. */
+async function artworkFor(r: Signature): Promise<Artwork | null> {
+  const qid = qidOf(r) || r.full_name;
+  if (qid in ART_CACHE) return ART_CACHE[qid];
+  let out: Artwork | null = null;
+  try {
+    const last = r.full_name.toLowerCase().split(' ').pop() || '';
+    const res = await fetch(
+      `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(r.full_name)}` +
+      `&query[term][is_public_domain]=true&fields=id,title,image_id,artist_title,date_display&limit=8`,
+    );
+    const j = await res.json();
+    const hits = (j.data || []).filter(
+      (a: any) => a.image_id && (a.artist_title || '').toLowerCase().includes(last),
+    );
+    if (hits.length) {
+      const a = hits[Math.floor(Math.random() * Math.min(hits.length, 4))];
+      out = {
+        img: `https://www.artic.edu/iiif/2/${a.image_id}/full/600,/0/default.jpg`,
+        title: a.title || 'Untitled',
+        date: a.date_display || '',
+        url: `https://www.artic.edu/artworks/${a.id}`,
+      };
+    }
+  } catch {
+    /* leave out=null */
+  }
+  ART_CACHE[qid] = out;
+  return out;
 }
 
-/**
- * Build a full 10-question deck from the available pool.
- * Each question needs a signature image URL; the pool is already filtered for that.
- */
-function buildDeck(pool: Signature[]): Question[] {
-  const allNames = pool.map((s) => s.full_name);
-  const picked = shuffle(pool).slice(0, TOTAL_QUESTIONS);
-
-  return picked.map((sig) => {
-    const wrong = pickDistinct(allNames, sig.full_name, CHOICES - 1);
-    const mixed = shuffle([sig.full_name, ...wrong]);
-    return {
-      signature: sig,
-      choices: mixed,
-      correctIndex: mixed.indexOf(sig.full_name),
-    };
-  });
+/** Near-category, notability-ranked wrong answers. */
+function distractors(answer: Signature, p: Signature[], n = 3): Signature[] {
+  const near = p.filter((r) => r !== answer && r.category === answer.category);
+  const ranked = [...near].sort(
+    (a, b) => Math.abs(notability(a) - notability(answer)) - Math.abs(notability(b) - notability(answer)),
+  );
+  const ds = shuffle(ranked.slice(0, 24)).slice(0, n);
+  while (ds.length < n) {
+    const x = shuffle(p).find((r) => r !== answer && !ds.includes(r));
+    if (!x) break;
+    ds.push(x);
+  }
+  return ds;
 }
 
 // ---------------------------------------------------------------------------
-// Sub-components
+// Round model (discriminated union)
 // ---------------------------------------------------------------------------
 
-function LoadingView() {
-  return (
-    <View style={styles.centeredFill}>
-      <ActivityIndicator size="large" color={GLASS.accent} />
-      <Text style={styles.dimText}>Loading signatures…</Text>
-    </View>
-  );
+type WhoseRound = { kind: 'whose'; answer: Signature; options: Signature[] };
+type ArtRound = { kind: 'art'; answer: Signature; artwork: Artwork; options: Signature[] };
+type MatchRound = { kind: 'match'; answer: Signature; artwork: Artwork; options: { owner: Signature; art: Artwork }[] };
+type EarlyRound = { kind: 'early'; person: string; qid: string; leftUrl: string; rightUrl: string; earlier: 'left' | 'right'; years: [number, number] };
+type CenturyRound = { kind: 'century'; answer: Signature; century: number; options: number[] };
+type LightningRound = { kind: 'lightning'; q: 'artist' | 'title'; pid: string; painting: { artist: Signature; title: string; img: string; url: string }; correct: string; options: string[] };
+type Round = WhoseRound | ArtRound | MatchRound | EarlyRound | CenturyRound | LightningRound;
+
+function poolFor(gameKey: GameKey, diff: string, cat: string, ALL: Signature[]): Signature[] {
+  const c = gameKey === 'whose' || gameKey === 'century' ? cat : 'Artists';
+  const p = c === 'All' ? ALL : ALL.filter((r) => r.category === c);
+  return [...p].sort((a, b) => notability(b) - notability(a)).slice(0, DIFF_N[diff]);
 }
 
-function ErrorView({ onRetry }: { onRetry: () => void }) {
-  return (
-    <View style={styles.centeredFill}>
-      <Text style={styles.errorText}>Could not load signatures.</Text>
-      <View style={styles.retryBtn}>
-        <GlassButton label="Retry" onPress={onRetry} solid />
-      </View>
-    </View>
-  );
-}
+async function buildRounds(
+  gameKey: GameKey,
+  diff: string,
+  cat: string,
+  ALL: Signature[],
+  EVO: Evolution,
+  setPrep: (s: string) => void,
+): Promise<Round[]> {
+  const p = poolFor(gameKey, diff, cat, ALL);
+  const rounds: Round[] = [];
+
+  if (gameKey === 'whose') {
+    if (p.length < 8) throw new Error('Not enough signatures in this tier.');
+    for (const answer of shuffle(p).slice(0, TOTAL)) {
+      rounds.push({ kind: 'whose', answer, options: shuffle([answer, ...distractors(answer, p)]) });
+    }
+    return rounds;
+  }
 
-// ---------------------------------------------------------------------------
-// Gold flash overlay (Animated) shown briefly on correct answer
-// ---------------------------------------------------------------------------
+  if (gameKey === 'century') {
+    const dated = p.filter((r) => centuryOf(r));
+    if (dated.length < TOTAL) throw new Error('Not enough dated signatures in this tier.');
+    for (const answer of shuffle(dated).slice(0, TOTAL)) {
+      const c = centuryOf(answer)!;
+      const opts = new Set<number>([c]);
+      let step = 1;
+      while (opts.size < 4) {
+        const cand = c + (Math.random() < 0.5 ? -step : step);
+        if (cand >= 6 && cand <= 21) opts.add(cand);
+        step = Math.min(step + 1, 6);
+      }
+      rounds.push({ kind: 'century', answer, century: c, options: shuffle([...opts]) });
+    }
+    return rounds;
+  }
 
-function CorrectFlash({ visible }: { visible: boolean }) {
-  const opacity = useRef(new Animated.Value(0)).current;
+  if (gameKey === 'early') {
+    const people = Object.entries(EVO)
+      .map(([qid, e]) => ({ qid, e, dated: (e.sigs || []).filter((s) => s.year) }))
+      .filter((x) => new Set(x.dated.map((s) => s.year)).size >= 2);
+    if (people.length < 5) throw new Error('Evolution data still loading — try again in a moment.');
+    for (let i = 0; i < TOTAL; i++) {
+      const P = people[Math.floor(Math.random() * people.length)];
+      const years = shuffle([...new Set(P.dated.map((s) => s.year as number))]);
+      const [y1, y2] = [years[0], years[1]].sort((a, b) => a - b);
+      const sigA = P.dated.find((s) => s.year === y1)!;
+      const sigB = P.dated.find((s) => s.year === y2)!;
+      const flip = Math.random() < 0.5;
+      rounds.push({
+        kind: 'early',
+        person: P.e.name,
+        qid: P.qid,
+        leftUrl: flip ? sigB.url : sigA.url,
+        rightUrl: flip ? sigA.url : sigB.url,
+        earlier: flip ? 'right' : 'left',
+        years: [y1, y2],
+      });
+    }
+    return rounds;
+  }
 
-  useEffect(() => {
-    if (visible) {
-      Animated.sequence([
-        Animated.timing(opacity, { toValue: 0.35, duration: 80, useNativeDriver: true }),
-        Animated.timing(opacity, { toValue: 0, duration: 320, useNativeDriver: true }),
-      ]).start();
+  if (gameKey === 'lightning') {
+    const cands = shuffle(p);
+    let idx = 0;
+    const paintings: { artist: Signature; title: string; img: string; url: string }[] = [];
+    while (paintings.length < 8 && idx < cands.length) {
+      const batch = cands.slice(idx, idx + 6);
+      idx += 6;
+      setPrep(`Loading the gallery… ${paintings.length}/8 paintings ready`);
+      const arts = await Promise.all(batch.map(artworkFor));
+      for (let i = 0; i < batch.length && paintings.length < 8; i++) {
+        if (arts[i]) paintings.push({ artist: batch[i], title: arts[i]!.title, img: arts[i]!.img, url: arts[i]!.url });
+      }
     }
-  }, [visible, opacity]);
+    if (paintings.length < 5) throw new Error('Not enough museum paintings at this tier — try Icons or Scholar.');
+    const nameOf = (x: typeof paintings[number]) => x.artist.full_name;
+    const titleOf = (x: typeof paintings[number]) => x.title;
+    for (const pt of paintings) {
+      const otherA = shuffle(paintings.filter((x) => x !== pt && nameOf(x) !== nameOf(pt))).slice(0, 3);
+      const otherT = shuffle(paintings.filter((x) => x !== pt && titleOf(x) !== titleOf(pt))).slice(0, 3);
+      if (otherA.length < 3 || otherT.length < 3) continue;
+      rounds.push({ kind: 'lightning', q: 'artist', pid: pt.url, painting: pt, correct: nameOf(pt), options: shuffle([nameOf(pt), ...otherA.map(nameOf)]) });
+      rounds.push({ kind: 'lightning', q: 'title', pid: pt.url, painting: pt, correct: titleOf(pt), options: shuffle([titleOf(pt), ...otherT.map(titleOf)]) });
+    }
+    if (rounds.length < 8) throw new Error('Not enough distinct paintings — try Icons or Scholar.');
+    return rounds;
+  }
 
-  return (
-    <Animated.View
-      pointerEvents="none"
-      style={[StyleSheet.absoluteFill, { backgroundColor: GLASS.accent, opacity, borderRadius: 22 }]}
-    />
-  );
+  // art + match — both need real artworks
+  const cands = shuffle(p);
+  let idx = 0;
+  const staged: { answer: Signature; artwork: Artwork }[] = [];
+  while (staged.length < TOTAL && idx < cands.length) {
+    const batch = cands.slice(idx, idx + 6);
+    idx += 6;
+    setPrep(`Hanging the gallery… ${staged.length}/${TOTAL} rooms ready`);
+    const arts = await Promise.all(batch.map(artworkFor));
+    for (let i = 0; i < batch.length && staged.length < TOTAL; i++) {
+      if (arts[i]) staged.push({ answer: batch[i], artwork: arts[i]! });
+    }
+  }
+
+  if (gameKey === 'art') {
+    for (const s of staged) {
+      rounds.push({ kind: 'art', answer: s.answer, artwork: s.artwork, options: shuffle([s.answer, ...distractors(s.answer, p)]) });
+    }
+    if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
+    return rounds;
+  }
+
+  // match — each round needs 3 distractor ARTWORKS too
+  for (const s of staged) {
+    setPrep(`Matching hands… ${rounds.length}/${staged.length} ready`);
+    const ds = distractors(s.answer, p, 8);
+    const arts: { owner: Signature; art: Artwork }[] = [];
+    for (const d of ds) {
+      if (arts.length >= 3) break;
+      const a = await artworkFor(d);
+      if (a) arts.push({ owner: d, art: a });
+    }
+    if (arts.length < 3) continue;
+    rounds.push({
+      kind: 'match',
+      answer: s.answer,
+      artwork: s.artwork,
+      options: shuffle([{ owner: s.answer, art: s.artwork }, ...arts.slice(0, 3)]),
+    });
+  }
+  if (rounds.length < 6) throw new Error('Not enough museum artworks at this tier — try Icons or Scholar.');
+  return rounds.slice(0, TOTAL);
 }
 
 // ---------------------------------------------------------------------------
-// Leaderboard panel
+// Small UI atoms
 // ---------------------------------------------------------------------------
 
-function LeaderboardPanel({ rows, loading }: { rows: LeaderRow[]; loading: boolean }) {
+function OptionChip({
+  label, sub, active, onPress,
+}: { label: string; sub?: string; active: boolean; onPress: () => void }) {
+  return (
+    <Pressable onPress={onPress} style={[styles.chip, active && styles.chipActive]}>
+      <Text style={[styles.chipText, active && styles.chipTextActive]}>{label}</Text>
+      {sub ? <Text style={[styles.chipSub, active && styles.chipTextActive]}> {sub}</Text> : null}
+    </Pressable>
+  );
+}
+
+function LoadingView({ label }: { label: string }) {
+  return (
+    <View style={styles.centeredFill}>
+      <ActivityIndicator size="large" color={GLASS.accent} />
+      <Text style={styles.dimText}>{label}</Text>
+    </View>
+  );
+}
+
+function LeaderboardPanel({ rows, loading, tag }: { rows: LeaderRow[]; loading: boolean; tag?: string }) {
   return (
     <GlassPanel style={styles.leaderPanel}>
-      <Text style={styles.sectionTitle}>Top Scores</Text>
+      <Text style={styles.sectionTitle}>🏆 Leaderboard{tag ? ` · ${tag}` : ''}</Text>
       {loading ? (
         <ActivityIndicator color={GLASS.accent} style={{ marginTop: 8 }} />
       ) : rows.length === 0 ? (
-        <Text style={styles.dimText}>No scores yet — be the first!</Text>
+        <Text style={styles.dimText}>Be the first — post your score!</Text>
       ) : (
         rows.slice(0, 5).map((row, i) => (
-          <View key={row.id} style={styles.leaderRow}>
+          <View key={row.id ?? `${row.name}-${i}`} style={styles.leaderRow}>
             <Text style={styles.leaderRank}>{i + 1}.</Text>
             <Text style={styles.leaderName} numberOfLines={1}>{row.name}</Text>
-            <GlassPill tintColor={GLASS.accent}>
-              <Text style={styles.leaderScore}>{row.score}</Text>
-            </GlassPill>
+            <GlassPill tintColor={GLASS.accent}><Text style={styles.leaderScore}>{row.score}</Text></GlassPill>
           </View>
         ))
       )}
@@ -175,566 +362,674 @@ function LeaderboardPanel({ rows, loading }: { rows: LeaderRow[]; loading: boole
 }
 
 // ---------------------------------------------------------------------------
-// Game-over card
+// Main
 // ---------------------------------------------------------------------------
 
-type GameOverCardProps = {
-  score: number;
-  onPlayAgain: () => void;
-};
+type Phase = 'setup' | 'prep' | 'playing' | 'over';
 
-function GameOverCard({ score, onPlayAgain }: GameOverCardProps) {
-  const [name, setName] = useState('');
-  const [submitting, setSubmitting] = useState(false);
-  const [rank, setRank] = useState<number | null>(null);
-  const [submitError, setSubmitError] = useState('');
+export default function GameScreen() {
+  // catalog
+  const [ALL, setALL] = useState<Signature[]>([]);
+  const [EVO, setEVO] = useState<Evolution>({});
+  const [dataError, setDataError] = useState(false);
+
+  // selections
+  const [gameKey, setGameKey] = useState<GameKey>('whose');
+  const [diff, setDiff] = useState('icons');
+  const [cat, setCat] = useState('All');
+
+  // run state
+  const [phase, setPhase] = useState<Phase>('setup');
+  const [prepMsg, setPrepMsg] = useState('Hanging the gallery…');
+  const [startError, setStartError] = useState('');
+  const [rounds, setRounds] = useState<Round[]>([]);
+  const [i, setI] = useState(0);
+  const [score, setScore] = useState(0);
+  const [streak, setStreak] = useState(0);
+  const [misses, setMisses] = useState<{ full_name: string; qid: string | null }[]>([]);
+  const [answered, setAnswered] = useState(false);
+  const [pick, setPick] = useState<number | 'left' | 'right' | 'timeout' | null>(null);
+  const lightningRes = useRef<Record<string, { artist: boolean; title: boolean }>>({});
+  const lockRef = useRef(false);
+
+  // leaderboard
+  const [leaderRows, setLeaderRows] = useState<LeaderRow[]>([]);
+  const [leaderLoading, setLeaderLoading] = useState(false);
 
-  const handleSubmit = useCallback(async () => {
-    const trimmed = name.trim();
-    if (!trimmed) return;
-    setSubmitting(true);
-    setSubmitError('');
+  // lightning timer
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const barW = useRef(new Animated.Value(1)).current;
+
+  const catRelevant = gameKey === 'whose' || gameKey === 'century';
+
+  // --- load catalog (on mount + on retry) ---
+  const loadCatalog = useCallback(async () => {
+    setDataError(false);
     try {
-      const res = await api.leaderboardSubmit(GAME_ID, DIFF_ID, score, trimmed);
-      if (res.ok && res.rank != null) {
-        setRank(res.rank);
-      } else {
-        setSubmitError('Submit failed — try again.');
-      }
+      const [sigs, evo] = await Promise.all([api.signatures(), api.evolution().catch(() => ({} as Evolution))]);
+      setALL(sigs.filter((r) => r.signature_image_url && /wikimedia|upload\./.test(r.signature_image_url)));
+      setEVO(evo || {});
     } catch {
-      setSubmitError('Network error.');
-    } finally {
-      setSubmitting(false);
+      setDataError(true);
     }
-  }, [name, score]);
-
-  return (
-    <GlassPanel style={styles.gameOverPanel}>
-      <Text style={styles.gameOverTitle}>Game Over</Text>
-
-      <View style={styles.scoreRow}>
-        <GlassPill tintColor={GLASS.accent}>
-          <Text style={styles.finalScoreText}>
-            {score} / {TOTAL_QUESTIONS}
-          </Text>
-        </GlassPill>
-      </View>
+  }, []);
 
-      {rank != null ? (
-        <Text style={styles.rankText}>You ranked #{rank} — nice!</Text>
-      ) : (
-        <>
-          <TextInput
-            style={styles.nameInput}
-            placeholder="Your name"
-            placeholderTextColor={GLASS.textDim}
-            value={name}
-            onChangeText={setName}
-            maxLength={32}
-            returnKeyType="done"
-            autoCorrect={false}
-          />
-          {submitError !== '' && (
-            <Text style={styles.errorText}>{submitError}</Text>
-          )}
-          <View style={styles.submitBtn}>
-            <GlassButton
-              label="Submit score"
-              onPress={handleSubmit}
-              solid
-              busy={submitting}
-              disabled={name.trim().length === 0}
-            />
-          </View>
-        </>
-      )}
+  useEffect(() => { loadCatalog(); }, [loadCatalog]);
 
-      <View style={styles.playAgainBtn}>
-        <GlassButton label="Play again" onPress={onPlayAgain} />
-      </View>
-    </GlassPanel>
-  );
-}
+  const clearTimer = useCallback(() => {
+    if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
+    barW.stopAnimation();
+  }, [barW]);
 
-// ---------------------------------------------------------------------------
-// Main GameScreen
-// ---------------------------------------------------------------------------
+  useEffect(() => () => clearTimer(), [clearTimer]);
 
-export default function GameScreen() {
-  // --- Data loading ---
-  const [phase, setPhase] = useState<GamePhase>('loading');
-  const [pool, setPool] = useState<Signature[]>([]);
+  // --- start a game ---
+  const begin = useCallback(async () => {
+    if (!ALL.length) return;
+    setPhase('prep');
+    setPrepMsg('Hanging the gallery…');
+    setStartError('');
+    try {
+      const r = await buildRounds(gameKey, diff, cat, ALL, EVO, setPrepMsg);
+      lightningRes.current = {};
+      setRounds(r);
+      setI(0); setScore(0); setStreak(0); setMisses([]);
+      setAnswered(false); setPick(null); lockRef.current = false;
+      setPhase('playing');
+    } catch (e: any) {
+      // surface the reason on the setup screen
+      setStartError(e?.message || 'Could not start this game.');
+      setPrepMsg('Hanging the gallery…');
+      setPhase('setup');
+    }
+  }, [ALL, EVO, gameKey, diff, cat]);
 
-  // --- Leader board ---
-  const [leaderRows, setLeaderRows] = useState<LeaderRow[]>([]);
-  const [leaderLoading, setLeaderLoading] = useState(true);
+  // --- lightning timer per round ---
+  const round = rounds[i];
+  useEffect(() => {
+    clearTimer();
+    if (phase === 'playing' && round?.kind === 'lightning' && !answered) {
+      barW.setValue(1);
+      Animated.timing(barW, { toValue: 0, duration: LIGHTNING_MS, useNativeDriver: false }).start();
+      timerRef.current = setTimeout(() => answer('timeout'), LIGHTNING_MS);
+    }
+    return clearTimer;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [phase, i, answered, round?.kind]);
+
+  // --- answer a round ---
+  function answer(choice: number | 'left' | 'right' | 'timeout') {
+    if (lockRef.current || answered || !round) return;
+    lockRef.current = true;
+    clearTimer();
+    setPick(choice);
+    setAnswered(true);
+
+    let right = false;
+    const addMiss = (m: { full_name: string; qid: string | null }) => setMisses((prev) => [...prev, m]);
+
+    if (round.kind === 'whose' || round.kind === 'art') {
+      const chosen = round.options[choice as number];
+      right = chosen === round.answer;
+      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
+    } else if (round.kind === 'century') {
+      right = round.options[choice as number] === round.century;
+      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
+    } else if (round.kind === 'match') {
+      right = round.options[choice as number].owner === round.answer;
+      if (!right) addMiss({ full_name: round.answer.full_name, qid: qidOf(round.answer) });
+    } else if (round.kind === 'early') {
+      right = choice === round.earlier;
+      if (!right) addMiss({ full_name: round.person, qid: round.qid });
+    } else if (round.kind === 'lightning') {
+      const chosen = choice === 'timeout' ? null : round.options[choice as number];
+      right = chosen === round.correct;
+      const rec = (lightningRes.current[round.pid] = lightningRes.current[round.pid] || { artist: false, title: false });
+      rec[round.q] = right;
+      if (!right && round.q === 'artist') addMiss({ full_name: round.painting.artist.full_name, qid: qidOf(round.painting.artist) });
+    }
 
-  // --- Game state ---
-  const [deck, setDeck] = useState<Question[]>([]);
-  const [qIndex, setQIndex] = useState(0);            // 0-based current question
-  const [score, setScore] = useState(0);
-  const [feedback, setFeedback] = useState<FeedbackState>('idle');
-  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
-  const [correctFlash, setCorrectFlash] = useState(false);
+    void Haptics.notificationAsync(
+      right ? Haptics.NotificationFeedbackType.Success : Haptics.NotificationFeedbackType.Error,
+    );
 
-  // Used to prevent double-tap race during feedback animation
-  const lockedRef = useRef(false);
+    if (right) {
+      setStreak((s) => s + 1);
+      const bonus = Math.min(streak, 5);
+      setScore((v) => v + (round.kind === 'lightning' ? 5 + bonus : 10 + bonus * 2));
+    } else {
+      setStreak(0);
+    }
+  }
 
-  // ---------------------------------------------------------------------------
-  // Fetch signatures on mount
-  // ---------------------------------------------------------------------------
+  function next() {
+    const last = i >= rounds.length - 1;
+    if (last) { finish(); return; }
+    setI((v) => v + 1);
+    setAnswered(false); setPick(null); lockRef.current = false;
+  }
 
-  const fetchSignatures = useCallback(async () => {
-    setPhase('loading');
-    try {
-      const all = await api.signatures();
-      const withImg = all.filter((s) => !!s.signature_image_url);
-      if (withImg.length < CHOICES) {
-        // Not enough data to build a quiz
-        setPhase('error');
-        return;
-      }
-      setPool(withImg);
-      setPhase('playing');
-      startNewGame(withImg);
-    } catch {
-      setPhase('error');
+  function finish() {
+    clearTimer();
+    let finalScore = score;
+    if (gameKey === 'lightning') {
+      const both = Object.values(lightningRes.current).filter((x) => x.artist && x.title).length;
+      finalScore += both * 5;
+      setScore(finalScore);
     }
-  }, []); // eslint-disable-line react-hooks/exhaustive-deps
-
-  // ---------------------------------------------------------------------------
-  // Fetch leaderboard
-  // ---------------------------------------------------------------------------
+    const key = `${gameKey}.${diff}.${cat}`;
+    BEST[key] = Math.max(finalScore, BEST[key] || 0);
+    setPhase('over');
+    loadLeaderboard();
+  }
 
-  const fetchLeaderboard = useCallback(async () => {
+  const loadLeaderboard = useCallback(async () => {
     setLeaderLoading(true);
     try {
-      const res = await api.leaderboardTop(GAME_ID, DIFF_ID);
+      const res = await api.leaderboardTop(gameKey, diff);
       if (res.ok) setLeaderRows(res.top);
-    } catch {
-      // non-fatal — board just stays empty
-    } finally {
-      setLeaderLoading(false);
-    }
-  }, []);
+    } catch { /* non-fatal */ } finally { setLeaderLoading(false); }
+  }, [gameKey, diff]);
 
-  useEffect(() => {
-    fetchSignatures();
-    fetchLeaderboard();
-  }, [fetchSignatures, fetchLeaderboard]);
-
-  // ---------------------------------------------------------------------------
-  // Game logic
-  // ---------------------------------------------------------------------------
-
-  function startNewGame(sigPool: Signature[] = pool) {
-    const newDeck = buildDeck(sigPool);
-    setDeck(newDeck);
-    setQIndex(0);
-    setScore(0);
-    setFeedback('idle');
-    setSelectedIndex(null);
-    setCorrectFlash(false);
-    lockedRef.current = false;
-    setPhase('playing');
-  }
+  // -------------------------------------------------------------------------
+  // Renders
+  // -------------------------------------------------------------------------
 
-  const handlePlayAgain = useCallback(() => {
-    fetchLeaderboard();      // refresh board after game-over view
-    startNewGame();
-  }, [pool]); // eslint-disable-line react-hooks/exhaustive-deps
-
-  const handleChoice = useCallback(
-    (choiceIndex: number) => {
-      if (lockedRef.current || feedback !== 'idle') return;
-      lockedRef.current = true;
-      setSelectedIndex(choiceIndex);
-
-      const q = deck[qIndex];
-      const isCorrect = choiceIndex === q.correctIndex;
-
-      if (isCorrect) {
-        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
-        setCorrectFlash(true);
-        setFeedback('correct');
-        const nextScore = score + 1;
-
-        setTimeout(() => {
-          setCorrectFlash(false);
-          const nextIndex = qIndex + 1;
-          if (nextIndex >= TOTAL_QUESTIONS) {
-            setScore(nextScore);
-            setPhase('over');
-          } else {
-            setScore(nextScore);
-            setQIndex(nextIndex);
-            setFeedback('idle');
-            setSelectedIndex(null);
-            lockedRef.current = false;
-          }
-        }, 520);
-      } else {
-        void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
-        setFeedback('wrong');
-
-        // Reveal answer for 1.4 s then advance
-        setTimeout(() => {
-          const nextIndex = qIndex + 1;
-          if (nextIndex >= TOTAL_QUESTIONS) {
-            setPhase('over');
-          } else {
-            setQIndex(nextIndex);
-            setFeedback('idle');
-            setSelectedIndex(null);
-            lockedRef.current = false;
-          }
-        }, 1400);
-      }
-    },
-    [deck, feedback, qIndex, score],
-  );
-
-  // ---------------------------------------------------------------------------
-  // Derived values
-  // ---------------------------------------------------------------------------
-
-  const currentQuestion: Question | undefined = deck[qIndex];
-
-  // Button color logic per choice:
-  // idle    → default glass
-  // correct → gold background on the correct choice
-  // wrong   → red on selected, gold on correct
-  function choiceStyle(choiceIndex: number): { tint?: string; textColor?: string } {
-    if (feedback === 'idle') return {};
-    const isCorrectChoice = currentQuestion && choiceIndex === currentQuestion.correctIndex;
-    const isSelectedChoice = choiceIndex === selectedIndex;
-    if (isCorrectChoice) return { tint: GLASS.accent, textColor: GLASS.ink };
-    if (isSelectedChoice && feedback === 'wrong') return { tint: '#b03a2e', textColor: '#fff' };
-    return {};
+  if (dataError) {
+    return (
+      <View style={styles.centeredFill}>
+        <Text style={styles.errorText}>Could not load the signature catalog.</Text>
+        <View style={{ width: 160 }}>
+          <GlassButton label="Retry" solid onPress={loadCatalog} />
+        </View>
+      </View>
+    );
   }
 
-  // ---------------------------------------------------------------------------
-  // Render helpers
-  // ---------------------------------------------------------------------------
+  // ---- SETUP ----
+  if (phase === 'setup' || phase === 'prep') {
+    return (
+      <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
+        <Text style={styles.h1}>The Signature Games</Text>
+        <Text style={styles.sub}>Choose your game</Text>
+
+        <View style={styles.gameGrid}>
+          {GAMES.map((g) => {
+            const on = g.key === gameKey;
+            return (
+              <Pressable key={g.key} onPress={() => { setGameKey(g.key); setStartError(''); }} style={[styles.gcard, on && styles.gcardOn]}>
+                <Text style={[styles.gcardTitle, on && styles.gcardTitleOn]}>{g.title}</Text>
+                <Text style={styles.gcardDesc}>{g.desc}</Text>
+              </Pressable>
+            );
+          })}
+        </View>
 
-  function renderChoiceButtons() {
-    if (!currentQuestion) return null;
-    return currentQuestion.choices.map((name, idx) => {
-      const { tint, textColor } = choiceStyle(idx);
-      const isTinted = !!tint;
+        <Text style={styles.label}>Difficulty</Text>
+        <View style={styles.optRow}>
+          {DIFFS.map((d) => (
+            <OptionChip key={d.key} label={d.label} sub={`(${d.note})`} active={diff === d.key} onPress={() => setDiff(d.key)} />
+          ))}
+        </View>
 
-      // We need tinted buttons — GlassButton only has solid=accent-gold.
-      // For correct/wrong states we render a custom GlassPill-based pressable
-      // so the color override works without forking GlassButton.
-      if (isTinted) {
-        return (
-          <View key={idx} style={styles.choiceOverrideWrap}>
-            <View style={[styles.choiceOverridePill, { backgroundColor: tint }]}>
-              <Text
-                style={[styles.choiceOverrideText, { color: textColor ?? '#fff' }]}
-                numberOfLines={2}
-              >
-                {name}
-              </Text>
+        {catRelevant && (
+          <>
+            <Text style={styles.label}>Category</Text>
+            <View style={styles.optRow}>
+              {CATS.map((c) => (
+                <OptionChip key={c.key} label={c.label} active={cat === c.key} onPress={() => setCat(c.key)} />
+              ))}
             </View>
-          </View>
-        );
-      }
+          </>
+        )}
+
+        {startError !== '' && <Text style={styles.errorText}>{startError}</Text>}
 
-      return (
-        <View key={idx} style={styles.choiceBtn}>
+        <View style={styles.beginBtn}>
           <GlassButton
-            label={name}
-            onPress={() => handleChoice(idx)}
-            disabled={feedback !== 'idle'}
+            label="Begin"
+            solid
+            busy={phase === 'prep'}
+            disabled={!ALL.length || phase === 'prep'}
+            onPress={begin}
           />
         </View>
-      );
-    });
+        {phase === 'prep' && <Text style={styles.dimText}>{prepMsg}</Text>}
+        {!ALL.length && phase !== 'prep' && <Text style={styles.dimText}>Loading signatures…</Text>}
+      </ScrollView>
+    );
   }
 
-  function renderProgress() {
+  // ---- OVER ----
+  if (phase === 'over') {
+    return <ResultsView
+      gameKey={gameKey} diff={diff} cat={cat}
+      score={score} misses={misses} total={rounds.length}
+      lightningRes={lightningRes.current}
+      leaderRows={leaderRows} leaderLoading={leaderLoading}
+      onReloadLeaderboard={loadLeaderboard}
+      onAgain={() => { setPhase('setup'); }}
+    />;
+  }
+
+  // ---- PLAYING ----
+  return (
+    <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
+      <View style={styles.hud}>
+        <GlassPill><Text style={styles.hudText}>Round {i + 1}/{rounds.length}</Text></GlassPill>
+        {streak >= 2 && <GlassPill tintColor="rgba(201,168,106,0.22)"><Text style={styles.hudText}>🔥 {streak} streak</Text></GlassPill>}
+        <GlassPill><Text style={styles.hudText}>Score {score}</Text></GlassPill>
+      </View>
+
+      <Text style={styles.prompt}>{round?.kind === 'early' ? `${(round as EarlyRound).person} — which is the EARLIER signature?` : PROMPTS[gameKey]}</Text>
+
+      {round?.kind === 'lightning' && !answered && (
+        <View style={styles.timerTrack}>
+          <Animated.View style={[styles.timerFill, { width: barW.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }) }]} />
+        </View>
+      )}
+      {round?.kind === 'lightning' && (
+        <Text style={styles.tag}>{round.q === 'artist' ? 'Name the ARTIST' : 'Name the PAINTING'}</Text>
+      )}
+
+      <Stage round={round} />
+
+      {round?.kind === 'match'
+        ? <MatchChoices round={round as MatchRound} answered={answered} pick={pick} onPick={answer} />
+        : <TextChoices round={round} answered={answered} pick={pick} onPick={answer} />}
+
+      {answered && <Reveal round={round} pick={pick} />}
+      {answered && (
+        <View style={styles.nextBtn}>
+          <GlassButton label={i >= rounds.length - 1 ? 'See results' : 'Next →'} solid onPress={next} />
+        </View>
+      )}
+    </ScrollView>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Stage (the image(s) shown for the round)
+// ---------------------------------------------------------------------------
+
+function Stage({ round }: { round: Round | undefined }) {
+  if (!round) return null;
+  if (round.kind === 'whose' || round.kind === 'century' || round.kind === 'match') {
     return (
-      <View style={styles.progressRow}>
-        <GlassPill>
-          <Text style={styles.progressText}>
-            Q {qIndex + 1}/{TOTAL_QUESTIONS} · Score {score}
-          </Text>
-        </GlassPill>
+      <View style={styles.sigWrap}>
+        <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
+          <Image source={{ uri: round.answer.signature_image_url }} style={styles.sigImage} resizeMode="contain" accessibilityLabel="A handwritten signature" />
+        </GlassPanel>
+      </View>
+    );
+  }
+  if (round.kind === 'art' || round.kind === 'lightning') {
+    const img = round.kind === 'art' ? round.artwork.img : round.painting.img;
+    return (
+      <View style={styles.sigWrap}>
+        <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
+          <Image source={{ uri: img }} style={styles.artImage} resizeMode="cover" accessibilityLabel="A museum artwork" />
+        </GlassPanel>
+      </View>
+    );
+  }
+  if (round.kind === 'early') {
+    return (
+      <View style={styles.duo}>
+        {(['leftUrl', 'rightUrl'] as const).map((k, idx) => (
+          <View key={k} style={styles.duoCell}>
+            <GlassPanel style={styles.sigPanel} intensity={18} tint="light">
+              <Image source={{ uri: round[k] }} style={styles.duoImg} resizeMode="contain" accessibilityLabel={`Signature ${idx === 0 ? 'A' : 'B'}`} />
+            </GlassPanel>
+            <Text style={styles.duoLab}>{idx === 0 ? 'A' : 'B'}</Text>
+          </View>
+        ))}
       </View>
     );
   }
+  return null;
+}
 
-  // ---------------------------------------------------------------------------
-  // Phase renders
-  // ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Text-choice buttons (whose / art / century / early / lightning)
+// ---------------------------------------------------------------------------
 
-  if (phase === 'loading') return <LoadingView />;
-  if (phase === 'error') return <ErrorView onRetry={fetchSignatures} />;
+function TextChoices({
+  round, answered, pick, onPick,
+}: { round: Round | undefined; answered: boolean; pick: any; onPick: (c: any) => void }) {
+  if (!round) return null;
+
+  // build the [label, isCorrect, value] list per game
+  let items: { label: string; correct: boolean; value: number | 'left' | 'right' }[] = [];
+  if (round.kind === 'whose' || round.kind === 'art') {
+    items = round.options.map((o, idx) => ({ label: o.full_name, correct: o === round.answer, value: idx }));
+  } else if (round.kind === 'century') {
+    items = round.options.map((c, idx) => ({ label: centuryLabel(c), correct: c === round.century, value: idx }));
+  } else if (round.kind === 'lightning') {
+    items = round.options.map((o, idx) => ({ label: o, correct: o === round.correct, value: idx }));
+  } else if (round.kind === 'early') {
+    items = [
+      { label: 'A is earlier', correct: round.earlier === 'left', value: 'left' },
+      { label: 'B is earlier', correct: round.earlier === 'right', value: 'right' },
+    ];
+  } else {
+    return null;
+  }
 
   return (
-    <ScrollView
-      style={styles.root}
-      contentContainerStyle={styles.scroll}
-      showsVerticalScrollIndicator={false}
-      keyboardShouldPersistTaps="handled"
-    >
-      {/* ---- Game-over card (replaces quiz) ---- */}
-      {phase === 'over' ? (
-        <>
-          <GameOverCard
-            score={score}
-            onPlayAgain={handlePlayAgain}
-          />
-          <LeaderboardPanel rows={leaderRows} loading={leaderLoading} />
-        </>
-      ) : (
-        <>
-          {/* ---- Progress pill ---- */}
-          {renderProgress()}
-
-          {/* ---- Signature image panel ---- */}
-          {currentQuestion && (
-            <View style={styles.sigWrap}>
-              <GlassPanel
-                style={styles.sigPanel}
-                intensity={18}
-                tint="light"          // parchment-tinted (light tint over gold gradient)
-              >
-                <CorrectFlash visible={correctFlash} />
-                <Image
-                  source={{ uri: currentQuestion.signature.signature_image_url }}
-                  style={styles.sigImage}
-                  resizeMode="contain"
-                  accessibilityLabel="A handwritten signature — whose is it?"
-                />
-              </GlassPanel>
-            </View>
-          )}
-
-          {/* ---- Category hint ---- */}
-          {currentQuestion && (
-            <View style={styles.categoryRow}>
-              <GlassPill tintColor="rgba(201,168,106,0.18)">
-                <Text style={styles.categoryText}>
-                  {currentQuestion.signature.category}
-                </Text>
-              </GlassPill>
+    <View style={styles.choicesWrap}>
+      {items.map((it, idx) => {
+        let tint: string | undefined;
+        let textColor: string | undefined;
+        if (answered) {
+          const isPicked = pick === it.value;
+          if (it.correct) { tint = GLASS.accent; textColor = GLASS.ink; }
+          else if (isPicked) { tint = '#b03a2e'; textColor = '#fff'; }
+        }
+        if (tint) {
+          return (
+            <View key={idx} style={styles.choiceOverrideWrap}>
+              <View style={[styles.choiceOverridePill, { backgroundColor: tint }]}>
+                <Text style={[styles.choiceOverrideText, { color: textColor }]} numberOfLines={2}>{it.label}</Text>
+              </View>
             </View>
-          )}
-
-          {/* ---- Wrong-answer reveal label ---- */}
-          {feedback === 'wrong' && currentQuestion && (
-            <Text style={styles.revealText}>
-              That's {currentQuestion.signature.full_name}
-            </Text>
-          )}
-
-          {/* ---- Answer buttons ---- */}
-          <View style={styles.choicesWrap}>
-            {renderChoiceButtons()}
+          );
+        }
+        return (
+          <View key={idx} style={styles.choiceBtn}>
+            <GlassButton label={it.label} disabled={answered} onPress={() => onPick(it.value)} />
           </View>
+        );
+      })}
+    </View>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Match choices — 2×2 grid of artwork images
+// ---------------------------------------------------------------------------
+
+function MatchChoices({
+  round, answered, pick, onPick,
+}: { round: MatchRound; answered: boolean; pick: any; onPick: (c: number) => void }) {
+  return (
+    <View style={styles.matchGrid}>
+      {round.options.map((o, idx) => {
+        const correct = o.owner === round.answer;
+        const isPicked = pick === idx;
+        const border = answered ? (correct ? GLASS.accent : isPicked ? '#b03a2e' : GLASS.edgeFaint) : GLASS.edgeFaint;
+        return (
+          <Pressable key={idx} disabled={answered} onPress={() => onPick(idx)} style={[styles.matchCell, { borderColor: border }]}>
+            <Image source={{ uri: o.art.img }} style={styles.matchImg} resizeMode="cover" accessibilityLabel="Artwork option" />
+            {answered && (
+              <View style={styles.matchLabelWrap}>
+                <Text style={styles.matchLabel} numberOfLines={2}>{o.owner.full_name}</Text>
+              </View>
+            )}
+          </Pressable>
+        );
+      })}
+    </View>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Reveal line under the choices after answering
+// ---------------------------------------------------------------------------
 
-          {/* ---- Leaderboard ---- */}
-          <LeaderboardPanel rows={leaderRows} loading={leaderLoading} />
-        </>
+function Reveal({ round, pick }: { round: Round | undefined; pick: any }) {
+  if (!round) return null;
+  let right = false;
+  let text = '';
+  let link: { label: string; url: string } | null = null;
+
+  if (round.kind === 'whose') {
+    right = round.options[pick as number] === round.answer;
+    text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
+  } else if (round.kind === 'art') {
+    right = round.options[pick as number] === round.answer;
+    text = `${right ? '✓ Correct —' : '✗ It was'} ${round.answer.full_name}`;
+    link = { label: `“${round.artwork.title}”${round.artwork.date ? ', ' + round.artwork.date : ''} →`, url: round.artwork.url };
+  } else if (round.kind === 'century') {
+    right = round.options[pick as number] === round.century;
+    text = `${right ? '✓ Correct —' : '✗'} ${round.answer.full_name} died ${String(round.answer.death_date).slice(0, 4)} (${centuryLabel(round.century)})`;
+  } else if (round.kind === 'match') {
+    right = round.options[pick as number]?.owner === round.answer;
+    const ownPiece = round.options.find((o) => o.owner === round.answer)!;
+    text = `${right ? '✓ Correct —' : '✗ Their piece was'} “${ownPiece.art.title}” by ${round.answer.full_name}`;
+    link = { label: 'View artwork →', url: ownPiece.art.url };
+  } else if (round.kind === 'early') {
+    right = pick === round.earlier;
+    text = `${right ? '✓ Correct —' : '✗'} ${round.person}: ${round.earlier === 'left' ? 'A' : 'B'} is from ${round.years[0]}, the other from ${round.years[1]}`;
+  } else if (round.kind === 'lightning') {
+    const chosen = pick === 'timeout' ? null : round.options[pick as number];
+    right = chosen === round.correct;
+    const lead = right ? '✓' : pick === 'timeout' ? '⏱ Time!' : '✗';
+    text = `${lead} ${round.q === 'artist' ? 'Artist' : 'Painting'}: ${round.correct}`;
+    if (round.q === 'title') link = { label: 'see it →', url: round.painting.url };
+  }
+
+  return (
+    <View style={styles.reveal}>
+      <Text style={styles.revealText}>{text}</Text>
+      {link && (
+        <Text style={styles.revealLink} onPress={() => Linking.openURL(link!.url)}>{link.label}</Text>
       )}
-    </ScrollView>
+    </View>
   );
 }
 
 // ---------------------------------------------------------------------------
-// Styles
+// Results view
 // ---------------------------------------------------------------------------
 
-const styles = StyleSheet.create({
-  root: {
-    flex: 1,
-  },
-  scroll: {
-    paddingHorizontal: 18,
-    paddingTop: 20,
-    paddingBottom: 48,
-    gap: 16,
-  },
+function ResultsView({
+  gameKey, diff, cat, score, misses, total, lightningRes,
+  leaderRows, leaderLoading, onReloadLeaderboard, onAgain,
+}: {
+  gameKey: GameKey; diff: string; cat: string; score: number; total: number;
+  misses: { full_name: string; qid: string | null }[];
+  lightningRes: Record<string, { artist: boolean; title: boolean }>;
+  leaderRows: LeaderRow[]; leaderLoading: boolean;
+  onReloadLeaderboard: () => void; onAgain: () => void;
+}) {
+  const [name, setName] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [rank, setRank] = useState<number | null>(null);
+  const [submitError, setSubmitError] = useState('');
 
-  // Loading / error
-  centeredFill: {
-    flex: 1,
-    alignItems: 'center',
-    justifyContent: 'center',
-    gap: 16,
-    padding: 32,
-  },
-  dimText: {
-    color: GLASS.textDim,
-    fontSize: 15,
-    textAlign: 'center',
-  },
-  errorText: {
-    color: '#e05c5c',
-    fontSize: 14,
-    textAlign: 'center',
-  },
-  retryBtn: {
-    width: 160,
-  },
+  const uniqueMisses = useMemo(() => {
+    const m = new Map<string, { full_name: string; qid: string | null }>();
+    misses.forEach((x) => m.set(x.qid || x.full_name, x));
+    return [...m.values()];
+  }, [misses]);
 
-  // Progress
-  progressRow: {
-    alignItems: 'center',
-  },
-  progressText: {
-    color: GLASS.accentSoft,
-    fontSize: 14,
-    fontWeight: '600',
-    letterSpacing: 0.4,
-  },
+  const rightN = total - misses.length;
+  const verdict =
+    rightN === total ? 'Master archivist. The auction houses should be calling.'
+    : rightN >= total * 0.8 ? "A connoisseur's eye."
+    : rightN >= total * 0.6 ? 'A promising apprentice.'
+    : rightN >= total * 0.4 ? 'The gallery guide is that way…'
+    : 'Everyone starts somewhere.';
 
-  // Signature image
-  sigWrap: {
-    borderRadius: 22,
-    // Warm parchment shadow to sell the physical-paper feel
-    shadowColor: GLASS.accent,
-    shadowOffset: { width: 0, height: 8 },
-    shadowOpacity: 0.22,
-    shadowRadius: 24,
-    elevation: 10,
-  },
-  sigPanel: {
-    // override panelBody padding is via children — the GlassPanel adds 18px padding internally
-  },
-  sigImage: {
-    width: '100%',
-    height: 200,
-    // Slight warm tint is applied via the GlassPanel light tint
-  },
+  const bonusBoth = gameKey === 'lightning'
+    ? Object.values(lightningRes).filter((x) => x.artist && x.title).length : 0;
+  const bonusTotal = gameKey === 'lightning' ? Object.keys(lightningRes).length : 0;
 
-  // Category
-  categoryRow: {
-    alignItems: 'center',
-  },
-  categoryText: {
-    color: GLASS.textDim,
-    fontSize: 13,
-    fontWeight: '500',
-    letterSpacing: 0.6,
-    textTransform: 'uppercase',
-  },
+  const best = BEST[`${gameKey}.${diff}.${cat}`] || score;
 
-  // Wrong reveal
-  revealText: {
-    color: GLASS.accent,
-    fontSize: 16,
-    fontWeight: '700',
-    textAlign: 'center',
-    letterSpacing: 0.2,
-    marginVertical: 4,
-  },
+  const submit = useCallback(async () => {
+    const trimmed = name.trim();
+    if (!trimmed) return;
+    setSubmitting(true); setSubmitError('');
+    try {
+      const res = await api.leaderboardSubmit(gameKey, diff, score, trimmed);
+      if (res.ok && res.rank != null) { setRank(res.rank); onReloadLeaderboard(); }
+      else setSubmitError('Submit failed — try again.');
+    } catch { setSubmitError('Network error.'); }
+    finally { setSubmitting(false); }
+  }, [name, score, gameKey, diff, onReloadLeaderboard]);
+
+  const share = useCallback(() => {
+    const msg = `I scored ${score} on ${GAME_NAMES[gameKey]} (${diff}) at The Signature Games — can you beat me? https://celebsignatures.com/game`;
+    Share.share({ message: msg }).catch(() => {});
+  }, [score, gameKey, diff]);
 
-  // Choices
-  choicesWrap: {
-    gap: 10,
-  },
-  choiceBtn: {
-    // no extra style — GlassButton fills full width via its own styles
-  },
-  // Tinted override pill (correct / wrong state)
-  choiceOverrideWrap: {
-    borderRadius: 999,
-    overflow: 'hidden',
-  },
-  choiceOverridePill: {
-    borderRadius: 999,
-    paddingVertical: 16,
-    paddingHorizontal: 20,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  choiceOverrideText: {
-    fontSize: 17,
-    fontWeight: '700',
-    letterSpacing: 0.2,
-    textAlign: 'center',
-  },
+  return (
+    <ScrollView style={styles.root} contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
+      <GlassPanel style={styles.gameOverPanel}>
+        <Text style={styles.scoreBig}>{score}</Text>
+        <Text style={styles.verdict}>{verdict}</Text>
+        {bonusTotal > 0 && (
+          <Text style={styles.dimText}>⚡ Named BOTH on {bonusBoth} of {bonusTotal} paintings — +{bonusBoth * 5} bonus</Text>
+        )}
+        <Text style={styles.bestText}>Best ({gameKey} · {diff}): {best}</Text>
+
+        {uniqueMisses.length > 0 ? (
+          <Text style={styles.missText}>
+            Worth a second look: {uniqueMisses.map((m) => m.full_name).join(' · ')}
+          </Text>
+        ) : (
+          <Text style={styles.missText}>A perfect run.</Text>
+        )}
+
+        {rank != null ? (
+          <Text style={styles.rankText}>You ranked #{rank} — nice!</Text>
+        ) : (
+          <>
+            <TextInput
+              style={styles.nameInput}
+              placeholder="Your name (optional for board)"
+              placeholderTextColor={GLASS.textDim}
+              value={name}
+              onChangeText={setName}
+              maxLength={24}
+              returnKeyType="done"
+              autoCorrect={false}
+            />
+            {submitError !== '' && <Text style={styles.errorText}>{submitError}</Text>}
+            <View style={styles.submitBtn}>
+              <GlassButton label="Post my score" solid busy={submitting} disabled={name.trim().length === 0} onPress={submit} />
+            </View>
+          </>
+        )}
 
-  // Game-over
-  gameOverPanel: {
-    gap: 14,
-  },
-  gameOverTitle: {
-    color: GLASS.accentSoft,
-    fontSize: 26,
-    fontWeight: '800',
-    textAlign: 'center',
-    letterSpacing: 0.4,
-  },
-  scoreRow: {
-    alignItems: 'center',
-  },
-  finalScoreText: {
-    color: GLASS.ink,
-    fontSize: 22,
-    fontWeight: '800',
-    letterSpacing: 0.5,
-  },
-  rankText: {
-    color: GLASS.accent,
-    fontSize: 18,
-    fontWeight: '700',
-    textAlign: 'center',
-    marginTop: 4,
-  },
-  nameInput: {
-    borderRadius: 14,
-    borderWidth: StyleSheet.hairlineWidth,
-    borderColor: GLASS.edge,
-    backgroundColor: 'rgba(255,255,255,0.06)',
-    color: GLASS.accentSoft,
-    fontSize: 17,
-    paddingHorizontal: 16,
-    paddingVertical: 13,
-    marginTop: 4,
-  },
-  submitBtn: {
-    marginTop: 4,
-  },
-  playAgainBtn: {
-    marginTop: 4,
-  },
+        <View style={styles.shareRow}>
+          <View style={{ flex: 1 }}><GlassButton label="🔗 Share result" onPress={share} /></View>
+        </View>
+        <View style={styles.playAgainBtn}><GlassButton label="Play again" onPress={onAgain} /></View>
+      </GlassPanel>
 
-  // Leaderboard
-  leaderPanel: {
-    gap: 10,
-  },
-  sectionTitle: {
-    color: GLASS.accent,
-    fontSize: 16,
-    fontWeight: '700',
-    letterSpacing: 0.6,
-    textTransform: 'uppercase',
-    marginBottom: 2,
+      <LeaderboardPanel rows={leaderRows} loading={leaderLoading} tag={`${GAME_NAMES[gameKey]} · ${diff}`} />
+    </ScrollView>
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Styles
+// ---------------------------------------------------------------------------
+
+const styles = StyleSheet.create({
+  root: { flex: 1 },
+  scroll: { paddingHorizontal: 18, paddingTop: 20, paddingBottom: 48, gap: 14 },
+
+  centeredFill: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 },
+  dimText: { color: GLASS.textDim, fontSize: 14, textAlign: 'center' },
+  errorText: { color: '#e05c5c', fontSize: 14, textAlign: 'center' },
+
+  // setup
+  h1: { color: GLASS.accentSoft, fontSize: 28, fontWeight: '800', textAlign: 'center', letterSpacing: 0.4 },
+  sub: { color: GLASS.textDim, fontSize: 15, textAlign: 'center', marginTop: -6 },
+  gameGrid: { gap: 10, marginTop: 4 },
+  gcard: {
+    borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
+    backgroundColor: 'rgba(255,255,255,0.04)', padding: 14,
   },
-  leaderRow: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 8,
+  gcardOn: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.14)' },
+  gcardTitle: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '800', letterSpacing: 0.2 },
+  gcardTitleOn: { color: GLASS.accent },
+  gcardDesc: { color: GLASS.textDim, fontSize: 13, marginTop: 3, lineHeight: 18 },
+
+  label: { color: GLASS.accent, fontSize: 13, fontWeight: '700', letterSpacing: 0.6, textTransform: 'uppercase', marginTop: 6 },
+  optRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
+  chip: {
+    flexDirection: 'row', alignItems: 'center',
+    borderRadius: 999, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edgeFaint,
+    paddingHorizontal: 14, paddingVertical: 9, backgroundColor: 'rgba(255,255,255,0.04)',
   },
-  leaderRank: {
-    color: GLASS.textDim,
-    fontSize: 14,
-    fontWeight: '600',
-    width: 22,
+  chipActive: { borderColor: GLASS.accent, backgroundColor: 'rgba(201,168,106,0.2)' },
+  chipText: { color: GLASS.accentSoft, fontSize: 14, fontWeight: '600' },
+  chipSub: { color: GLASS.textDim, fontSize: 12 },
+  chipTextActive: { color: GLASS.accent },
+  beginBtn: { marginTop: 12 },
+
+  // hud / play
+  hud: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 },
+  hudText: { color: GLASS.accentSoft, fontSize: 13, fontWeight: '700', letterSpacing: 0.3 },
+  prompt: { color: GLASS.accentSoft, fontSize: 18, fontWeight: '700', textAlign: 'center', letterSpacing: 0.2, lineHeight: 24 },
+  tag: { color: GLASS.accent, fontSize: 13, fontWeight: '800', letterSpacing: 0.8, textAlign: 'center', textTransform: 'uppercase' },
+
+  timerTrack: { height: 6, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.08)', overflow: 'hidden' },
+  timerFill: { height: 6, backgroundColor: GLASS.accent },
+
+  sigWrap: {
+    borderRadius: 22, shadowColor: GLASS.accent, shadowOffset: { width: 0, height: 8 },
+    shadowOpacity: 0.22, shadowRadius: 24, elevation: 10,
   },
-  leaderName: {
-    flex: 1,
-    color: GLASS.accentSoft,
-    fontSize: 15,
-    fontWeight: '500',
+  sigPanel: {},
+  sigImage: { width: '100%', height: 190 },
+  artImage: { width: '100%', height: 260, borderRadius: 8 },
+
+  // early duo
+  duo: { flexDirection: 'row', gap: 12 },
+  duoCell: { flex: 1, alignItems: 'center', gap: 6 },
+  duoImg: { width: '100%', height: 150 },
+  duoLab: { color: GLASS.accent, fontSize: 16, fontWeight: '800' },
+
+  // choices
+  choicesWrap: { gap: 10 },
+  choiceBtn: {},
+  choiceOverrideWrap: { borderRadius: 999, overflow: 'hidden' },
+  choiceOverridePill: { borderRadius: 999, paddingVertical: 16, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
+  choiceOverrideText: { fontSize: 16, fontWeight: '700', letterSpacing: 0.2, textAlign: 'center' },
+
+  // match grid
+  matchGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 10 },
+  matchCell: {
+    width: '48%', aspectRatio: 1, borderRadius: 14, overflow: 'hidden',
+    borderWidth: 2, backgroundColor: 'rgba(255,255,255,0.04)',
   },
-  leaderScore: {
-    color: GLASS.ink,
-    fontSize: 14,
-    fontWeight: '700',
+  matchImg: { width: '100%', height: '100%' },
+  matchLabelWrap: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(13,11,10,0.82)', paddingVertical: 4, paddingHorizontal: 6 },
+  matchLabel: { color: GLASS.accentSoft, fontSize: 11, fontWeight: '600', textAlign: 'center' },
+
+  // reveal
+  reveal: { alignItems: 'center', gap: 4, marginTop: 2 },
+  revealText: { color: GLASS.accent, fontSize: 15, fontWeight: '700', textAlign: 'center', lineHeight: 20 },
+  revealLink: { color: GLASS.accentSoft, fontSize: 14, textDecorationLine: 'underline' },
+  nextBtn: { marginTop: 4 },
+
+  // results
+  gameOverPanel: { gap: 12, alignItems: 'stretch' },
+  scoreBig: { color: GLASS.accent, fontSize: 52, fontWeight: '900', textAlign: 'center', letterSpacing: 0.5 },
+  verdict: { color: GLASS.accentSoft, fontSize: 17, fontWeight: '700', textAlign: 'center', lineHeight: 23 },
+  bestText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center' },
+  missText: { color: GLASS.textDim, fontSize: 13, textAlign: 'center', lineHeight: 19 },
+  rankText: { color: GLASS.accent, fontSize: 18, fontWeight: '700', textAlign: 'center' },
+  nameInput: {
+    borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: GLASS.edge,
+    backgroundColor: 'rgba(255,255,255,0.06)', color: GLASS.accentSoft, fontSize: 16,
+    paddingHorizontal: 16, paddingVertical: 13,
   },
+  submitBtn: { marginTop: 2 },
+  shareRow: { flexDirection: 'row', marginTop: 2 },
+  playAgainBtn: { marginTop: 2 },
+
+  // leaderboard
+  leaderPanel: { gap: 10 },
+  sectionTitle: { color: GLASS.accent, fontSize: 15, fontWeight: '700', letterSpacing: 0.5, marginBottom: 2 },
+  leaderRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
+  leaderRank: { color: GLASS.textDim, fontSize: 14, fontWeight: '600', width: 22 },
+  leaderName: { flex: 1, color: GLASS.accentSoft, fontSize: 15, fontWeight: '500' },
+  leaderScore: { color: GLASS.ink, fontSize: 14, fontWeight: '700' },
 });

← 4b6b29e iOS: complete App Store screenshot set — all 4 tabs (Gallery  ·  back to CelebritySignatures  ·  mobile: harden games port per adversarial review 701c009 →