← 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
A public/game.htmlM public/index.htmlM server.js
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 →