Files
casino/cmd/arcade/static/app.js
drjones d7fa097eab feat(ux): scan-to-cash-out, first-run walkthrough, plain language
Cashing out was the least approachable thing here: open your wallet,
create an invoice for exactly the right amount, copy it, come back, paste
it. That is the step people abandon, leaving sats behind.

LNURL-withdraw replaces it with a scan. The arcade shows a code, the
wallet pulls the funds, and the player never handles an invoice or types
an amount. The paste path is kept for wallets without LNURL support, but
folded away.

The withdraw token is a bearer instrument, so it is random, single-use,
bound to one account and one amount, and expires in five minutes. Sixteen
goroutines racing one code yield exactly one payment. Funds are debited
when the code is issued — otherwise a player could cash out and bet the
same sats before the wallet claimed them — and a sweep refunds any code
that is never scanned.

bech32 is verified against the BIP-173 vectors, including the invalid
ones. Getting this wrong produces codes that silently fail to scan with
no useful error for the player.

Adds a three-card first-run walkthrough, an explanation of what a
multiplier target means, and a one-time confirmation before a player's
first real-money action — the interface is deliberately frictionless, and
that is the one place a moment of friction is worth it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:05:52 +00:00

1068 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Quantum Arcade client.
*
* Identity is a keypair generated in the browser and kept in localStorage.
* There is no account to create and no password to lose.
*
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
* the server whether a round was fair — it checks.
*
* All dynamic content is inserted with textContent or built as DOM nodes.
* Nothing that originates from another player (nicknames, keys) or from the
* server ever reaches innerHTML. */
import { Arcade3D } from '/scene3d.js';
import * as charts from '/charts.js';
import * as qr from '/qr.js';
const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name';
const STATS_STORAGE = 'quantum-arcade-stats';
let keypair = null;
let token = null;
let nickname = '';
let stake = 5000; // millisatoshis
let currentGame = 'rocket';
let socket = null;
let snapshot = null;
let myBet = null; // 'in' | 'out' | null
let scene = null;
let balanceMsat = 0;
/* Session statistics, kept client-side. The ledger remains the authority on
* money; this is only for the charts. */
const stats = loadStats();
function loadStats() {
try {
const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
return {
crashes: raw.crashes || [], // crash points seen, for distribution
balances: raw.balances || [], // balance samples over time
wagered: raw.wagered || 0,
plays: raw.plays || 0,
wins: raw.wins || 0,
losses: raw.losses || 0,
best: raw.best || 0,
sessionStart: null, // set at sign-in, never persisted
};
} catch {
return { crashes: [], balances: [], wagered: 0, plays: 0,
wins: 0, losses: 0, best: 0, sessionStart: null };
}
}
function saveStats() {
// Cap the arrays so localStorage cannot grow without bound over a long night.
stats.crashes = stats.crashes.slice(-300);
stats.balances = stats.balances.slice(-300);
localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
}
const $ = (id) => document.getElementById(id);
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
function el(tag, attrs, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else node.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return node;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/* A short haptic tap. Phones only, and silently absent elsewhere. */
function buzz(ms) {
if (navigator.vibrate) navigator.vibrate(ms);
}
/* ---------------- identity ---------------- */
async function loadOrCreateKey() {
const stored = localStorage.getItem(KEY_STORAGE);
if (stored) {
const jwk = JSON.parse(stored);
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
}
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
const pubHex = hex(rawPub);
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
jwk.qa_pub = pubHex;
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
}
function hex(bytes) {
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function unhex(s) {
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
async function signIn() {
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
localStorage.setItem(NAME_STORAGE, nickname);
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
const sig = new Uint8Array(await crypto.subtle.sign(
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
const res = await api('POST', '/api/auth/verify', {
pubkey: keypair.publicKeyHex,
signature: hex(sig),
nickname,
});
token = res.token;
$('signin').hidden = true;
$('app').hidden = false;
$('tabs').hidden = false;
$('balance-wrap').hidden = false;
setBalance(res.balance_msat);
stats.sessionStart = res.balance_msat;
$('pubkey').textContent = keypair.publicKeyHex;
scene = new Arcade3D($('scene'));
scene.setGame(currentGame);
scene.start();
connect(currentGame);
loadScratch();
detectLightning();
startTour();
}
/* ---------------- api ---------------- */
async function api(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(path, {
method, headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function setBalance(msat) {
balanceMsat = msat;
$('balance').textContent = sats(msat);
const last = stats.balances[stats.balances.length - 1];
if (last !== msat) {
stats.balances.push(msat);
saveStats();
}
}
/* ---------------- auto cash-out ---------------- */
function autoTarget() {
const v = parseFloat($('auto-target').value);
return Number.isFinite(v) && v > 1 ? v : 0;
}
function refreshAutoRow() {
$('autorow').classList.toggle('armed', autoTarget() > 0);
}
/* ---------------- crash room ---------------- */
function connect(game) {
if (socket) socket.close();
currentGame = game;
myBet = null;
if (scene) scene.setGame(game);
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
}
/* The server sends a frame a few times a second; the multiplier curve is
* deterministic, so between frames the client computes it locally from the
* round's start time. This is why the animation is smooth without the server
* pushing sixty frames a second to every phone. */
const TICK_HZ = 60;
const ROUND_TICKS = 60 * TICK_HZ;
function multiplierAtTick(tick) {
if (tick <= 0) return 1;
if (tick >= ROUND_TICKS) tick = ROUND_TICKS - 1;
const remaining = 1 - tick / ROUND_TICKS;
return 1 / (remaining * remaining);
}
function localMultiplier() {
if (!snapshot || snapshot.state !== 'running' || !snapshot.started_unix_milli) {
return snapshot ? parseFloat(snapshot.multiplier) : 1;
}
const elapsedMs = Date.now() - snapshot.started_unix_milli;
return multiplierAtTick(Math.floor((elapsedMs / 1000) * TICK_HZ));
}
/* Runs every animation frame while a round is in flight, so the number and the
* 3D scene update at display rate rather than at network rate. */
function interpolate() {
if (!snapshot || snapshot.state !== 'running') return;
const m = localMultiplier();
$('multiplier').textContent = m.toFixed(2) + '×';
if (scene) scene.setState(Math.log(Math.max(1, m)) / Math.log(25), false);
if (myBet === 'in') {
$('action').textContent = `Cash out ${sats(stake * m)}`;
}
requestAnimationFrame(interpolate);
}
function onSnapshot(s) {
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
const wasRunning = snapshot && snapshot.state === 'running';
const wasSettled = snapshot && snapshot.state === 'settled';
snapshot = s;
if (roundChanged) myBet = null;
const mult = $('multiplier');
const current = parseFloat(s.multiplier);
mult.textContent = current.toFixed(2) + '×';
mult.className = 'multiplier';
$('commitment').textContent = s.commitment || '—';
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
// Counts come from the server aggregate: the player list in each frame is
// only the leaderboard, capped so a large room stays cheap to broadcast.
$('potline').textContent = s.player_count
? `${s.player_count} in · ${sats(s.pot_msat)} sats at stake` +
(s.cashed_out_count ? ` · ${s.cashed_out_count} out` : '')
: 'no bets yet';
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
// and the tail does not saturate instantly.
if (scene) {
scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
s.state === 'settled');
}
const action = $('action');
const hint = $('hint');
switch (s.state) {
case 'betting_open':
$('state').textContent =
`betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
action.textContent = myBet
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
: 'Place bet';
action.className = 'primary big';
action.disabled = !!myBet;
break;
case 'locked':
$('state').textContent = 'launching';
action.textContent = 'Launching…';
action.disabled = true;
break;
case 'running': {
$('state').textContent = 'in flight';
if (!wasRunning) requestAnimationFrame(interpolate);
if (myBet === 'in') {
const payout = stake * current;
action.textContent = `Cash out ${sats(payout)}`;
action.className = 'primary big cashout';
action.disabled = false;
} else {
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
action.className = 'primary big';
action.disabled = true;
}
break;
}
case 'settled': {
const crash = s.crash_point ? parseFloat(s.crash_point) : current;
mult.textContent = crash.toFixed(2) + '×';
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
$('state').textContent =
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
action.textContent = 'Bet again';
action.className = 'primary big';
action.disabled = false;
// Record the round once, on the transition into settled.
if (!wasSettled && s.crash_point) {
stats.crashes.push(crash);
if (myBet === 'in') {
stats.losses++;
// Near-miss psychology: show how close they were
const autoTarget = parseFloat($('auto-target').value || '0');
if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
hint.className = 'hint near-miss';
} else {
hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
hint.className = 'hint bad';
}
stats.streak = 0;
buzz(120);
} else if (myBet === 'out') {
stats.wins++;
stats.streak = (stats.streak || 0) + 1;
const payout = Math.round(stake * crash / 1000);
hint.textContent = stats.streak >= 5
? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
: stats.streak >= 3
? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
: `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
hint.className = 'hint good';
if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
else buzz([30, 40, 30]);
// Auto-increment stake on hot streak
if (stats.streak >= 3 && stake < 25000) {
const newStake = stake * 2;
setStake(newStake);
hint.textContent += ' • Stake doubled!';
}
}
if (!stats.best || crash > stats.best) stats.best = crash;
saveStats();
renderStrip();
refreshBalance();
}
// Pre-fill for instant re-bet: auto-bet on next round
if (!wasSettled && myBet === 'out') {
action.classList.add('pulse');
}
break;
}
}
renderPlayers(s.players);
}
/* The recent-rounds strip under the stage. */
function renderStrip() {
const wrap = $('strip');
clear(wrap);
for (const c of stats.crashes.slice(-24).reverse()) {
const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
}
}
function renderPlayers(players) {
const wrap = $('players');
clear(wrap);
for (const p of players || []) {
const amount = p.cashed_out
? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
: sats(p.stake_msat) + ' sats';
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
el('span', { class: 'who', text: p.nickname || 'anon' }),
el('span', { class: 'amt', text: amount })));
}
}
async function refreshBalance() {
try {
const b = await api('GET', '/api/balance');
setBalance(b.balance_msat);
} catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
}
async function onAction() {
const hint = $('hint');
hint.textContent = '';
hint.className = 'hint';
try {
if (snapshot.state === 'betting_open' && !myBet) {
if (!(await confirmRealMoney(`Bet ${sats(stake)} sats?`,
'Rounds are played with real satoshis. If the game crashes before ' +
'you cash out, the stake is lost.'))) {
return;
}
const r = await api('POST', '/api/bet', {
game: currentGame, stake_msat: stake, nickname,
auto_cashout: autoTarget(),
});
setBalance(r.balance_msat);
stats.wagered += stake;
stats.plays++;
saveStats();
myBet = 'in';
buzz(20);
} else if (snapshot.state === 'running' && myBet === 'in') {
const r = await api('POST', '/api/cashout', { game: currentGame });
myBet = 'out';
const at = parseFloat(r.cashed_out_at);
const won = stake * at;
if (won > stats.best) stats.best = won;
saveStats();
hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
hint.className = 'hint good';
buzz([25, 30, 25]);
refreshBalance();
}
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
}
}
/* ---------------- ambient sound ---------------- */
let audio = null;
function toggleSound() {
if (audio) {
audio.close(); audio = null;
$('sound-toggle').classList.remove('on');
return;
}
audio = new (window.AudioContext || window.webkitAudioContext)();
const gain = audio.createGain();
gain.gain.value = 0.05;
gain.connect(audio.destination);
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
[55, 82.5].forEach((f) => {
const o = audio.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const lfo = audio.createOscillator();
lfo.frequency.value = 0.05 + Math.random() * 0.06;
const depth = audio.createGain();
depth.gain.value = 1.5;
lfo.connect(depth).connect(o.frequency);
o.connect(gain);
o.start(); lfo.start();
});
$('sound-toggle').classList.add('on');
}
/* ---------------- scratch tickets ---------------- */
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
async function loadScratch() {
const { tickets } = await api('GET', '/api/scratch/catalog');
const wrap = $('tickets');
clear(wrap);
for (const t of tickets) {
const grid = el('div', { class: 'grid c' + t.cells });
for (let i = 0; i < t.cells; i++) {
grid.appendChild(el('div', { class: 'cell', text: '?' }));
}
const result = el('div', { class: 'result' });
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
button.dataset.play = t.id;
button.onclick = () => playScratch(t, grid, result, button);
const table = el('table', { class: 'odds' },
el('tr', {},
el('th', { text: 'prize' }),
el('th', { text: 'pays' }),
el('th', { text: 'chance' })));
for (const o of t.odds) {
table.appendChild(el('tr', {},
el('td', { text: o.tier }),
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
}
wrap.appendChild(el('div', { class: 'card' },
el('h3', { text: t.name }),
el('p', { class: 'muted small', text: t.blurb }),
grid,
result,
button,
table,
el('p', { class: 'small' },
'Return to player: ',
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
'. These odds are read from the same table that generates results.')));
}
}
async function playScratch(t, grid, result, button) {
result.textContent = '';
result.className = 'result';
button.disabled = true;
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
let out;
try {
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
} catch (e) {
result.textContent = e.message;
button.disabled = false;
return;
}
setBalance(out.balance_msat);
stats.wagered += stake;
stats.plays++;
if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
saveStats();
const cells = out.outcome.cells;
const counts = {};
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
cells.forEach((sym, i) => {
setTimeout(() => {
const cell = grid.children[i];
cell.textContent = SYMBOLS[sym];
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
buzz(8);
if (i === cells.length - 1) {
const won = out.outcome.payout_msat > 0;
result.className = 'result' + (won ? ' win' : '');
result.textContent = won
? `${out.outcome.tier_name}${sats(out.outcome.payout_msat)} sats`
: 'No win this time';
if (won) buzz([40, 50, 40]);
button.disabled = false;
}
}, i * 120);
});
}
/* ---------------- portfolio ---------------- */
function renderPortfolio() {
$('t-balance').textContent = sats(balanceMsat);
charts.sparkline($('spark-balance'), stats.balances.slice(-40));
const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
const sess = $('t-session');
sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');
$('t-wagered').textContent = sats(stats.wagered);
$('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
$('t-best').textContent = sats(stats.best);
charts.balanceChart($('chart-balance'),
stats.balances.map((b) => ({ BalanceAfter: b })));
charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
charts.distributionChart($('chart-dist'), stats.crashes);
charts.crashHistoryChart($('chart-history'), stats.crashes);
const total = stats.wins + stats.losses;
$('donut-note').textContent = total
? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
: '';
}
/* ---------------- lightning ----------------
*
* The deposit and withdraw cards stay hidden unless the server reports a node,
* so a play-money deployment does not advertise a deposit it cannot honour. */
let depositHash = null;
let depositPoll = null;
async function detectLightning() {
try {
// A zero-amount request is rejected either way; what distinguishes the two
// cases is whether the server says "not configured" or "bad amount".
const res = await fetch('/api/deposit', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
Authorization: 'Bearer ' + token },
body: JSON.stringify({ amount_sats: 0 }),
});
const data = await res.json().catch(() => ({}));
const configured = !(data.error || '').includes('lightning not configured');
$('card-deposit').hidden = !configured;
$('card-withdraw').hidden = !configured;
} catch {
$('card-deposit').hidden = true;
$('card-withdraw').hidden = true;
}
}
async function createDeposit() {
const hint = $('dep-hint');
hint.textContent = '';
hint.className = 'hint';
const amount = Math.floor(Number($('dep-amt').value));
if (!Number.isFinite(amount) || amount < 1) {
hint.textContent = 'Enter an amount in sats.';
hint.className = 'hint bad';
return;
}
let res;
try {
res = await api('POST', '/api/deposit', { amount_sats: amount });
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
return;
}
depositHash = res.payment_hash;
$('dep-bolt11').textContent = res.invoice;
$('dep-invoice').hidden = false;
const wrap = $('dep-qr');
clear(wrap);
// Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
// more densely, which keeps the symbol scannable on a phone screen.
const matrix = qr.encode(res.invoice.toUpperCase());
if (matrix) {
wrap.appendChild(qr.render(matrix));
} else {
wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
}
$('dep-status').textContent = 'Waiting for payment…';
startDepositPolling();
}
/* Poll for settlement. The server confirms with the node before crediting, so
* this cannot be used to claim a payment that never arrived. */
function startDepositPolling() {
if (depositPoll) clearInterval(depositPoll);
let attempts = 0;
depositPoll = setInterval(async () => {
if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
clearInterval(depositPoll);
depositPoll = null;
return;
}
try {
const res = await api('POST', '/api/deposit/check',
{ payment_hash: depositHash });
if (res.credited_msat > 0 || res.settled) {
clearInterval(depositPoll);
depositPoll = null;
depositHash = null;
$('dep-status').textContent = 'Paid. Balance updated.';
$('dep-invoice').hidden = true;
buzz([40, 50, 40]);
await refreshBalance();
loadHistory();
}
} catch {
// Not settled yet is the normal case and arrives as an error; keep
// waiting rather than treating it as a failure.
}
}, 5000);
}
/* Cash out by showing a code the player's wallet pulls from. The invoice-paste
* path stays available for wallets that cannot scan LNURL, but it is folded
* away because it is the step most people give up on. */
async function withdrawByCode() {
const hint = $('wd-hint');
hint.textContent = '';
hint.className = 'hint';
let amount = Math.floor(Number($('wd-amt').value));
if (!Number.isFinite(amount) || amount < 1) {
hint.textContent = 'Pick an amount first.';
hint.className = 'hint bad';
return;
}
if (!(await confirmRealMoney(`Cash out ${amount.toLocaleString()} sats?`,
'This sends real satoshis to your wallet and removes them from your ' +
'arcade balance.'))) {
return;
}
let res;
try {
res = await api('POST', '/api/withdraw/code', { amount_sats: amount });
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
return;
}
setBalance(res.balance_msat);
const wrap = $('wd-qr');
clear(wrap);
const matrix = qr.encode(res.lnurl);
if (matrix) {
wrap.appendChild(qr.render(matrix, { foreground: '#ffb000' }));
} else {
wrap.textContent = 'Could not render the code — copy it instead.';
}
$('wd-code').hidden = false;
$('wd-copy').dataset.code = res.lnurl;
$('wd-expiry').textContent =
`Scan within ${Math.round(res.expires_in / 60)} minutes. ` +
'If you do not, the sats come back to your balance.';
hint.className = 'hint good';
hint.textContent = 'Scan with your Lightning wallet.';
loadHistory();
}
/* The manual path, for wallets without LNURL support. */
async function withdrawToInvoice() {
const hint = $('wd-hint');
hint.textContent = '';
hint.className = 'hint';
const bolt11 = $('wd-bolt11').value.trim();
const amount = Math.floor(Number($('wd-amt').value));
if (!bolt11.toLowerCase().startsWith('ln')) {
hint.textContent = 'That does not look like a Lightning invoice.';
hint.className = 'hint bad';
return;
}
if (!Number.isFinite(amount) || amount < 1) {
hint.textContent = 'Enter the amount the invoice is for.';
hint.className = 'hint bad';
return;
}
if (!(await confirmRealMoney(`Send ${amount.toLocaleString()} sats?`,
'This pays the invoice you pasted with real satoshis.'))) {
return;
}
try {
const res = await api('POST', '/api/withdraw', { bolt11, amount_sats: amount });
hint.className = 'hint good';
hint.textContent = res.status === 'needs_approval'
? 'Queued for approval — large cash-outs are reviewed.'
: 'Queued. It should arrive within about fifteen seconds.';
$('wd-bolt11').value = '';
await refreshBalance();
loadHistory();
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
}
}
/* ---------------- real-money confirmation ----------------
*
* Shown once, the first time a player moves real value. The interface is
* deliberately frictionless, and the one thing worth a moment of friction is
* someone not registering that the sats are real. */
const CONFIRMED_KEY = 'quantum-arcade-understands-real-money';
function confirmRealMoney(title, body) {
if (localStorage.getItem(CONFIRMED_KEY) === '1') return Promise.resolve(true);
return new Promise((resolve) => {
const overlay = $('tour');
overlay.classList.add('confirm');
overlay.hidden = false;
$('tour-step').textContent = 'Real satoshis';
$('tour-title').textContent = title;
$('tour-body').textContent = body;
clear($('tour-dots'));
$('tour-next').textContent = 'I understand';
$('tour-skip').textContent = 'Cancel';
const finish = (ok) => {
overlay.hidden = true;
overlay.classList.remove('confirm');
$('tour-skip').textContent = 'Skip';
if (ok) localStorage.setItem(CONFIRMED_KEY, '1');
resolve(ok);
};
$('tour-next').onclick = () => finish(true);
$('tour-skip').onclick = () => finish(false);
});
}
/* ---------------- first-run walkthrough ---------------- */
const TOUR_KEY = 'quantum-arcade-tour-done';
const TOUR = [
{
title: 'You are already signed in',
body: 'No account, no password. This device made a key that is your ' +
'identity. Keep the device, keep the balance.',
},
{
title: 'Bet, then get out',
body: 'The number climbs. Tap Cash out before it crashes and you keep ' +
'the multiple. Wait too long and the stake is gone.',
},
{
title: 'Nobody can rig it',
body: 'The result is sealed before betting opens and mixed with every ' +
'player\'s key. Tap Verify after any round to check it yourself.',
},
];
function startTour() {
if (localStorage.getItem(TOUR_KEY) === '1') return;
let i = 0;
const overlay = $('tour');
overlay.hidden = false;
const render = () => {
const step = TOUR[i];
$('tour-step').textContent = `Step ${i + 1} of ${TOUR.length}`;
$('tour-title').textContent = step.title;
$('tour-body').textContent = step.body;
$('tour-next').textContent = i === TOUR.length - 1 ? 'Play' : 'Got it';
const dots = $('tour-dots');
clear(dots);
TOUR.forEach((_, n) => dots.appendChild(el('span', { class: n === i ? 'on' : '' })));
};
const finish = () => {
overlay.hidden = true;
localStorage.setItem(TOUR_KEY, '1');
};
$('tour-next').onclick = () => {
if (++i >= TOUR.length) finish();
else render();
};
$('tour-skip').onclick = finish;
render();
}
/* ---------------- wallet ---------------- */
async function loadHistory() {
const { entries } = await api('GET', '/api/history');
const wrap = $('history');
clear(wrap);
for (const e of entries || []) {
wrap.appendChild(el('div', { class: 'entry' },
el('span', { class: 'kind', text: e.Kind }),
el('span', {
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
}),
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
}
}
async function sendSats() {
const hint = $('send-hint');
try {
const r = await api('POST', '/api/transfer', {
to_pubkey: $('to-key').value.trim(),
amount_msat: Math.round(Number($('send-amt').value) * 1000),
});
setBalance(r.balance_msat);
hint.textContent = 'Sent.';
hint.className = 'hint good';
loadHistory();
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
}
}
/* ---------------- verifier ---------------- */
async function sha256(bytes) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
}
async function hmacSha256(keyBytes, msg) {
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
}
function concat(...arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; }
return out;
}
function kvRow(label, value) {
return el('div', { class: 'kv' },
el('span', { text: label }),
el('code', { text: value }));
}
async function verifyRound() {
const out = $('verify-out');
clear(out);
out.appendChild(el('p', { text: 'checking…' }));
let r;
try {
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
} catch (e) {
clear(out);
out.appendChild(el('p', { class: 'bad', text: e.message }));
return;
}
// 1. The revealed seed must hash to the commitment published beforehand.
const seedBytes = unhex(r.server_seed);
const seedHash = hex(await sha256(seedBytes));
const commitOK = seedHash === r.commitment;
// 2. The client seed must be the hash over participants, length-prefixed.
const parts = [];
for (const p of r.participants || []) {
const pk = unhex(p);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, pk.length, false);
parts.push(len, pk);
}
const clientSeed = await sha256(concat(...parts));
const clientOK = hex(clientSeed) === r.client_seed;
// 3. The round seed follows from both, and determines the crash point.
const nonceBytes = new Uint8Array(8);
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
const allOK = commitOK && clientOK;
clear(out);
out.appendChild(el('p', {
class: allOK ? 'ok' : 'bad',
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
}));
out.appendChild(kvRow('commitment', r.commitment));
out.appendChild(kvRow('sha256(seed)', seedHash));
out.appendChild(kvRow('client seed', r.client_seed || ''));
out.appendChild(kvRow('recomputed', hex(clientSeed)));
out.appendChild(kvRow('round seed', hex(roundSeed)));
out.appendChild(kvRow('crash point',
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
out.appendChild(el('p', {
class: 'muted small',
text: 'Computed on this device. The server was asked only for the published '
+ 'values, not for its opinion.',
}));
}
/* ---------------- wiring ---------------- */
function selectTab(view) {
document.querySelectorAll('.tab').forEach((t) =>
t.classList.toggle('active', t.dataset.view === view));
document.querySelectorAll('.view').forEach((v) =>
(v.hidden = v.id !== 'view-' + view));
// The 3D loop only runs while its view is visible.
if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
if (view === 'wallet') loadHistory();
if (view === 'portfolio') renderPortfolio();
}
function setStake(v) {
stake = v;
document.querySelectorAll('.chip').forEach((c) =>
c.classList.toggle('on', Number(c.dataset.stake) === v));
document.querySelectorAll('[data-play]').forEach((b) => {
b.textContent = `Scratch for ${sats(stake)} sats`;
});
}
async function init() {
keypair = await loadOrCreateKey();
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
$('enter').onclick = () => signIn().catch((e) => {
$('keynote').textContent = e.message;
});
$('action').onclick = onAction;
$('sound-toggle').onclick = toggleSound;
$('send').onclick = sendSats;
$('do-verify').onclick = verifyRound;
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
document.querySelectorAll('.tab').forEach((t) =>
(t.onclick = () => selectTab(t.dataset.view)));
document.querySelectorAll('.chip').forEach((c) =>
(c.onclick = () => setStake(Number(c.dataset.stake))));
$('auto-target').oninput = refreshAutoRow;
$('do-deposit').onclick = createDeposit;
$('do-withdraw').onclick = withdrawByCode;
$('do-withdraw-manual').onclick = withdrawToInvoice;
$('wd-copy').onclick = (e) =>
navigator.clipboard.writeText(e.currentTarget.dataset.code || '');
document.querySelectorAll('[data-wd]').forEach((b) => {
b.onclick = () => {
$('wd-amt').value = b.dataset.wd === 'all'
? Math.floor(balanceMsat / 1000)
: b.dataset.wd;
};
});
$('dep-copy').onclick = () =>
navigator.clipboard.writeText($('dep-bolt11').textContent);
$('dep-check').onclick = async () => {
if (!depositHash) return;
try {
const res = await api('POST', '/api/deposit/check',
{ payment_hash: depositHash });
if (res.credited_msat > 0 || res.settled) {
$('dep-status').textContent = 'Paid. Balance updated.';
$('dep-invoice').hidden = true;
depositHash = null;
await refreshBalance();
} else {
$('dep-status').textContent = 'Not settled yet — still waiting.';
}
} catch (e) {
$('dep-status').textContent = e.message;
}
};
document.querySelectorAll('[data-dep]').forEach((b) => {
b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
});
document.querySelectorAll('[data-auto]').forEach((b) => {
b.onclick = () => {
$('auto-target').value = b.dataset.auto;
refreshAutoRow();
};
});
const pick = $('gamepick');
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
b.onclick = () => {
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
connect(id);
};
pick.appendChild(b);
});
setStake(stake);
renderStrip();
}
init();