[object Object]

← back to CelebritySignatures

yoloforever C2: games social layer — server /api/leaderboard (top-20 per game+diff, JSON-backed, no-auth, sanitized) + results-screen leaderboard w/ your-rank highlight + 'Copy result' & X-share intent; addresses Cody's zero-retention/no-social-loop risk. Verified: API ranks correctly, UI posts+renders, 0 new console errors (.deploy.conf leaderboard-exclude applied locally, gitignored) (TK-10181)

79cdb51e43a9e4f26ce4a03eccb9932c5a95f4f4 · 2026-08-03 15:45:32 -0700 · Steve Abrams

Files touched

Diff

commit 79cdb51e43a9e4f26ce4a03eccb9932c5a95f4f4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 15:45:32 2026 -0700

    yoloforever C2: games social layer — server /api/leaderboard (top-20 per game+diff, JSON-backed, no-auth, sanitized) + results-screen leaderboard w/ your-rank highlight + 'Copy result' & X-share intent; addresses Cody's zero-retention/no-social-loop risk. Verified: API ranks correctly, UI posts+renders, 0 new console errors (.deploy.conf leaderboard-exclude applied locally, gitignored) (TK-10181)
---
 .gitignore       |  1 +
 public/game.html | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js        | 27 ++++++++++++++++++++++
 3 files changed, 97 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index fb274bd..21df3b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ data/celebrity-uploads.json
 data/uploads-private/
 data/download-ledger.jsonl
 data/admin-token.txt
+data/leaderboard.json
diff --git a/public/game.html b/public/game.html
index d6fddab..5727384 100644
--- a/public/game.html
+++ b/public/game.html
@@ -73,6 +73,23 @@
   .tb-fill { height:100%; width:100%; background:var(--green); border-radius:999px; }
   .tb-fill.run { transition:width 5s linear, background 5s linear; width:0%; background:var(--red); }
   .tag2 { display:inline-block; font-size:11px; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); background:#f0ece3; border-radius:999px; padding:3px 10px; margin-bottom:8px; }
