Files
casino/cmd/arcade/static/app.js
drjones 5c3393b989 feat(ui): 3D scenes, mobile-first layout, portfolio charts
Three WebGL scenes on one renderer and one context, since a phone should
not allocate a context per game: a rocket straining against gravity, a
decaying orbit, and a tower that sways harder the higher it stacks. The
loop stops when the tab is hidden.

Navigation moves to the bottom, where a thumb already is. New Stats view
with balance history, cash-out rate, and a distribution chart that plots
observed crash points against what the published maths predicts — the
honest version of a hot-numbers board.

Charts are hand-built SVG, ~300 lines, rather than a library that would
cost more to load than the 3D engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:26:35 +00:00

682 lines
22 KiB
JavaScript
Raw 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';
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);
}
function onSnapshot(s) {
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
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';
const potMsat = (s.players || []).reduce((n, p) => n + p.stake_msat, 0);
$('potline').textContent = potMsat
? `${(s.players || []).length} in · ${sats(potMsat)} sats at stake`
: '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 (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();