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();

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">
<head>
<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>
<link rel="stylesheet" href="/style.css">
</head>
<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">
<defs>
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
@@ -33,39 +36,43 @@
<button class="sound" id="sound-toggle" title="Ambient sound"></button>
</header>
<!-- Sign-in: a nickname and nothing else. -->
<!-- Sign-in -->
<section class="panel center" id="signin">
<h1>ACCESS TERMINAL</h1>
<p class="muted small">
No account. No email. No password. This device generated a keypair that
<em>is</em> your identity. Keep the device, keep the balance.
No account. No email. No password. This device generates a keypair
that <em>is</em> your identity.
</p>
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
<button class="primary" id="enter">Connect</button>
<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>
<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 ============ -->
<section class="view" id="view-crash">
<div class="gamepick" id="gamepick"></div>
<div class="stage">
<canvas id="canvas"></canvas>
<canvas id="scene"></canvas>
<div class="readout">
<div class="multiplier" id="multiplier">1.00×</div>
<div class="state" id="state">connecting…</div>
</div>
<div class="stagetop">
<span class="pot" id="potline"></span>
</div>
</div>
<!-- Live crash history strip, directly under the stage. -->
<div class="strip" id="strip"></div>
<div class="controls">
<div class="stakerow">
<button class="chip" data-stake="1000">1</button>
@@ -74,11 +81,17 @@
<button class="chip" data-stake="100000">100</button>
<span class="unit">sats</span>
</div>
<div class="autorow" id="autorow">
<label for="auto-target">auto&nbsp;out</label>
<input id="auto-target" type="number" min="1.01" step="0.1"
inputmode="decimal" placeholder="off">
<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>
<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>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
<p class="muted small">
The commitment is published before betting opens. The crash point is
derived from that seed combined with every player's key — so it cannot
be chosen after seeing who joined.
The commitment is published before betting opens. The crash point comes
from that seed combined with every player's key — so it cannot be
chosen after seeing who joined.
</p>
</details>
</section>
@@ -104,6 +117,58 @@
<div id="tickets"></div>
</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 ============ -->
<section class="view" id="view-wallet" hidden>
<div class="card">
@@ -115,7 +180,8 @@
<div class="card">
<h2>Send sats</h2>
<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>
<div class="hint" id="send-hint"></div>
</div>
@@ -133,14 +199,61 @@
Enter a round number. Your phone recomputes the outcome from the
published seeds — it does not take the server's word for anything.
</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>
<div id="verify-out" class="verify-out"></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>
</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>
</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 .bad { color: var(--red); font-weight: 700; }
.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