launch features: win feed, public stats, share-a-win, live CTA

Four pre-launch additions to make the game feel alive and shareable:

- Live win feed: server broadcasts 'win_feed' on every escape; client shows a
  transient toast ('X escaped with the bag') so the room feels active.
- Public stats: GET /api/stats (10s-cached) returns games + escapes in the last
  24h and total paid out; a status-area strip polls it for social proof.
- Share-a-win: game-over screen gets 'Share to X' + 'Copy link' buttons pointing
  at the /verify page, which now renders OpenGraph/Twitter-card meta (brand OG
  image per instance) so shared links unfurl with a preview.
- Land-on-action: a dismissible 'N games live now — watch' CTA appears on connect
  when games are in progress, opening the existing spectator panel. Non-intrusive:
  hidden while playing/spectating and never hijacks the canvas.
This commit is contained in:
jwinterm
2026-06-01 09:17:48 -04:00
parent 00b013978f
commit 63e3006aca
10 changed files with 324 additions and 4 deletions
+3
View File
@@ -80,6 +80,7 @@
<script src="js/ui/transactionHistory.js"></script>
<script src="js/ui/leaderboard.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/ui/publicStats.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
<script src="js/input/inputHandler.js"></script>
@@ -98,6 +99,8 @@
<div class="status">
<div id="blockHeight">Block Height: <span id="heightValue">Loading...</span></div>
<div id="connectionStatus">Status: <span id="statusValue">Connecting...</span></div>
<div id="publicStats" style="display:none; margin-top:4px; font-size:11px; color:#9cf; font-family:monospace; line-height:1.4;"></div>
<div id="liveCta" style="display:none; margin-top:6px; cursor:pointer; background:rgba(80,0,0,0.55); border:1px solid #f33; border-radius:4px; padding:5px 10px; font-size:12px; font-family:monospace; color:#ff8a8a; font-weight:bold;" title="Watch a live game"></div>
<div id="creditsDisplay" style="display:none; margin-top:4px; font-size:13px; font-family:monospace; color:#0f0; background:#001a00; padding:4px 8px; border:1px solid #0a0; border-radius:4px; font-weight:bold;"></div>
<button id="startButton" style="background: #0a5c0a; color: #ff0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; display: none;">
🎮 <span class="lbl-full">START GAME</span><span class="lbl-short">START</span>
+103
View File
@@ -158,6 +158,35 @@ const SocketHandlers = {
socket.on('spectate_start', this.onSpectateStart);
socket.on('spectator_update', this.onSpectatorUpdate);
socket.on('spectate_ended', this.onSpectateEnded);
// Global win feed (someone escaped) -> floating toast
socket.on('win_feed', this.onWinFeed);
},
// Show a transient "someone just escaped" toast so the room feels alive. Toasts stack in a
// fixed container (top-right), auto-dismiss, and never block the UI.
onWinFeed: function(data) {
if (!data) return;
var name = String(data.name || 'Someone').slice(0, 24);
var bag = data.treasure ? ' 💰 with the bag' : '';
var paid = data.paid ? ' <span style="color:#fbbf24;">(payout!)</span>' : '';
try {
var $c = $('#win-feed');
if (!$c.length) {
$c = $('<div id="win-feed"></div>').appendTo('body');
}
var $t = $('<div class="win-toast"></div>').html(
'🏆 <strong>' + escapeHtml(name) + '</strong> escaped' + bag + paid
);
$c.append($t);
// Fade in, hold, fade out, remove.
requestAnimationFrame(function () { $t.addClass('show'); });
setTimeout(function () { $t.removeClass('show'); }, 5000);
setTimeout(function () { $t.remove(); }, 5600);
// Cap the number of simultaneous toasts.
var $all = $c.children('.win-toast');
if ($all.length > 4) { $all.first().remove(); }
} catch (e) { /* non-critical */ }
},
onConnect: function() {
@@ -523,6 +552,12 @@ const SocketHandlers = {
));
}
// Offer a one-tap "Share to X" on wins (brag mechanic). The verify link unfurls with a
// social card (OG meta on the /verify page), so a shared tweet shows a preview.
if (won) {
SocketHandlers._appendShareWin(data);
}
UI.scrollChat();
if (won) {
SocketHandlers._setBannerStatus('Won', '#0f0');
@@ -531,6 +566,43 @@ const SocketHandlers = {
}
},
// Build an absolute verify URL for this game (used by the share button).
_verifyUrlFor: function(data) {
var path = (data && data.proof && (data.proof.verificationUrl ||
(data.proof.gameId ? '/verify/' + data.proof.gameId : null)));
if (!path) return null;
try { return new URL(path, window.location.origin).href; }
catch (e) { return window.location.origin + path; }
},
_appendShareWin: function(data) {
var url = SocketHandlers._verifyUrlFor(data);
if (!url) return; // no proof -> nothing to verify/share
var bag = data && data.treasure ? ' with the bag 💰' : '';
var score = (data && typeof data.score === 'number') ? (' (score ' + data.score + ')') : '';
var text = '🏆 I escaped the dungeon' + bag + score + ' in this provably-fair crypto roguelike!';
var intent = 'https://twitter.com/intent/tweet?text=' +
encodeURIComponent(text) + '&url=' + encodeURIComponent(url);
var $row = $('<li class="share-win" style="margin-top:6px; padding:8px; background:rgba(0,40,0,0.4); border-radius:4px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">');
$row.append($('<strong style="color:#4ade80;">').text('Brag about it:'));
var $x = $('<button type="button" class="share-x-btn" style="cursor:pointer; background:#000; color:#fff; border:1px solid #555; border-radius:4px; padding:4px 10px; font-size:12px;">𝕏 Share to X</button>');
$x.on('click', function () { window.open(intent, '_blank', 'noopener'); });
var $copy = $('<button type="button" class="share-copy-btn" style="cursor:pointer; background:#053655; color:#0af; border:1px solid #0af; border-radius:4px; padding:4px 10px; font-size:12px;">🔗 Copy link</button>');
$copy.on('click', function () {
var done = function () { $copy.text('✅ Copied!'); setTimeout(function () { $copy.text('🔗 Copy link'); }, 1500); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt('Copy this link:', url); });
} else { window.prompt('Copy this link:', url); }
});
$row.append($x).append($copy);
$('#messages').append($row);
},
onQueueCancelled: function(data) {
// Clear queued state
SocketHandlers._isQueued = false;
@@ -1097,6 +1169,37 @@ const SocketHandlers = {
if (!data || !data.games) return;
SocketHandlers._activeGames = data.games;
SocketHandlers._updateGamesListPanel(data);
SocketHandlers._updateLiveCta(data);
},
// "Land on action": when live games are in progress, surface a prominent, dismissible CTA
// in the status area so a new visitor immediately sees the room is alive and can jump into
// spectating with one click. Deliberately NON-intrusive — it never hijacks the canvas or
// interrupts someone who came to play (hidden while in a game / spectating, and dismissible).
_liveCtaDismissed: false,
_updateLiveCta: function(data) {
var el = document.getElementById('liveCta');
if (!el) return;
var liveCount = (data.games || []).length;
var inGame = (typeof Game !== 'undefined' && (Game._gameActive || Game._isSpectating)) || SocketHandlers._spectatorMode || SocketHandlers._isQueued;
var panelOpen = $('#gamesListPanel').is(':visible');
if (liveCount > 0 && !inGame && !panelOpen && !SocketHandlers._liveCtaDismissed) {
el.innerHTML = '🔴 ' + liveCount + ' game' + (liveCount === 1 ? '' : 's') +
' live right now — <span style="text-decoration:underline;">watch &raquo;</span>';
el.style.display = 'block';
if (!el._bound) {
el._bound = true;
el.addEventListener('click', function () {
SocketHandlers._liveCtaDismissed = true; // don't nag after they engage
el.style.display = 'none';
if (window.socket) socket.emit('get_active_games', { page: 1, pageSize: 20 });
SocketHandlers._showGamesPanel();
});
}
} else {
el.style.display = 'none';
}
},
onSpectateStart: function(data) {
+45
View File
@@ -0,0 +1,45 @@
/**
* Public stats strip — light "social proof" shown in the status area: games and escapes in
* the last 24h, and total paid out (only where payouts are enabled). Polls /api/stats, which
* is cached server-side (10s), so a 30s poll is cheap. Online-player count is intentionally
* NOT shown here — it already updates live via the 'user_count' socket event.
*/
(function () {
var EL_ID = 'publicStats';
function fmtAmount(n) {
if (!isFinite(n)) return '0';
// Trim to a tidy magnitude: whole numbers plain, otherwise up to 4 dp without trailing zeros.
if (n >= 1000) return Math.round(n).toLocaleString();
var s = (Math.round(n * 10000) / 10000).toString();
return s;
}
function render(data) {
var el = document.getElementById(EL_ID);
if (!el || !data) return;
var parts = [];
parts.push('🎮 ' + (data.gamesToday || 0) + ' games (24h)');
parts.push('🏆 ' + (data.escapesToday || 0) + ' escapes');
if (data.payoutsEnabled && data.totalPaidOut > 0) {
parts.push('💰 ' + fmtAmount(data.totalPaidOut) + ' ' + (data.currencyLabel || '') + ' paid out');
}
el.innerHTML = parts.join(' &nbsp;·&nbsp; ');
el.style.display = 'block';
}
function poll() {
fetch('/api/stats')
.then(function (r) { return r.json(); })
.then(render)
.catch(function () { /* ignore; transient */ });
}
function start() {
poll();
setInterval(poll, 30000);
}
if (document.readyState !== 'loading') start();
else document.addEventListener('DOMContentLoaded', start);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+31
View File
@@ -314,6 +314,37 @@ li.debug {
}
/* =========================================================================
Win feed toasts (global "someone escaped" announcements)
========================================================================= */
#win-feed {
position: fixed;
top: 56px;
right: 12px;
z-index: 1800;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
max-width: min(320px, 80vw);
}
.win-toast {
background: rgba(0, 30, 0, 0.92);
border: 1px solid #0f0;
border-left: 4px solid #4ade80;
border-radius: 6px;
color: #cffccf;
font-family: 'PixelOperator', monospace;
font-size: 13px;
padding: 8px 12px;
box-shadow: 0 0 14px rgba(0, 255, 0, 0.18);
opacity: 0;
transform: translateX(40px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.win-toast.show { opacity: 1; transform: translateX(0); }
.win-toast strong { color: #4ade80; }
/* =========================================================================
Mobile / touch support
========================================================================= */