/* 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'; 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(); } /* ---------------- 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 = 'Next round'; action.className = 'primary big'; action.disabled = true; // Record the round once, on the transition into settled. if (!wasSettled && s.crash_point) { stats.crashes.push(crash); if (myBet === 'in') { stats.losses++; hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; buzz(120); } else if (myBet === 'out') { stats.wins++; buzz([30, 40, 30]); } saveStats(); renderStrip(); refreshBalance(); } 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) { 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.` : ''; } /* ---------------- 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; 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();