[object Object]

← back to CelebritySignatures

'Whose Hand?' signature-ID game at /game: 10 rounds, 4 fame-adjacent same-category distractors, streak scoring, 4 difficulty tiers (Icons top-100 -> Deep Cuts all 7,076), category filters, per-mode best score, every reveal links to the artist's page; nav wired (TK-10181)

5a072752320cc34a511627bd978fec2912a11c13 · 2026-08-03 12:27:26 -0700 · Steve Abrams

Files touched

Diff

commit 5a072752320cc34a511627bd978fec2912a11c13
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 12:27:26 2026 -0700

    'Whose Hand?' signature-ID game at /game: 10 rounds, 4 fame-adjacent same-category distractors, streak scoring, 4 difficulty tiers (Icons top-100 -> Deep Cuts all 7,076), category filters, per-mode best score, every reveal links to the artist's page; nav wired (TK-10181)
---
 public/game.html  | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html |   1 +
 server.js         |   1 +
 3 files changed, 200 insertions(+)

diff --git a/public/game.html b/public/game.html
new file mode 100644
index 0000000..c8fd84e
--- /dev/null
+++ b/public/game.html
@@ -0,0 +1,198 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Whose Hand? — the signature identification game</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
+<style>
+  :root { --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e3dc; --bg:#f7f5f0; --green:#1d7a36; --red:#b3261e; }
+  * { box-sizing:border-box; }
+  body { margin:0; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--bg); }
+  .masthead { position:sticky; top:0; z-index:30; display:flex; align-items:center; gap:22px; height:56px; padding:0 24px; background:#fff; border-bottom:1px solid var(--line); }
+  .wordmark { font:italic 600 24px/1 'Playfair Display', Georgia, serif; color:var(--ink); text-decoration:none; }
+  .gnav { margin-left:auto; display:flex; gap:20px; }
+  .gnav a { font-size:13.5px; color:var(--muted); text-decoration:none; padding:4px 2px; border-bottom:2px solid transparent; }
+  .gnav a.on, .gnav a:hover { color:var(--ink); border-bottom-color:var(--ink); }
+  main { max-width:680px; margin:0 auto; padding:30px 20px 80px; }
+  h1 { font:700 30px/1.2 'Playfair Display', Georgia, serif; margin:0 0 6px; text-align:center; }
+  .tagline { text-align:center; color:var(--muted); margin:0 0 24px; }
+  .panel { background:#fff; border:1px solid var(--line); border-radius:16px; padding:26px 28px; box-shadow:0 2px 10px rgba(0,0,0,.05); }
+  /* setup */
+  .setup label { display:block; font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:16px 0 7px; }
+  .opts { display:flex; gap:8px; flex-wrap:wrap; }
+  .opt { padding:8px 16px; border:1px solid var(--line); border-radius:999px; background:#fff; cursor:pointer; font:inherit; font-size:13.5px; color:var(--muted); }
+  .opt.on { background:var(--ink); color:#fff; border-color:var(--ink); }
+  .go { display:block; width:100%; margin-top:24px; padding:13px; border-radius:999px; border:none; background:var(--ink); color:#fff; font:600 16px 'Playfair Display',Georgia,serif; cursor:pointer; }
+  .go:hover { background:#000; }
+  /* play */
+  .hud { display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--muted); margin-bottom:14px; }
+  .hud b { color:var(--ink); font-size:16px; }
+  .streak { color:var(--green); font-weight:600; }
+  .sigbox { background:#fff; border:1px solid var(--line); border-radius:12px; height:170px; display:flex; align-items:center; justify-content:center; padding:20px; margin-bottom:18px; }
+  .sigbox img { max-width:100%; max-height:100%; object-fit:contain; }
+  .choices { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
+  .choice { padding:13px 16px; border:1px solid var(--line); border-radius:10px; background:#fff; cursor:pointer; font:inherit; font-size:14.5px; text-align:center; transition:transform .08s; }
+  .choice:hover:not(:disabled) { transform:translateY(-1px); border-color:var(--ink); }
+  .choice:disabled { cursor:default; }
+  .choice.right { background:var(--green); color:#fff; border-color:var(--green); }
+  .choice.wrong { background:var(--red); color:#fff; border-color:var(--red); }
+  .choice.dim { opacity:.45; }
+  .reveal { margin-top:14px; text-align:center; font-size:14px; min-height:44px; }
+  .reveal a { color:var(--green); }
+  .next { display:block; margin:14px auto 0; padding:10px 30px; border-radius:999px; border:1px solid var(--ink); background:var(--ink); color:#fff; font:inherit; cursor:pointer; }
+  /* results */
+  .score-big { font:700 64px/1 'Playfair Display', Georgia, serif; text-align:center; margin:10px 0 2px; }
+  .verdict { text-align:center; font:italic 400 22px 'Playfair Display', Georgia, serif; color:var(--muted); margin-bottom:16px; }
+  .misses { font-size:13.5px; color:var(--muted); }
+  .misses a { color:var(--green); }
+  .best { text-align:center; font-size:13px; color:var(--muted); margin-top:14px; }
+  .again { display:block; margin:20px auto 0; padding:12px 34px; border-radius:999px; border:none; background:var(--ink); color:#fff; font:600 15px 'Playfair Display',Georgia,serif; cursor:pointer; }
+  @media (max-width:520px){ .choices{grid-template-columns:1fr} }
+</style>
+</head>
+<body>
+<div class="masthead">
+  <a class="wordmark" href="/">Celebrity Signatures</a>
+  <nav class="gnav">
+    <a href="/">Browse signatures</a>
+    <a href="/murals">Order a mural</a>
+    <a href="/game" class="on">Play</a>
+    <a href="/upload">Upload your signature</a>
+  </nav>
+</div>
+<main>
+  <h1>Whose Hand?</h1>
+  <p class="tagline">Ten signatures. Four names each. How well do you know history's handwriting?</p>
+
+  <div class="panel setup" id="setup">
+    <label>Difficulty</label>
+    <div class="opts" id="diffOpts">
+      <button class="opt on" data-d="icons">Icons <small>(the 100 most famous)</small></button>
+      <button class="opt" data-d="scholar">Scholar <small>(top 500)</small></button>
+      <button class="opt" data-d="curator">Curator <small>(top 2,000)</small></button>
+      <button class="opt" data-d="deep">Deep Cuts <small>(everyone)</small></button>
+    </div>
+    <label>Category</label>
+    <div class="opts" id="catOpts">
+      <button class="opt on" data-c="All">Everything</button>
+      <button class="opt" data-c="Artists">Artists</button>
+      <button class="opt" data-c="Authors">Authors</button>
+      <button class="opt" data-c="Politics">Politics</button>
+      <button class="opt" data-c="Declaration of Independence">Declaration</button>
+    </div>
+    <button class="go" id="goBtn">Begin</button>
+  </div>
+
+  <div class="panel" id="play" hidden>
+    <div class="hud">
+      <span>Round <b id="round">1</b> / 10</span>
+      <span class="streak" id="streak"></span>
+      <span>Score <b id="score">0</b></span>
+    </div>
+    <div class="sigbox"><img id="sigImg" alt="Mystery signature"></div>
+    <div class="choices" id="choices"></div>
+    <div class="reveal" id="reveal"></div>
+  </div>
+
+  <div class="panel" id="results" hidden>
+    <div class="score-big" id="finalScore"></div>
+    <div class="verdict" id="verdict"></div>
+    <div class="misses" id="misses"></div>
+    <div class="best" id="best"></div>
+    <button class="again" id="againBtn">Play again</button>
+  </div>
+</main>
+<script>
+const $ = s => document.querySelector(s);
+const qidOf = r => (r.wikidata||'').split('/').pop();
+const notability = r => { const m=(r.reason_for_ranking||'').match(/(\d+) language/); return m?+m[1]:0; };
+let ALL=[], POOL=[], game=null;
+const DIFF_N = { icons:100, scholar:500, curator:2000, deep:Infinity };
+let diff='icons', cat='All';
+
+fetch('/api/signatures').then(r=>r.json()).then(d=>{
+  ALL = d.filter(r => r.signature_image_url && /wikimedia|upload\./.test(r.signature_image_url));
+});
+
+document.querySelectorAll('#diffOpts .opt').forEach(b=>b.onclick=()=>{ diff=b.dataset.d;
+  document.querySelectorAll('#diffOpts .opt').forEach(x=>x.classList.toggle('on',x===b)); });
+document.querySelectorAll('#catOpts .opt').forEach(b=>b.onclick=()=>{ cat=b.dataset.c;
+  document.querySelectorAll('#catOpts .opt').forEach(x=>x.classList.toggle('on',x===b)); });
+
+function shuffled(a){ a=[...a]; for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } return a; }
+
+$('#goBtn').onclick = () => {
+  let pool = cat==='All' ? ALL : ALL.filter(r=>r.category===cat);
+  pool = [...pool].sort((a,b)=>notability(b)-notability(a)).slice(0, DIFF_N[diff]);
+  if (pool.length < 8) { alert('Not enough signatures in this tier — try a broader setting.'); return; }
+  POOL = pool;
+  game = { rounds: [], i: 0, score: 0, streak: 0, misses: [] };
+  const picks = shuffled(pool).slice(0,10);
+  game.rounds = picks.map(answer => {
+    // distractors: same category, adjacent fame (fair but hard)
+    const near = pool.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 = shuffled(ranked.slice(0, 24)).slice(0,3);
+    while (ds.length<3) { const x = shuffled(pool).find(r=>r!==answer && !ds.includes(r)); if(!x) break; ds.push(x); }
+    return { answer, options: shuffled([answer, ...ds]) };
+  });
+  $('#setup').hidden = true; $('#results').hidden = true; $('#play').hidden = false;
+  showRound();
+};
+
+function showRound(){
+  const r = game.rounds[game.i];
+  $('#round').textContent = game.i+1;
+  $('#score').textContent = game.score;
+  $('#streak').textContent = game.streak>=2 ? `🔥 ${game.streak} streak` : '';
+  $('#sigImg').src = r.answer.signature_image_url;
+  $('#reveal').innerHTML = '';
+  $('#choices').innerHTML = r.options.map((o,i)=>`<button class="choice" data-i="${i}">${o.full_name}</button>`).join('');
+  document.querySelectorAll('.choice').forEach(b=>b.onclick=()=>pick(+b.dataset.i));
+}
+
+function pick(i){
+  const r = game.rounds[game.i];
+  const chosen = r.options[i];
+  const right = chosen === r.answer;
+  document.querySelectorAll('.choice').forEach((b,j)=>{
+    b.disabled = true;
+    if (r.options[j]===r.answer) b.classList.add('right');
+    else if (j===i) b.classList.add('wrong');
+    else b.classList.add('dim');
+  });
+  if (right) { game.streak++; game.score += 10 + Math.min(game.streak-1,5)*2; }
+  else { game.streak = 0; game.misses.push(r.answer); }
+  const a = r.answer;
+  $('#reveal').innerHTML = `${right?'✓ Correct —':'✗ It was'} <b>${a.full_name}</b>`
+    + ` (${a.category}${a.death_date?', d. '+String(a.death_date).slice(0,4):''})`
+    + ` · <a href="/?artist=${qidOf(a)}" target="_blank" rel="noopener noreferrer">see their page →</a>`
+    + `<button class="next" id="nextBtn">${game.i===9?'See results':'Next signature →'}</button>`;
+  $('#nextBtn').onclick = () => { game.i++; game.i>9 ? finish() : showRound(); };
+}
+
+function finish(){
+  $('#play').hidden = true; $('#results').hidden = false;
+  const s = game.score;
+  $('#finalScore').textContent = s;
+  const right = 10 - game.misses.length;
+  $('#verdict').textContent =
+    right===10 ? 'Master archivist. The auction houses should be calling.' :
+    right>=8  ? 'A connoisseur’s eye.' :
+    right>=6  ? 'A promising apprentice.' :
+    right>=4  ? 'The gallery guide is that way…' :
+                'Everyone starts somewhere.';
+  $('#misses').innerHTML = game.misses.length
+    ? '<b>Worth a second look:</b><br>' + game.misses.map(a=>`<a href="/?artist=${qidOf(a)}" target="_blank" rel="noopener noreferrer">${a.full_name}</a>`).join(' · ')
+    : 'A perfect run — every hand identified.';
+  const key = 'whosehand.best.'+diff+'.'+cat;
+  const best = Math.max(s, +(localStorage.getItem(key)||0));
+  localStorage.setItem(key, best);
+  $('#best').textContent = `Best score (${diff} · ${cat}): ${best}`;
+}
+$('#againBtn').onclick = () => { $('#results').hidden = true; $('#setup').hidden = false; };
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index 2e58db0..6ae43a0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -105,6 +105,7 @@
   <nav class="gnav">
     <a href="/" class="on">Browse signatures</a>
     <a href="/murals">Order a mural</a>
+    <a href="/game">Play “Whose Hand?”</a>
     <a href="/upload">Upload your signature</a>
   </nav>
 </div>
diff --git a/server.js b/server.js
index 47308b9..7efc7d3 100644
--- a/server.js
+++ b/server.js
@@ -294,6 +294,7 @@ createServer(async (req, res) => {
     if (path === '/') path = '/public/index.html';
     if (path === '/murals') path = '/public/murals.html';
     if (path === '/upload') path = '/public/upload-signature.html';
+    if (path === '/game') path = '/public/game.html';
     if (path === '/account.js') path = '/public/account.js';
     if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
     if (path === '/api/signature-evolution') path = '/data/signature-evolution.json';

← b9facd3 auto-save: 2026-08-03T12:23:59 (5 files) — public/index.html  ·  back to CelebritySignatures  ·  6 switchable shopping layouts (View chips), all info-on-hove d1e7283 →