/* Quantum Arcade client. * * Identity is an ed25519 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. */ const KEY_STORAGE = 'quantum-arcade-key'; const NAME_STORAGE = 'quantum-arcade-name'; let keypair = null; // { publicKeyHex, privateKey (CryptoKey) } let token = null; let nickname = ''; let stake = 5000; // millisatoshis let currentGame = 'rocket'; let socket = null; let snapshot = null; let myBet = null; // 'in' | 'out' | null 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); } /* ---------------- 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; $('balance-wrap').hidden = false; setBalance(res.balance_msat); $('pubkey').textContent = keypair.publicKeyHex; 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) { $('balance').textContent = sats(msat); } /* Reads the auto cash-out box. Returns 0 when empty or invalid, which the * server treats as "no target". */ 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; 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); } function onSnapshot(s) { const roundChanged = !snapshot || snapshot.round_id !== s.round_id; snapshot = s; if (roundChanged) myBet = null; const mult = $('multiplier'); mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×'; mult.className = 'multiplier'; $('commitment').textContent = s.commitment || '—'; $('revealed').textContent = s.server_seed || 'sealed until the round ends'; 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 (myBet === 'in') { const payout = stake * parseFloat(s.multiplier); 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': if (s.crash_point) mult.textContent = parseFloat(s.crash_point).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; if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; } break; } renderPlayers(s.players); draw(s); if (s.state === 'running') tone(parseFloat(s.multiplier)); } 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 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); myBet = 'in'; } else if (snapshot.state === 'running' && myBet === 'in') { const r = await api('POST', '/api/cashout', { game: currentGame }); myBet = 'out'; hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`; hint.className = 'hint good'; const b = await api('GET', '/api/balance'); setBalance(b.balance_msat); } } catch (e) { hint.textContent = e.message; hint.className = 'hint bad'; } } /* ---------------- rendering ---------------- * Each game draws the same climb differently: a rocket fighting gravity, a * craft spiralling inward, or a tower stacking upward. */ const canvas = $('canvas'); const ctx = canvas.getContext('2d'); let stars = []; function sizeCanvas() { const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); stars = Array.from({ length: 70 }, () => ({ x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3, })); } window.addEventListener('resize', sizeCanvas); function draw(s) { const w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return; ctx.clearRect(0, 0, w, h); const m = parseFloat(s.multiplier) || 1; const crashed = s.state === 'settled'; const progress = Math.min(1, Math.log(m) / Math.log(12)); // Scrolling grid: a horizon that rushes past as the multiplier climbs. ctx.strokeStyle = '#0a7a5233'; ctx.lineWidth = 1; const spacing = 34; const offset = (progress * 260) % spacing; for (let x = 0; x <= w; x += spacing) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = -spacing + offset; y <= h; y += spacing) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // Sparse drifting particles, phosphor green. ctx.fillStyle = '#00ff9c'; for (const st of stars) { const y = (st.y + progress * 0.9) % 1; ctx.globalAlpha = 0.12 + st.r * 0.18; ctx.fillRect(st.x * w, y * h, st.r, st.r); } ctx.globalAlpha = 1; if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed); else if (currentGame === 'tower') drawTower(w, h, progress, crashed); else drawRocket(w, h, progress, crashed); } function drawRocket(w, h, p, crashed) { const x = w * 0.5; const y = h * (0.88 - p * 0.66); const accent = crashed ? '#ff3355' : '#00ff9c'; // Exhaust plume: longer and more agitated as the climb steepens. const plume = 26 + p * 60; const g = ctx.createLinearGradient(x, y, x, y + plume); g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc'); g.addColorStop(1, '#00ff9c00'); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(x - 7, y + 8); ctx.lineTo(x + 7, y + 8); ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume); ctx.closePath(); ctx.fill(); ctx.fillStyle = accent; ctx.beginPath(); ctx.moveTo(x, y - 18); ctx.lineTo(x + 9, y + 10); ctx.lineTo(x - 9, y + 10); ctx.closePath(); ctx.fill(); if (crashed) { ctx.strokeStyle = '#ff335588'; ctx.lineWidth = 2; for (let i = 0; i < 9; i++) { const a = (i / 9) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34); ctx.stroke(); } } } function drawOrbital(w, h, p, crashed) { const cx = w / 2, cy = h / 2; const planet = Math.min(w, h) * 0.16; ctx.fillStyle = '#00291c'; ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill(); const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26; ctx.strokeStyle = crashed ? '#ff335555' : '#00ff9c55'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke(); const a = p * Math.PI * 9; const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit; ctx.fillStyle = crashed ? '#ff3355' : '#00ff9c'; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); } function drawTower(w, h, p, crashed) { const blocks = Math.floor(p * 16) + 1; const bw = w * 0.28, bh = h * 0.05; for (let i = 0; i < blocks; i++) { const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1); const y = h * 0.9 - (i + 1) * bh; ctx.fillStyle = i === blocks - 1 ? (crashed ? '#ff3355' : '#00ff9c') : `hsl(${160 - i} 90% ${12 + i}%)`; ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2); } } /* ---------------- ambient sound ---------------- * Synthesised, so it never loops and ships no audio files. */ 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'); } let lastTone = 0; function tone(mult) { if (!audio) return; const now = audio.currentTime; if (now - lastTone < 0.28) return; lastTone = now; const o = audio.createOscillator(); const g = audio.createGain(); o.type = 'triangle'; o.frequency.value = 220 * Math.min(4, mult); g.gain.setValueAtTime(0.0001, now); g.gain.exponentialRampToValueAtTime(0.03, now + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); o.connect(g).connect(audio.destination); o.start(now); o.stop(now + 0.3); } /* ---------------- 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); 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) { result.textContent = ''; result.className = 'result'; [...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; return; } setBalance(out.balance_msat); // Reveal cells one at a time — the outcome is already fixed, this is pacing. 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' : ''); 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'; } }, i * 130); }); } /* ---------------- 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 ---------------- * Recomputed here, in the browser, from published values only. */ 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)); if (view === 'wallet') loadHistory(); } 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() { sizeCanvas(); 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; 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); } init();