+  /* leaderboard + share (results screen) */
+  .lb { margin-top:18px; border-top:1px solid var(--line); padding-top:14px; }
+  .lb-head { font:600 15px 'Playfair Display', Georgia, serif; margin-bottom:8px; }
+  .lb-head span { color:var(--muted); font:400 12px -apple-system,sans-serif; }
+  .lb-submit { display:flex; gap:8px; margin-bottom:10px; }
+  .lb-submit input { flex:1; font:inherit; font-size:13.5px; padding:8px 11px; border:1px solid var(--line); border-radius:8px; }
+  .lb-btn { font:inherit; font-size:13px; padding:8px 14px; border-radius:8px; border:1px solid var(--ink); background:var(--ink); color:#fff; cursor:pointer; white-space:nowrap; }
+  .lb-btn:disabled { opacity:.5; cursor:default; }
+  .lb-list { margin:0; padding-left:0; list-style:none; counter-reset:lb; }
+  .lb-list li { counter-increment:lb; display:flex; justify-content:space-between; font-size:13.5px; padding:5px 2px; border-bottom:1px solid #f0ece3; }
+  .lb-list li::before { content:counter(lb) "."; color:var(--muted); width:24px; display:inline-block; }
+  .lb-list li .nm { flex:1; }
+  .lb-list li.me { font-weight:700; color:var(--green); }
+  .lb-list li .sc { font-variant-numeric:tabular-nums; }
+  .share { display:flex; gap:10px; justify-content:center; margin-top:16px; }
+  .sh-btn { font:inherit; font-size:13.5px; padding:9px 18px; border-radius:999px; border:1px solid var(--line); background:#fff; color:var(--ink); cursor:pointer; text-decoration:none; }
+  .sh-btn:hover { border-color:var(--ink); }
   @media (max-width:560px){ .choices{grid-template-columns:1fr} .games{grid-template-columns:1fr} }
   @media (max-width:700px){
     .masthead { height:auto; flex-wrap:wrap; padding:10px 14px; gap:8px 14px; }
@@ -152,6 +169,18 @@
     <div class="verdict" id="verdict"></div>
     <div class="misses" id="misses"></div>
     <div class="best" id="best"></div>
+    <div class="lb" id="lbWrap" hidden>
+      <div class="lb-head">🏆 Leaderboard <span id="lbTag"></span></div>
+      <div class="lb-submit" id="lbSubmit">
+        <input id="lbName" maxlength="24" placeholder="Your name (optional)" aria-label="Name for leaderboard">
+        <button class="lb-btn" id="lbPost">Post my score</button>
+      </div>
+      <ol class="lb-list" id="lbList"></ol>
+    </div>
+    <div class="share" id="shareRow">
+      <button class="sh-btn" id="shCopy">🔗 Copy result</button>
+      <a class="sh-btn" id="shTweet" target="_blank" rel="noopener noreferrer">𝕏 Share</a>
+    </div>
     <button class="again" id="againBtn">Play again</button>
   </div>
 </main>
@@ -471,8 +500,47 @@ function finish(){
   const best = Math.max(s, +(localStorage.getItem(key)||0));
   localStorage.setItem(key, best);
   $('#best').textContent = `Best (${gameKey} · ${diff}): ${best}`;
+
+  // ── social layer: share + leaderboard ──
+  const gameName = ({whose:'Whose Hand?',art:'Who Signed This?',match:'Match the Hand',early:'Early or Late?',century:'Date the Hand',lightning:'⚡ Lightning Round'})[gameKey]||'The Signature Games';
+  const shareText = `I scored ${s} on ${gameName} (${diff}) at The Signature Games — can you beat me?`;
+  const shareUrl = 'https://celebsignatures.com/game';
+  $('#shTweet').href = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`;
+  $('#shCopy').onclick = async () => {
+    try { await navigator.clipboard.writeText(shareText+' '+shareUrl); $('#shCopy').textContent='✓ Copied'; setTimeout(()=>$('#shCopy').textContent='🔗 Copy result',1500); }
+    catch { $('#shCopy').textContent='—'; }
+  };
+  // leaderboard
+  const lbKey = { game: gameKey, diff };
+  $('#lbTag').textContent = `· ${gameName} · ${diff}`;
+  $('#lbWrap').hidden = false;
+  $('#lbSubmit').style.display = '';
+  $('#lbName').value = localStorage.getItem('siggames.name') || '';
+  renderLeaderboard(null);
+  fetch(`/api/leaderboard?game=${gameKey}&diff=${diff}`).then(r=>r.json()).then(d=>renderLeaderboard(d.top||[]));
+  $('#lbPost').disabled = false;
+  $('#lbPost').onclick = async () => {
+    $('#lbPost').disabled = true;
+    const name = ($('#lbName').value||'Anonymous').slice(0,24);
+    localStorage.setItem('siggames.name', name);
+    try {
+      const d = await (await fetch('/api/leaderboard',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...lbKey, score:s, name})})).json();
+      renderLeaderboard(d.top||[], name, s, d.rank);
+      $('#lbSubmit').style.display='none';
+    } catch { $('#lbPost').disabled=false; }
+  };
+}
+function renderLeaderboard(top, meName, meScore, meRank){
+  const el=$('#lbList');
+  if (top===null){ el.innerHTML='<li style="border:none;color:#999">loading…</li>'; return; }
+  if (!top.length){ el.innerHTML='<li style="border:none;color:#999">Be the first — post your score!</li>'; return; }
+  el.innerHTML = top.map((e,i)=>{
+    const mine = meName!=null && e.name===meName && e.score===meScore && (i+1)===meRank;
+    return `<li class="${mine?'me':''}"><span class="nm">${esc(e.name)}</span><span class="sc">${e.score}</span></li>`;
+  }).join('');
+  if (meRank && meRank>20) el.innerHTML += `<li class="me" style="border-top:1px dashed var(--line)"><span class="nm">${esc(meName)} (you)</span><span class="sc">${meScore} · #${meRank}</span></li>`;
 }
-$('#againBtn').onclick = () => { $('#results').hidden = true; $('#setup').hidden = false; };
+$('#againBtn').onclick = () => { $('#results').hidden = true; $('#setup').hidden = false; $('#lbWrap').hidden = true; };
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index c396f19..8b8fe41 100644
--- a/server.js
+++ b/server.js
@@ -137,6 +137,33 @@ createServer(async (req, res) => {
       return sendJSON(res, 200, u ? { ok: true, user: pubUser(u) } : { ok: true, user: null });
     }
 
+    // ===== GAME LEADERBOARD (public, no account needed) =====
+    // Top-20 per game+difficulty. No auth — a name is optional and sanitized.
+    // Reversible local JSON; nothing customer-facing beyond a score list.
+    if (path === '/api/leaderboard') {
+      const board = await load('leaderboard.json', {});
+      if (M === 'GET') {
+        const key = (url.searchParams.get('game') || '') + '|' + (url.searchParams.get('diff') || '');
+        return sendJSON(res, 200, { ok: true, top: (board[key] || []).slice(0, 20) });
+      }
+      if (M === 'POST') {
+        const b = await readBody(req);
+        const game = String(b.game || '').replace(/[^a-z]/gi, '').slice(0, 20);
+        const diff = String(b.diff || '').replace(/[^a-z]/gi, '').slice(0, 20);
+        const score = Math.max(0, Math.min(9999, parseInt(b.score, 10) || 0));
+        const name = (String(b.name || 'Anonymous').replace(/[<>&"]/g, '').trim() || 'Anonymous').slice(0, 24);
+        if (!game || !diff) return sendJSON(res, 400, { ok: false, error: 'game + diff required' });
+        const key = game + '|' + diff;
+        board[key] = board[key] || [];
+        board[key].push({ name, score, at: new Date().toISOString() });
+        board[key].sort((a, b2) => b2.score - a.score);
+        board[key] = board[key].slice(0, 50); // keep a little headroom, serve 20
+        await store('leaderboard.json', board);
+        const rank = board[key].findIndex(e => e.name === name && e.score === score) + 1;
+        return sendJSON(res, 200, { ok: true, rank, top: board[key].slice(0, 20) });
+      }
+    }
+
     // ===== SAVED PLACEMENTS (per account) =====
     if (path === '/api/saves') {
       const u = await currentUser(req);

← e4183c5 yoloforever C1: scope-aware empty-search state (honest 'arch  ·  back to CelebritySignatures  ·  yoloforever C2 hardening (Cody FIX-FIRST, all verified real) cb358f7 →