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. /* Quantum Arcade client.
* *
* Identity is an ed25519 keypair generated in the browser and kept in * Identity is a keypair generated in the browser and kept in localStorage.
* localStorage. There is no account to create and no password to lose. * There is no account to create and no password to lose.
* *
* The verifier recomputes round outcomes locally with WebCrypto. It never asks * The verifier recomputes round outcomes locally with WebCrypto. It never asks
* the server whether a round was fair — it checks. * the server whether a round was fair — it checks.
@@ -10,17 +10,53 @@
* Nothing that originates from another player (nicknames, keys) or from the * Nothing that originates from another player (nicknames, keys) or from the
* server ever reaches innerHTML. */ * server ever reaches innerHTML. */
import { Arcade3D } from '/scene3d.js';
import * as charts from '/charts.js';
const KEY_STORAGE = 'quantum-arcade-key'; const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name'; 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 token = null;
let nickname = ''; let nickname = '';
let stake = 5000; // millisatoshis let stake = 5000; // millisatoshis
let currentGame = 'rocket'; let currentGame = 'rocket';
let socket = null; let socket = null;
let snapshot = 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 $ = (id) => document.getElementById(id);
const sats = (msat) => Math.round(msat / 1000).toLocaleString(); const sats = (msat) => Math.round(msat / 1000).toLocaleString();
@@ -44,6 +80,11 @@ function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild); 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 ---------------- */ /* ---------------- identity ---------------- */
async function loadOrCreateKey() { async function loadOrCreateKey() {
@@ -88,10 +129,16 @@ async function signIn() {
$('signin').hidden = true; $('signin').hidden = true;
$('app').hidden = false; $('app').hidden = false;
$('tabs').hidden = false;
$('balance-wrap').hidden = false; $('balance-wrap').hidden = false;
setBalance(res.balance_msat); setBalance(res.balance_msat);
stats.sessionStart = res.balance_msat;
$('pubkey').textContent = keypair.publicKeyHex; $('pubkey').textContent = keypair.publicKeyHex;
scene = new Arcade3D($('scene'));
scene.setGame(currentGame);
scene.start();
connect(currentGame); connect(currentGame);
loadScratch(); loadScratch();
} }
@@ -102,8 +149,7 @@ async function api(method, path, body) {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token; if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(path, { const res = await fetch(path, {
method, method, headers,
headers,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -112,11 +158,17 @@ async function api(method, path, body) {
} }
function setBalance(msat) { function setBalance(msat) {
balanceMsat = msat;
$('balance').textContent = sats(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 /* ---------------- auto cash-out ---------------- */
* server treats as "no target". */
function autoTarget() { function autoTarget() {
const v = parseFloat($('auto-target').value); const v = parseFloat($('auto-target').value);
return Number.isFinite(v) && v > 1 ? v : 0; return Number.isFinite(v) && v > 1 ? v : 0;
@@ -132,6 +184,7 @@ function connect(game) {
if (socket) socket.close(); if (socket) socket.close();
currentGame = game; currentGame = game;
myBet = null; myBet = null;
if (scene) scene.setGame(game);
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`); socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data)); socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
@@ -140,37 +193,54 @@ function connect(game) {
function onSnapshot(s) { function onSnapshot(s) {
const roundChanged = !snapshot || snapshot.round_id !== s.round_id; const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
const wasSettled = snapshot && snapshot.state === 'settled';
snapshot = s; snapshot = s;
if (roundChanged) myBet = null; if (roundChanged) myBet = null;
const mult = $('multiplier'); const mult = $('multiplier');
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×'; const current = parseFloat(s.multiplier);
mult.textContent = current.toFixed(2) + '×';
mult.className = 'multiplier'; mult.className = 'multiplier';
$('commitment').textContent = s.commitment || '—'; $('commitment').textContent = s.commitment || '—';
$('revealed').textContent = s.server_seed || 'sealed until the round ends'; $('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 action = $('action');
const hint = $('hint'); const hint = $('hint');
switch (s.state) { switch (s.state) {
case 'betting_open': 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 action.textContent = myBet
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck') ? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
: 'Place bet'; : 'Place bet';
action.className = 'primary big'; action.className = 'primary big';
action.disabled = !!myBet; action.disabled = !!myBet;
break; break;
case 'locked': case 'locked':
$('state').textContent = 'launching'; $('state').textContent = 'launching';
action.textContent = 'Launching…'; action.textContent = 'Launching…';
action.disabled = true; action.disabled = true;
break; break;
case 'running':
case 'running': {
$('state').textContent = 'in flight'; $('state').textContent = 'in flight';
if (myBet === 'in') { if (myBet === 'in') {
const payout = stake * parseFloat(s.multiplier); const payout = stake * current;
action.textContent = `Cash out ${sats(payout)}`; action.textContent = `Cash out ${sats(payout)}`;
action.className = 'primary big cashout'; action.className = 'primary big cashout';
action.disabled = false; action.disabled = false;
@@ -180,21 +250,49 @@ function onSnapshot(s) {
action.disabled = true; action.disabled = true;
} }
break; 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'; mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
$('state').textContent = $('state').textContent =
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`; `crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
action.textContent = 'Next round'; action.textContent = 'Next round';
action.className = 'primary big'; action.className = 'primary big';
action.disabled = true; 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; break;
}
} }
renderPlayers(s.players); 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) { 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() { async function onAction() {
const hint = $('hint'); const hint = $('hint');
hint.textContent = ''; hint.textContent = '';
@@ -221,14 +326,22 @@ async function onAction() {
auto_cashout: autoTarget(), auto_cashout: autoTarget(),
}); });
setBalance(r.balance_msat); setBalance(r.balance_msat);
stats.wagered += stake;
stats.plays++;
saveStats();
myBet = 'in'; myBet = 'in';
buzz(20);
} else if (snapshot.state === 'running' && myBet === 'in') { } else if (snapshot.state === 'running' && myBet === 'in') {
const r = await api('POST', '/api/cashout', { game: currentGame }); const r = await api('POST', '/api/cashout', { game: currentGame });
myBet = 'out'; 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'; hint.className = 'hint good';
const b = await api('GET', '/api/balance'); buzz([25, 30, 25]);
setBalance(b.balance_msat); refreshBalance();
} }
} catch (e) { } catch (e) {
hint.textContent = e.message; hint.textContent = e.message;
@@ -236,131 +349,7 @@ async function onAction() {
} }
} }
/* ---------------- rendering ---------------- /* ---------------- ambient sound ---------------- */
* 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; let audio = null;
@@ -391,23 +380,6 @@ function toggleSound() {
$('sound-toggle').classList.add('on'); $('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 ---------------- */ /* ---------------- scratch tickets ---------------- */
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡']; const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
@@ -426,7 +398,7 @@ async function loadScratch() {
const result = el('div', { class: 'result' }); const result = el('div', { class: 'result' });
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` }); const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
button.dataset.play = t.id; button.dataset.play = t.id;
button.onclick = () => playScratch(t, grid, result); button.onclick = () => playScratch(t, grid, result, button);
const table = el('table', { class: 'odds' }, const table = el('table', { class: 'odds' },
el('tr', {}, 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.textContent = '';
result.className = 'result'; result.className = 'result';
button.disabled = true;
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; }); [...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
let out; 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 }); out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
} catch (e) { } catch (e) {
result.textContent = e.message; result.textContent = e.message;
button.disabled = false;
return; return;
} }
setBalance(out.balance_msat); 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 cells = out.outcome.cells;
const counts = {}; const counts = {};
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1)); cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
@@ -479,17 +457,47 @@ async function playScratch(t, grid, result) {
const cell = grid.children[i]; const cell = grid.children[i];
cell.textContent = SYMBOLS[sym]; cell.textContent = SYMBOLS[sym];
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : ''); cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
buzz(8);
if (i === cells.length - 1) { if (i === cells.length - 1) {
const won = out.outcome.payout_msat > 0; const won = out.outcome.payout_msat > 0;
result.className = 'result' + (won ? ' win' : ''); result.className = 'result' + (won ? ' win' : '');
result.textContent = won result.textContent = won
? `${out.outcome.tier_name}${sats(out.outcome.payout_msat)} sats` ? `${out.outcome.tier_name}${sats(out.outcome.payout_msat)} sats`
: 'No win this time'; : '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 ---------------- */ /* ---------------- wallet ---------------- */
async function loadHistory() { async function loadHistory() {
@@ -524,8 +532,7 @@ async function sendSats() {
} }
} }
/* ---------------- verifier ---------------- /* ---------------- verifier ---------------- */
* Recomputed here, in the browser, from published values only. */
async function sha256(bytes) { async function sha256(bytes) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', 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)); t.classList.toggle('active', t.dataset.view === view));
document.querySelectorAll('.view').forEach((v) => document.querySelectorAll('.view').forEach((v) =>
(v.hidden = v.id !== 'view-' + view)); (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 === 'wallet') loadHistory();
if (view === 'portfolio') renderPortfolio();
} }
function setStake(v) { function setStake(v) {
@@ -626,7 +637,6 @@ function setStake(v) {
} }
async function init() { async function init() {
sizeCanvas();
keypair = await loadOrCreateKey(); keypair = await loadOrCreateKey();
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…'; $('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
$('nickname').value = localStorage.getItem(NAME_STORAGE) || ''; $('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
@@ -644,7 +654,14 @@ async function init() {
(t.onclick = () => selectTab(t.dataset.view))); (t.onclick = () => selectTab(t.dataset.view)));
document.querySelectorAll('.chip').forEach((c) => document.querySelectorAll('.chip').forEach((c) =>
(c.onclick = () => setStake(Number(c.dataset.stake)))); (c.onclick = () => setStake(Number(c.dataset.stake))));
$('auto-target').oninput = refreshAutoRow; $('auto-target').oninput = refreshAutoRow;
document.querySelectorAll('[data-auto]').forEach((b) => {
b.onclick = () => {
$('auto-target').value = b.dataset.auto;
refreshAutoRow();
};
});
const pick = $('gamepick'); const pick = $('gamepick');
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => { [['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
@@ -658,6 +675,7 @@ async function init() {
}); });
setStake(stake); setStake(stake);
renderStrip();
} }
init(); init();

274
cmd/arcade/static/charts.js Normal file
View File

@@ -0,0 +1,274 @@
/* Quantum Arcade — charts.
*
* Hand-built SVG rather than a charting library. The whole set is under 300
* lines and adds nothing to load time, where Chart.js would cost more than the
* 3D engine. Everything is built with createElementNS, so no untrusted value
* ever reaches innerHTML.
*
* Colour follows the rest of the interface: green is neutral information,
* amber is money you gained, red is money you lost. Nothing is coloured
* decoratively. */
const NS = 'http://www.w3.org/2000/svg';
const GREEN = '#00ff9c';
const GREEN_DIM = '#0a7a52';
const AMBER = '#ffb000';
const RED = '#ff3355';
const MAGENTA = '#ff2e88';
function svg(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
return el;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/* Charts scale to their container: the viewBox is fixed and CSS handles the
* rest, so one implementation serves phone and desktop. */
function frame(host, w, h) {
clear(host);
const root = svg('svg', {
viewBox: `0 0 ${w} ${h}`,
preserveAspectRatio: 'none',
width: '100%',
height: '100%',
});
host.appendChild(root);
return root;
}
function emptyState(host, message) {
clear(host);
const p = document.createElement('p');
p.className = 'muted small chart-empty';
p.textContent = message;
host.appendChild(p);
}
/* ---------------- balance over time ---------------- */
/* An area chart of balance after each transaction, oldest to newest.
* The fill is split at the starting balance so being up reads amber and being
* down reads red without needing a legend. */
export function balanceChart(host, entries) {
if (!entries || entries.length < 2) {
emptyState(host, 'Play a few rounds and your balance history appears here.');
return;
}
const W = 300, H = 110, PAD = 4;
const root = frame(host, W, H);
const values = entries.map((e) => e.BalanceAfter / 1000); // sats
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const start = values[0];
const x = (i) => PAD + (i / (values.length - 1)) * (W - PAD * 2);
const y = (v) => H - PAD - ((v - min) / span) * (H - PAD * 2);
// Baseline at the starting balance, so the shape reads against where you began.
const baseY = y(start);
root.appendChild(svg('line', {
x1: 0, y1: baseY, x2: W, y2: baseY,
stroke: GREEN_DIM, 'stroke-width': 1, 'stroke-dasharray': '3 3', opacity: 0.6,
}));
const points = values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
const up = values[values.length - 1] >= start;
const colour = up ? AMBER : RED;
root.appendChild(svg('polygon', {
points: `${x(0)},${baseY} ${points} ${x(values.length - 1)},${baseY}`,
fill: colour, opacity: 0.16,
}));
root.appendChild(svg('polyline', {
points, fill: 'none', stroke: colour,
'stroke-width': 1.6, 'stroke-linejoin': 'round',
}));
// Mark the latest point.
root.appendChild(svg('circle', {
cx: x(values.length - 1), cy: y(values[values.length - 1]),
r: 2.6, fill: colour,
}));
}
/* ---------------- recent crash points ---------------- */
/* Bars of where recent rounds ended. Bars at or above 2x are amber, below are
* dim — so the streaks you actually care about are visible at a glance. */
export function crashHistoryChart(host, crashes) {
if (!crashes || crashes.length === 0) {
emptyState(host, 'No settled rounds yet.');
return;
}
const W = 300, H = 90, PAD = 3;
const root = frame(host, W, H);
const shown = crashes.slice(-40);
// Log scale: crash points are heavy-tailed, and a linear axis makes every
// round below 10x look identical.
const scale = (v) => Math.log(Math.max(1, v)) / Math.log(60);
const bw = (W - PAD * 2) / shown.length;
shown.forEach((v, i) => {
const hgt = Math.max(2, scale(v) * (H - PAD * 2));
root.appendChild(svg('rect', {
x: PAD + i * bw + bw * 0.15,
y: H - PAD - hgt,
width: bw * 0.7,
height: hgt,
fill: v >= 10 ? MAGENTA : v >= 2 ? AMBER : GREEN_DIM,
opacity: v >= 2 ? 0.95 : 0.65,
}));
});
// 2x reference line — the break-even point for the most common target.
const y2 = H - PAD - scale(2) * (H - PAD * 2);
root.appendChild(svg('line', {
x1: 0, y1: y2, x2: W, y2: y2,
stroke: AMBER, 'stroke-width': 0.8, 'stroke-dasharray': '4 4', opacity: 0.55,
}));
}
/* ---------------- win / loss split ---------------- */
/* A donut, because the only question it answers is a ratio. */
export function winLossDonut(host, wins, losses) {
const total = wins + losses;
if (total === 0) {
emptyState(host, 'No completed rounds yet.');
return;
}
const S = 120, R = 44, CX = S / 2, CY = S / 2;
const root = frame(host, S, S);
const circ = 2 * Math.PI * R;
const winFrac = wins / total;
root.appendChild(svg('circle', {
cx: CX, cy: CY, r: R, fill: 'none',
stroke: RED, 'stroke-width': 12, opacity: 0.55,
}));
root.appendChild(svg('circle', {
cx: CX, cy: CY, r: R, fill: 'none',
stroke: AMBER, 'stroke-width': 12,
'stroke-dasharray': `${circ * winFrac} ${circ}`,
transform: `rotate(-90 ${CX} ${CY})`,
'stroke-linecap': 'butt',
}));
const pct = svg('text', {
x: CX, y: CY + 2, 'text-anchor': 'middle',
fill: AMBER, 'font-size': 20, 'font-family': 'ui-monospace, monospace',
'font-weight': 700,
});
pct.textContent = `${Math.round(winFrac * 100)}%`;
root.appendChild(pct);
const label = svg('text', {
x: CX, y: CY + 18, 'text-anchor': 'middle',
fill: GREEN_DIM, 'font-size': 8.5, 'font-family': 'ui-monospace, monospace',
'letter-spacing': 1.5,
});
label.textContent = 'CASHED OUT';
root.appendChild(label);
}
/* ---------------- multiplier distribution ---------------- */
/* A histogram of where rounds ended, with the theoretical curve drawn over it.
* This is the honest version of a "hot numbers" board: instead of implying a
* pattern, it shows observed frequency against what the published maths
* predicts, so a player can see for themselves that they agree. */
export function distributionChart(host, crashes) {
if (!crashes || crashes.length < 5) {
emptyState(host, 'Needs a few more rounds before the shape is meaningful.');
return;
}
const W = 300, H = 110, PAD = 4;
const root = frame(host, W, H);
const buckets = [
{ label: '1-1.5', lo: 1, hi: 1.5 },
{ label: '1.5-2', lo: 1.5, hi: 2 },
{ label: '2-3', lo: 2, hi: 3 },
{ label: '3-5', lo: 3, hi: 5 },
{ label: '5-10', lo: 5, hi: 10 },
{ label: '10+', lo: 10, hi: Infinity },
];
const counts = buckets.map((b) => crashes.filter((c) => c >= b.lo && c < b.hi).length);
const maxCount = Math.max(...counts, 1);
const bw = (W - PAD * 2) / buckets.length;
// Expected share for each bucket: P(crash >= x) = 0.99 / x.
const expected = buckets.map((b) => {
const hi = b.hi === Infinity ? 0 : 0.99 / b.hi;
return 0.99 / b.lo - hi;
});
counts.forEach((n, i) => {
const hgt = (n / maxCount) * (H - PAD * 2 - 12);
root.appendChild(svg('rect', {
x: PAD + i * bw + bw * 0.18,
y: H - PAD - 12 - hgt,
width: bw * 0.64,
height: Math.max(1, hgt),
fill: GREEN, opacity: 0.5,
}));
const label = svg('text', {
x: PAD + i * bw + bw / 2, y: H - 3,
'text-anchor': 'middle', fill: GREEN_DIM,
'font-size': 7, 'font-family': 'ui-monospace, monospace',
});
label.textContent = buckets[i].label;
root.appendChild(label);
});
// The predicted shape, scaled to the same axis.
const maxExpected = Math.max(...expected);
const curve = expected.map((e, i) => {
const hgt = (e / maxExpected) * (H - PAD * 2 - 12) * (maxCount / crashes.length) /
(maxCount / crashes.length);
return `${PAD + i * bw + bw / 2},${H - PAD - 12 - hgt}`;
}).join(' ');
root.appendChild(svg('polyline', {
points: curve, fill: 'none', stroke: MAGENTA,
'stroke-width': 1.3, 'stroke-dasharray': '3 2', opacity: 0.9,
}));
}
/* ---------------- sparkline ---------------- */
/* A tiny inline trend, for stat tiles. */
export function sparkline(host, values) {
if (!values || values.length < 2) {
clear(host);
return;
}
const W = 80, H = 22;
const root = frame(host, W, H);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const points = values.map((v, i) =>
`${(i / (values.length - 1)) * W},${H - 2 - ((v - min) / span) * (H - 4)}`).join(' ');
root.appendChild(svg('polyline', {
points, fill: 'none',
stroke: values[values.length - 1] >= values[0] ? AMBER : RED,
'stroke-width': 1.4, 'stroke-linejoin': 'round',
}));
}

View File

@@ -2,13 +2,16 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<meta name="theme-color" content="#000000">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>QUANTUM ARCADE</title> <title>QUANTUM ARCADE</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. --> <!-- Circuit-trace ornament, tiled once behind everything. -->
<svg class="filigree" aria-hidden="true"> <svg class="filigree" aria-hidden="true">
<defs> <defs>
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse"> <pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
@@ -33,39 +36,43 @@
<button class="sound" id="sound-toggle" title="Ambient sound"></button> <button class="sound" id="sound-toggle" title="Ambient sound"></button>
</header> </header>
<!-- Sign-in: a nickname and nothing else. --> <!-- Sign-in -->
<section class="panel center" id="signin"> <section class="panel center" id="signin">
<h1>ACCESS TERMINAL</h1> <h1>ACCESS TERMINAL</h1>
<p class="muted small"> <p class="muted small">
No account. No email. No password. This device generated a keypair that No account. No email. No password. This device generates a keypair
<em>is</em> your identity. Keep the device, keep the balance. that <em>is</em> your identity.
</p> </p>
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off"> <input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
<button class="primary" id="enter">Connect</button> <button class="primary" id="enter">Connect</button>
<p class="fineprint" id="keynote"></p> <p class="fineprint" id="keynote"></p>
<div class="cryptobadge">
<span>Provably fair</span>
<span>SHA-256 commit-reveal</span>
<span>AGPL-3.0</span>
</div>
</section> </section>
<main id="app" hidden> <main id="app" hidden>
<nav class="tabs">
<button class="tab active" data-view="crash">Crash</button>
<button class="tab" data-view="scratch">Scratchers</button>
<button class="tab" data-view="wallet">Wallet</button>
<button class="tab" data-view="verify">Verify</button>
</nav>
<!-- ============ CRASH ============ --> <!-- ============ CRASH ============ -->
<section class="view" id="view-crash"> <section class="view" id="view-crash">
<div class="gamepick" id="gamepick"></div> <div class="gamepick" id="gamepick"></div>
<div class="stage"> <div class="stage">
<canvas id="canvas"></canvas> <canvas id="scene"></canvas>
<div class="readout"> <div class="readout">
<div class="multiplier" id="multiplier">1.00×</div> <div class="multiplier" id="multiplier">1.00×</div>
<div class="state" id="state">connecting…</div> <div class="state" id="state">connecting…</div>
</div> </div>
<div class="stagetop">
<span class="pot" id="potline"></span>
</div>
</div> </div>
<!-- Live crash history strip, directly under the stage. -->
<div class="strip" id="strip"></div>
<div class="controls"> <div class="controls">
<div class="stakerow"> <div class="stakerow">
<button class="chip" data-stake="1000">1</button> <button class="chip" data-stake="1000">1</button>
@@ -74,11 +81,17 @@
<button class="chip" data-stake="100000">100</button> <button class="chip" data-stake="100000">100</button>
<span class="unit">sats</span> <span class="unit">sats</span>
</div> </div>
<div class="autorow" id="autorow"> <div class="autorow" id="autorow">
<label for="auto-target">auto&nbsp;out</label> <label for="auto-target">auto&nbsp;out</label>
<input id="auto-target" type="number" min="1.01" step="0.1" <input id="auto-target" type="number" min="1.01" step="0.1"
inputmode="decimal" placeholder="off"> inputmode="decimal" placeholder="off">
<span class="x">×</span> <span class="x">×</span>
<div class="presets">
<button data-auto="1.5">1.5</button>
<button data-auto="2">2</button>
<button data-auto="5">5</button>
</div>
</div> </div>
<button class="primary big" id="action">Place bet</button> <button class="primary big" id="action">Place bet</button>
@@ -92,9 +105,9 @@
<div class="kv"><span>commitment</span><code id="commitment"></code></div> <div class="kv"><span>commitment</span><code id="commitment"></code></div>
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div> <div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
<p class="muted small"> <p class="muted small">
The commitment is published before betting opens. The crash point is The commitment is published before betting opens. The crash point comes
derived from that seed combined with every player's key — so it cannot from that seed combined with every player's key — so it cannot be
be chosen after seeing who joined. chosen after seeing who joined.
</p> </p>
</details> </details>
</section> </section>
@@ -104,6 +117,58 @@
<div id="tickets"></div> <div id="tickets"></div>
</section> </section>
<!-- ============ PORTFOLIO ============ -->
<section class="view" id="view-portfolio" hidden>
<div class="tiles">
<div class="tile">
<span class="tile-label">balance</span>
<span class="tile-value" id="t-balance"></span>
<div class="tile-spark" id="spark-balance"></div>
</div>
<div class="tile">
<span class="tile-label">session</span>
<span class="tile-value" id="t-session"></span>
<span class="tile-sub" id="t-session-sub">since you connected</span>
</div>
<div class="tile">
<span class="tile-label">wagered</span>
<span class="tile-value" id="t-wagered"></span>
<span class="tile-sub" id="t-plays">0 plays</span>
</div>
<div class="tile">
<span class="tile-label">best hit</span>
<span class="tile-value" id="t-best"></span>
<span class="tile-sub">largest single win</span>
</div>
</div>
<div class="card">
<h2>Balance over time</h2>
<div class="chart" id="chart-balance"></div>
</div>
<div class="card">
<h2>Cash-out rate</h2>
<div class="chart chart-donut" id="chart-donut"></div>
<p class="muted small" id="donut-note"></p>
</div>
<div class="card">
<h2>Where rounds ended</h2>
<div class="chart" id="chart-dist"></div>
<p class="muted small">
Bars are what actually happened. The dashed line is what the published
maths predicts. They should converge — that is the point.
</p>
</div>
<div class="card">
<h2>Recent rounds</h2>
<div class="chart" id="chart-history"></div>
</div>
</section>
<!-- ============ WALLET ============ --> <!-- ============ WALLET ============ -->
<section class="view" id="view-wallet" hidden> <section class="view" id="view-wallet" hidden>
<div class="card"> <div class="card">
@@ -115,7 +180,8 @@
<div class="card"> <div class="card">
<h2>Send sats</h2> <h2>Send sats</h2>
<input id="to-key" placeholder="recipient key" autocomplete="off"> <input id="to-key" placeholder="recipient key" autocomplete="off">
<input id="send-amt" type="number" min="1" placeholder="amount in sats"> <input id="send-amt" type="number" min="1" inputmode="numeric"
placeholder="amount in sats">
<button class="primary" id="send">Send</button> <button class="primary" id="send">Send</button>
<div class="hint" id="send-hint"></div> <div class="hint" id="send-hint"></div>
</div> </div>
@@ -133,14 +199,61 @@
Enter a round number. Your phone recomputes the outcome from the Enter a round number. Your phone recomputes the outcome from the
published seeds — it does not take the server's word for anything. published seeds — it does not take the server's word for anything.
</p> </p>
<input id="verify-id" type="number" min="1" placeholder="round number"> <input id="verify-id" type="number" inputmode="numeric" min="1"
placeholder="round number">
<button class="primary" id="do-verify">Verify</button> <button class="primary" id="do-verify">Verify</button>
<div id="verify-out" class="verify-out"></div> <div id="verify-out" class="verify-out"></div>
</div> </div>
<div class="card">
<h2>Underlying tech</h2>
<dl class="specs">
<dt>Identity</dt>
<dd>Ed25519 signed challenge, single-use and replay-proof.
Hybrid Ed25519 + ML-DSA-65 (FIPS 204) is implemented and tested
server-side; browser signing lands with the WASM module.</dd>
<dt>Transport</dt>
<dd>TLS 1.3 with X25519MLKEM768 hybrid key exchange when served over
HTTPS — post-quantum against harvest-now-decrypt-later</dd>
<dt>Fairness</dt>
<dd>SHA-256 commitment, HMAC-SHA256 outcome derivation. Hash-based,
so Grover only halves the margin: quantum-resistant as it stands.</dd>
<dt>Settlement</dt>
<dd>Bitcoin and Lightning sign with secp256k1, which is
<em>not</em> post-quantum. No application choice changes that.</dd>
<dt>Simulation</dt>
<dd>Q32.32 fixed-point, zero floating point, bit-identical replay</dd>
<dt>Ledger</dt>
<dd>Append-only double-entry, DB-enforced, audited every request</dd>
<dt>House edge</dt>
<dd>1.00% — verified by test across 2,000,000 simulated plays</dd>
<dt>Licence</dt>
<dd>AGPL-3.0 — every line auditable, forks must publish</dd>
</dl>
</div>
</section> </section>
</main> </main>
<script src="/app.js"></script> <!-- Bottom navigation: thumb-reachable, which is where navigation belongs on a
phone held one-handed at a party. -->
<nav class="tabs" id="tabs" hidden>
<button class="tab active" data-view="crash">
<span class="ico"></span><span>Crash</span>
</button>
<button class="tab" data-view="scratch">
<span class="ico"></span><span>Scratch</span>
</button>
<button class="tab" data-view="portfolio">
<span class="ico"></span><span>Stats</span>
</button>
<button class="tab" data-view="wallet">
<span class="ico"></span><span>Wallet</span>
</button>
<button class="tab" data-view="verify">
<span class="ico"></span><span>Verify</span>
</button>
</nav>
<script type="module" src="/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,389 @@
/* Quantum Arcade — 3D rendering.
*
* Three scenes sharing one renderer, because a phone should allocate exactly
* one WebGL context no matter how many games it switches between.
*
* Performance rules, in priority order, because this has to hold sixty frames
* on a mid-range phone in someone's hand at a party:
* - device pixel ratio capped at 2, dropped to 1.5 on small screens
* - low-poly geometry, no shadow maps, no post-processing passes
* - additive materials for glow instead of a bloom pass
* - particles are one BufferGeometry updated in place, never re-allocated
* - the render loop stops entirely when the tab is hidden
*/
import * as THREE from '/vendor/three.module.min.js';
const GREEN = 0x00ff9c;
const MAGENTA = 0xff2e88;
const AMBER = 0xffb000;
const RED = 0xff3355;
const PARTICLES = 220;
export class Arcade3D {
constructor(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: false, // FXAA-free; the aesthetic is crisp anyway
alpha: true,
powerPreference: 'high-performance',
});
this.renderer.setClearColor(0x000000, 0);
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400);
this.clock = new THREE.Clock();
this.scenes = {
rocket: this.buildRocket(),
orbital: this.buildOrbital(),
tower: this.buildTower(),
};
this.active = 'rocket';
this.progress = 0;
this.crashed = false;
this.shake = 0;
this.resize();
window.addEventListener('resize', () => this.resize());
// Stop rendering when the page is hidden; a background tab burning GPU on
// someone's phone is a battery bug, not a feature.
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.stop();
else this.start();
});
}
resize() {
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
if (!w || !h) return;
const cap = w < 500 ? 1.5 : 2;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap));
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
/* ---------- shared pieces ---------- */
// A wireframe floor grid that scrolls toward the camera, which is what sells
// the sensation of speed.
makeGrid() {
const grid = new THREE.GridHelper(120, 40, GREEN, 0x0a7a52);
grid.material.transparent = true;
grid.material.opacity = 0.28;
grid.position.y = -6;
return grid;
}
// One BufferGeometry of points, recycled every frame.
makeParticles(spread = 40) {
const positions = new Float32Array(PARTICLES * 3);
const speeds = new Float32Array(PARTICLES);
for (let i = 0; i < PARTICLES; i++) {
positions[i * 3] = (Math.random() - 0.5) * spread;
positions[i * 3 + 1] = (Math.random() - 0.5) * spread;
positions[i * 3 + 2] = (Math.random() - 0.5) * spread;
speeds[i] = 0.4 + Math.random() * 1.6;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: GREEN, size: 0.22, transparent: true, opacity: 0.75,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const points = new THREE.Points(geo, mat);
points.userData.speeds = speeds;
points.userData.spread = spread;
return points;
}
glowLine(geometry, color, opacity = 1) {
return new THREE.LineSegments(
new THREE.EdgesGeometry(geometry),
new THREE.LineBasicMaterial({
color, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
}
/* ---------- rocket: a climb against gravity ---------- */
buildRocket() {
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.012);
const grid = this.makeGrid();
scene.add(grid);
const particles = this.makeParticles(50);
scene.add(particles);
// Low-poly craft: a cone body with a wireframe overlay so it reads as
// "instrument" rather than "toy".
const body = new THREE.Mesh(
new THREE.ConeGeometry(0.7, 2.4, 6),
new THREE.MeshBasicMaterial({ color: 0x00291c }));
const wire = this.glowLine(new THREE.ConeGeometry(0.7, 2.4, 6), GREEN);
const craft = new THREE.Group();
craft.add(body, wire);
scene.add(craft);
// Exhaust: a stretched cone that grows with thrust.
const plume = new THREE.Mesh(
new THREE.ConeGeometry(0.45, 2.2, 6),
new THREE.MeshBasicMaterial({
color: GREEN, transparent: true, opacity: 0.6,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
plume.rotation.x = Math.PI;
plume.position.y = -1.9;
craft.add(plume);
// The planet you are leaving, which shrinks as you climb.
const planet = this.glowLine(new THREE.IcosahedronGeometry(9, 1), 0x0a7a52, 0.5);
planet.position.y = -18;
scene.add(planet);
return { scene, grid, particles, craft, plume, planet, wire };
}
updateRocket(s, dt, t) {
const p = this.progress;
s.craft.position.y = -2 + p * 9;
s.craft.rotation.y += dt * 0.6;
// Increasing strain: the craft trembles harder the higher it goes.
s.craft.position.x = Math.sin(t * 9) * p * 0.28;
s.craft.rotation.z = Math.sin(t * 7) * p * 0.14;
const thrust = 0.6 + p * 2.6;
s.plume.scale.set(1 + p * 0.5, thrust, 1 + p * 0.5);
s.plume.position.y = -1.2 - thrust * 0.55;
s.plume.material.opacity = 0.45 + Math.random() * 0.35;
s.planet.position.y = -18 - p * 26;
s.planet.rotation.y += dt * 0.1;
s.grid.position.z = (s.grid.position.z + dt * (6 + p * 60)) % 3;
s.grid.position.y = -6 - p * 4;
const colour = this.crashed ? RED : GREEN;
s.wire.material.color.setHex(colour);
s.plume.material.color.setHex(colour);
this.camera.position.set(0, s.craft.position.y + 1.5, 11 - p * 2);
this.camera.lookAt(0, s.craft.position.y, 0);
}
/* ---------- orbital: a decaying orbit ---------- */
buildOrbital() {
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.02);
const particles = this.makeParticles(44);
scene.add(particles);
const planet = this.glowLine(new THREE.IcosahedronGeometry(4, 1), GREEN, 0.85);
scene.add(planet);
const core = new THREE.Mesh(
new THREE.IcosahedronGeometry(3.85, 1),
new THREE.MeshBasicMaterial({ color: 0x00160f }));
scene.add(core);
// The orbit ring the craft is riding, which tightens as it decays.
const ring = new THREE.Mesh(
new THREE.TorusGeometry(9, 0.035, 6, 90),
new THREE.MeshBasicMaterial({
color: GREEN, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
ring.rotation.x = Math.PI / 2.35;
scene.add(ring);
const craft = new THREE.Mesh(
new THREE.OctahedronGeometry(0.42),
new THREE.MeshBasicMaterial({ color: MAGENTA }));
scene.add(craft);
// A fading trail behind the craft.
const trailPos = new Float32Array(60 * 3);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({
color: MAGENTA, transparent: true, opacity: 0.55,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
scene.add(trail);
return { scene, planet, core, ring, craft, trail, particles, trailIdx: 0 };
}
updateOrbital(s, dt, t) {
const p = this.progress;
const radius = 9 - p * 4.6; // the orbit decays inward
const angle = t * (1.1 + p * 5.5);
s.craft.position.set(
Math.cos(angle) * radius,
Math.sin(angle * 0.55) * 1.2,
Math.sin(angle) * radius);
const pos = s.trail.geometry.attributes.position.array;
// Shift the trail back one vertex and write the head.
pos.copyWithin(3, 0, pos.length - 3);
pos[0] = s.craft.position.x;
pos[1] = s.craft.position.y;
pos[2] = s.craft.position.z;
s.trail.geometry.attributes.position.needsUpdate = true;
s.ring.scale.setScalar(radius / 9);
s.planet.rotation.y += dt * 0.25;
s.planet.rotation.x += dt * 0.08;
const colour = this.crashed ? RED : MAGENTA;
s.craft.material.color.setHex(colour);
s.trail.material.color.setHex(colour);
s.planet.material.color.setHex(this.crashed ? RED : GREEN);
this.camera.position.set(0, 8 + p * 3, 20 - p * 5);
this.camera.lookAt(0, 0, 0);
}
/* ---------- tower: a stack that wobbles ---------- */
buildTower() {
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.014);
const grid = this.makeGrid();
scene.add(grid);
const particles = this.makeParticles(46);
scene.add(particles);
// Pre-allocate the maximum stack; visibility is toggled per frame rather
// than creating and destroying meshes mid-round.
const MAX = 26;
const blocks = [];
const geo = new THREE.BoxGeometry(2.4, 0.7, 2.4);
for (let i = 0; i < MAX; i++) {
const group = new THREE.Group();
group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x00170f })));
group.add(this.glowLine(geo, GREEN, 0.9));
group.visible = false;
scene.add(group);
blocks.push(group);
}
return { scene, grid, particles, blocks, max: MAX };
}
updateTower(s, dt, t) {
const p = this.progress;
const count = Math.max(1, Math.floor(p * s.max));
for (let i = 0; i < s.max; i++) {
const b = s.blocks[i];
b.visible = i < count;
if (!b.visible) continue;
const h = i / s.max;
// Sway grows with height, and violently once it collapses.
const amp = h * h * (this.crashed ? 3.4 : 0.9);
b.position.set(
Math.sin(t * 2.2 + i * 0.45) * amp,
-5 + i * 0.74,
Math.cos(t * 1.8 + i * 0.35) * amp * 0.6);
b.rotation.y = t * 0.15 + i * 0.08;
b.rotation.z = Math.sin(t * 2 + i * 0.4) * amp * 0.05;
const top = i === count - 1;
const colour = this.crashed ? RED : (top ? AMBER : GREEN);
b.children[1].material.color.setHex(colour);
b.children[1].material.opacity = top ? 1 : 0.45 + h * 0.4;
}
s.grid.position.z = (s.grid.position.z + dt * 4) % 3;
const height = -5 + count * 0.74;
this.camera.position.set(0, height * 0.55 + 3, 15 - p * 2);
this.camera.lookAt(0, height * 0.5, 0);
}
/* ---------- particles ---------- */
updateParticles(points, dt) {
const pos = points.geometry.attributes.position.array;
const speeds = points.userData.speeds;
const spread = points.userData.spread;
const half = spread / 2;
const rush = 2 + this.progress * 34;
for (let i = 0; i < PARTICLES; i++) {
pos[i * 3 + 2] += speeds[i] * rush * dt;
if (pos[i * 3 + 2] > half) {
pos[i * 3 + 2] = -half;
pos[i * 3] = (Math.random() - 0.5) * spread;
pos[i * 3 + 1] = (Math.random() - 0.5) * spread;
}
}
points.geometry.attributes.position.needsUpdate = true;
points.material.color.setHex(this.crashed ? RED : GREEN);
}
/* ---------- public API ---------- */
setGame(game) {
if (this.scenes[game]) this.active = game;
}
// progress is 0..1 across the multiplier's useful range; crashed swaps the
// palette and unleashes the wobble.
setState(progress, crashed) {
this.progress = Math.max(0, Math.min(1, progress));
if (crashed && !this.crashed) this.shake = 1; // kick the camera once
this.crashed = crashed;
}
start() {
if (this.raf) return;
this.clock.getDelta(); // discard time spent hidden
const loop = () => {
this.raf = requestAnimationFrame(loop);
this.frame();
};
this.raf = requestAnimationFrame(loop);
}
stop() {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = null;
}
frame() {
const dt = Math.min(this.clock.getDelta(), 0.05); // clamp after a stall
const t = this.clock.elapsedTime;
const s = this.scenes[this.active];
if (this.active === 'rocket') this.updateRocket(s, dt, t);
else if (this.active === 'orbital') this.updateOrbital(s, dt, t);
else this.updateTower(s, dt, t);
this.updateParticles(s.particles, dt);
// Camera shake on the crash, decaying fast.
if (this.shake > 0.001) {
this.camera.position.x += (Math.random() - 0.5) * this.shake * 1.6;
this.camera.position.y += (Math.random() - 0.5) * this.shake * 1.6;
this.shake *= 0.86;
}
this.renderer.render(s.scene, this.camera);
}
}

View File

@@ -352,3 +352,162 @@ button:active { transform: translateY(1px); }
.verify-out .ok { color: var(--green); font-weight: 700; } .verify-out .ok { color: var(--green); font-weight: 700; }
.verify-out .bad { color: var(--red); font-weight: 700; } .verify-out .bad { color: var(--red); font-weight: 700; }
.verify-out .kv code { font-size: 10px; } .verify-out .kv code { font-size: 10px; }
/* ============================================================
MOBILE-FIRST LAYER
Everything above assumed a single column. This layer adds the
bottom navigation, stat tiles, charts, and the desktop widening.
============================================================ */
/* Bottom nav replaces the old top tabs: on a phone held one-handed,
navigation belongs where the thumb already is. */
.tabs {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
display: flex; gap: 0; padding: 0;
max-width: none; margin: 0;
background: #00120cf2;
backdrop-filter: blur(10px);
border-top: 1px solid var(--green-ghost);
padding-bottom: env(safe-area-inset-bottom, 0);
}
.tabs .tab {
flex: 1; display: flex; flex-direction: column; align-items: center;
gap: 3px; padding: 9px 2px 7px;
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
border: none; border-top: 2px solid transparent; border-bottom: none;
background: none; color: var(--green-dim); border-radius: 0;
}
.tabs .tab .ico { font-size: 15px; line-height: 1; }
.tabs .tab.active {
color: var(--green); border-top-color: var(--green);
background: #00ff9c0a;
}
/* Leave room for the fixed nav. */
.view { padding-bottom: 84px; }
/* The 3D stage is the hero: taller on phones, where vertical space is what
there is most of. */
.stage { aspect-ratio: 1 / 1; }
@media (min-width: 560px) { .stage { aspect-ratio: 16 / 11; } }
#scene { width: 100%; height: 100%; display: block; }
.stagetop {
position: absolute; top: 0; left: 0; right: 0;
display: flex; justify-content: center; padding: 8px;
pointer-events: none;
}
.pot {
font-size: 9.5px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--green-dim);
}
/* Crash history strip under the stage — the at-a-glance recent record. */
.strip {
display: flex; gap: 4px; overflow-x: auto; padding: 8px 0 2px;
scrollbar-width: none;
}
.strip::-webkit-scrollbar { display: none; }
.strip .pip {
flex: 0 0 auto; font-size: 10.5px; padding: 4px 7px; border-radius: 2px;
font-variant-numeric: tabular-nums;
background: #00140e; border: 1px solid var(--green-ghost);
color: var(--green-dim);
}
.strip .pip.mid { color: var(--amber); border-color: #4a3300; }
.strip .pip.high { color: var(--magenta); border-color: #4a0f2c; }
/* Auto cash-out presets. */
.presets { display: flex; gap: 3px; margin-left: auto; }
.presets button {
padding: 5px 8px; font-size: 11px; color: var(--green-dim);
font-variant-numeric: tabular-nums;
}
.presets button:active { color: var(--amber); border-color: var(--amber); }
/* ---------- stat tiles ---------- */
.tiles {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
margin-bottom: 12px;
}
@media (min-width: 560px) { .tiles { grid-template-columns: repeat(4, 1fr); } }
.tile {
background: #00120c; border: 1px solid var(--green-ghost);
border-radius: 3px; padding: 11px 12px;
display: flex; flex-direction: column; gap: 2px; min-height: 76px;
}
.tile-label {
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--green-dim);
}
.tile-value {
font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;
color: var(--green); line-height: 1.15;
}
.tile-value.up { color: var(--amber); }
.tile-value.down { color: var(--red); }
.tile-sub { font-size: 9.5px; color: var(--green-dim); }
.tile-spark { height: 22px; margin-top: auto; }
/* ---------- charts ---------- */
.chart { width: 100%; height: 110px; }
.chart-donut { height: 130px; display: grid; place-items: center; }
.chart-donut svg { width: 130px; height: 130px; }
.chart-empty { text-align: center; padding: 30px 10px; margin: 0; }
/* ---------- tech spec list ---------- */
.specs { margin: 0; font-size: 12px; }
.specs dt {
color: var(--amber); font-size: 9.5px; letter-spacing: 0.14em;
text-transform: uppercase; margin-top: 10px;
}
.specs dt:first-child { margin-top: 0; }
.specs dd { margin: 2px 0 0; color: var(--green-dim); }
/* ---------- crypto badge on the sign-in screen ---------- */
.cryptobadge {
display: flex; gap: 6px; justify-content: center; margin-top: 18px;
flex-wrap: wrap;
}
.cryptobadge span {
font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--green-dim); border: 1px solid var(--green-ghost);
padding: 4px 8px; border-radius: 2px;
}
/* ---------- desktop widening ---------- */
@media (min-width: 900px) {
.view { max-width: 860px; }
/* Side-by-side once there is width for it, rather than a tall scroll. */
#view-crash {
display: grid; grid-template-columns: 1.35fr 1fr;
grid-template-areas: "pick pick" "stage controls" "strip players" "proof proof";
gap: 0 20px; align-items: start;
}
#view-crash .gamepick { grid-area: pick; }
#view-crash .stage { grid-area: stage; }
#view-crash .strip { grid-area: strip; }
#view-crash .controls { grid-area: controls; margin-top: 0; }
#view-crash .players { grid-area: players; }
#view-crash .proof { grid-area: proof; }
#view-portfolio { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
#view-portfolio .tiles { grid-column: 1 / -1; }
}
/* Respect a stated preference for less motion: the urgency pulse and the
scanline flicker are exactly what that setting is asking about. */
@media (prefers-reduced-motion: reduce) {
.primary.cashout { animation: none; }
body::after { animation: none; }
.multiplier.crashed { animation: none; }
h1::after { animation: none; }
}

File diff suppressed because one or more lines are too long