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>
This commit is contained in:
drjones
2026-08-05 22:26:35 +00:00
parent 5210266fd6
commit 5c3393b989
6 changed files with 1148 additions and 189 deletions

View File

@@ -1,7 +1,7 @@
/* 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.
* 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.
@@ -10,17 +10,53 @@
* 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; // { publicKeyHex, privateKey (CryptoKey) }
let keypair = null;
let token = null;
let nickname = '';
let stake = 5000; // millisatoshis
let stake = 5000; // millisatoshis
let currentGame = 'rocket';
let socket = null;
let snapshot = null;
let myBet = null; // 'in' | 'out' | 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();
@@ -44,6 +80,11 @@ 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() {
@@ -88,10 +129,16 @@ async function signIn() {
$('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();
}
@@ -102,8 +149,7 @@ 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,
method, headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
@@ -112,11 +158,17 @@ async function api(method, path, body) {
}
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();
}
}
/* Reads the auto cash-out box. Returns 0 when empty or invalid, which the
* server treats as "no target". */
/* ---------------- auto cash-out ---------------- */
function autoTarget() {
const v = parseFloat($('auto-target').value);
return Number.isFinite(v) && v > 1 ? v : 0;
@@ -132,6 +184,7 @@ 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));
@@ -140,37 +193,54 @@ function connect(game) {
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');
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
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`;
$('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':
case 'running': {
$('state').textContent = 'in flight';
if (myBet === 'in') {
const payout = stake * parseFloat(s.multiplier);
const payout = stake * current;
action.textContent = `Cash out ${sats(payout)}`;
action.className = 'primary big cashout';
action.disabled = false;
@@ -180,21 +250,49 @@ function onSnapshot(s) {
action.disabled = true;
}
break;
case 'settled':
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
}
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;
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
// 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);
draw(s);
if (s.state === 'running') tone(parseFloat(s.multiplier));
}
/* 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) {
@@ -210,6 +308,13 @@ function renderPlayers(players) {
}
}
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 = '';
@@ -221,14 +326,22 @@ async function onAction() {
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';
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
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';
const b = await api('GET', '/api/balance');
setBalance(b.balance_msat);
buzz([25, 30, 25]);
refreshBalance();
}
} catch (e) {
hint.textContent = e.message;
@@ -236,131 +349,7 @@ async function onAction() {
}
}
/* ---------------- 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. */
/* ---------------- ambient sound ---------------- */
let audio = null;
@@ -391,23 +380,6 @@ function toggleSound() {
$('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 = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
@@ -426,7 +398,7 @@ async function loadScratch() {
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.onclick = () => playScratch(t, grid, result, button);
const table = el('table', { class: 'odds' },
el('tr', {},
@@ -454,9 +426,10 @@ async function loadScratch() {
}
}
async function playScratch(t, grid, result) {
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;
@@ -464,11 +437,16 @@ async function playScratch(t, grid, result) {
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();
// 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));
@@ -479,17 +457,47 @@ async function playScratch(t, grid, result) {
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 * 130);
}, 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() {
@@ -524,8 +532,7 @@ async function sendSats() {
}
}
/* ---------------- verifier ----------------
* Recomputed here, in the browser, from published values only. */
/* ---------------- verifier ---------------- */
async function sha256(bytes) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
@@ -613,7 +620,11 @@ function selectTab(view) {
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) {
@@ -626,7 +637,6 @@ function setStake(v) {
}
async function init() {
sizeCanvas();
keypair = await loadOrCreateKey();
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
@@ -644,7 +654,14 @@ async function init() {
(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]) => {
@@ -658,6 +675,7 @@ async function init() {
});
setStake(stake);
renderStrip();
}
init();