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