feat: playable arcade — rooms, identity, client, deployment

Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.

Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 15:34:52 +00:00
parent 41c1bb2fdf
commit 2a2a1db8de
17 changed files with 2885 additions and 25 deletions

636
cmd/arcade/static/app.js Normal file
View File

@@ -0,0 +1,636 @@
/* Quantum Arcade client.
*
* Identity is an ed25519 keypair generated in the browser and kept in
* localStorage. There is no account to create and no password to lose.
*
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
* the server whether a round was fair — it checks.
*
* All dynamic content is inserted with textContent or built as DOM nodes.
* Nothing that originates from another player (nicknames, keys) or from the
* server ever reaches innerHTML. */
const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name';
let keypair = null; // { publicKeyHex, privateKey (CryptoKey) }
let token = null;
let nickname = '';
let stake = 5000; // millisatoshis
let currentGame = 'rocket';
let socket = null;
let snapshot = null;
let myBet = null; // 'in' | 'out' | null
const $ = (id) => document.getElementById(id);
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
function el(tag, attrs, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else node.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return node;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/* ---------------- identity ---------------- */
async function loadOrCreateKey() {
const stored = localStorage.getItem(KEY_STORAGE);
if (stored) {
const jwk = JSON.parse(stored);
const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
return { privateKey: priv, publicKeyHex: jwk.qa_pub };
}
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
const pubHex = hex(rawPub);
const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
jwk.qa_pub = pubHex;
localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
return { privateKey: kp.privateKey, publicKeyHex: pubHex };
}
function hex(bytes) {
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function unhex(s) {
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
async function signIn() {
nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
localStorage.setItem(NAME_STORAGE, nickname);
const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
const sig = new Uint8Array(await crypto.subtle.sign(
{ name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));
const res = await api('POST', '/api/auth/verify', {
pubkey: keypair.publicKeyHex,
signature: hex(sig),
nickname,
});
token = res.token;
$('signin').hidden = true;
$('app').hidden = false;
$('balance-wrap').hidden = false;
setBalance(res.balance_msat);
$('pubkey').textContent = keypair.publicKeyHex;
connect(currentGame);
loadScratch();
}
/* ---------------- api ---------------- */
async function api(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(path, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function setBalance(msat) {
$('balance').textContent = sats(msat);
}
/* ---------------- crash room ---------------- */
function connect(game) {
if (socket) socket.close();
currentGame = game;
myBet = null;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
}
function onSnapshot(s) {
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
snapshot = s;
if (roundChanged) myBet = null;
const mult = $('multiplier');
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
mult.className = 'multiplier';
$('commitment').textContent = s.commitment || '—';
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
const action = $('action');
const hint = $('hint');
switch (s.state) {
case 'betting_open':
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
action.textContent = myBet ? 'You are in — good luck' : 'Place bet';
action.className = 'primary big';
action.disabled = !!myBet;
break;
case 'locked':
$('state').textContent = 'launching';
action.textContent = 'Launching…';
action.disabled = true;
break;
case 'running':
$('state').textContent = 'in flight';
if (myBet === 'in') {
const payout = stake * parseFloat(s.multiplier);
action.textContent = `Cash out ${sats(payout)}`;
action.className = 'primary big cashout';
action.disabled = false;
} else {
action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
action.className = 'primary big';
action.disabled = true;
}
break;
case 'settled':
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
$('state').textContent =
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
action.textContent = 'Next round';
action.className = 'primary big';
action.disabled = true;
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
break;
}
renderPlayers(s.players);
draw(s);
if (s.state === 'running') tone(parseFloat(s.multiplier));
}
function renderPlayers(players) {
const wrap = $('players');
clear(wrap);
for (const p of players || []) {
const amount = p.cashed_out
? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×'
: sats(p.stake_msat) + ' sats';
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
el('span', { class: 'who', text: p.nickname || 'anon' }),
el('span', { class: 'amt', text: amount })));
}
}
async function onAction() {
const hint = $('hint');
hint.textContent = '';
hint.className = 'hint';
try {
if (snapshot.state === 'betting_open' && !myBet) {
const r = await api('POST', '/api/bet', {
game: currentGame, stake_msat: stake, nickname,
});
setBalance(r.balance_msat);
myBet = 'in';
} else if (snapshot.state === 'running' && myBet === 'in') {
const r = await api('POST', '/api/cashout', { game: currentGame });
myBet = 'out';
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
hint.className = 'hint good';
const b = await api('GET', '/api/balance');
setBalance(b.balance_msat);
}
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
}
}
/* ---------------- rendering ----------------
* Each game draws the same climb differently: a rocket fighting gravity, a
* craft spiralling inward, or a tower stacking upward. */
const canvas = $('canvas');
const ctx = canvas.getContext('2d');
let stars = [];
function sizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stars = Array.from({ length: 70 }, () => ({
x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3,
}));
}
window.addEventListener('resize', sizeCanvas);
function draw(s) {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
const m = parseFloat(s.multiplier) || 1;
const crashed = s.state === 'settled';
const progress = Math.min(1, Math.log(m) / Math.log(12));
// Starfield drifts downward as you climb.
ctx.fillStyle = '#ffffff';
for (const st of stars) {
const y = (st.y + progress * 0.9) % 1;
ctx.globalAlpha = 0.10 + st.r * 0.16;
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 ? '#ff4d6d' : '#38f2e4';
// 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 ? '#ff4d6daa' : '#38f2e4cc');
g.addColorStop(1, '#38f2e400');
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 = '#ff4d6d88';
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 = '#1d2757';
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 ? '#ff4d6d55' : '#38f2e455';
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 ? '#ff4d6d' : '#38f2e4';
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 ? '#ff4d6d' : '#38f2e4')
: `hsl(${230 + i * 4} 45% ${22 + i}%)`;
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
}
}
/* ---------------- ambient sound ----------------
* Synthesised, so it never loops and ships no audio files. */
let audio = null;
function toggleSound() {
if (audio) {
audio.close(); audio = null;
$('sound-toggle').classList.remove('on');
return;
}
audio = new (window.AudioContext || window.webkitAudioContext)();
const gain = audio.createGain();
gain.gain.value = 0.05;
gain.connect(audio.destination);
// Two detuned oscillators a fifth apart: calm, wide, and never resolving.
[55, 82.5].forEach((f) => {
const o = audio.createOscillator();
o.type = 'sine';
o.frequency.value = f;
const lfo = audio.createOscillator();
lfo.frequency.value = 0.05 + Math.random() * 0.06;
const depth = audio.createGain();
depth.gain.value = 1.5;
lfo.connect(depth).connect(o.frequency);
o.connect(gain);
o.start(); lfo.start();
});
$('sound-toggle').classList.add('on');
}
let lastTone = 0;
function tone(mult) {
if (!audio) return;
const now = audio.currentTime;
if (now - lastTone < 0.28) return;
lastTone = now;
const o = audio.createOscillator();
const g = audio.createGain();
o.type = 'triangle';
o.frequency.value = 220 * Math.min(4, mult);
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(0.03, now + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
o.connect(g).connect(audio.destination);
o.start(now); o.stop(now + 0.3);
}
/* ---------------- scratch tickets ---------------- */
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
async function loadScratch() {
const { tickets } = await api('GET', '/api/scratch/catalog');
const wrap = $('tickets');
clear(wrap);
for (const t of tickets) {
const grid = el('div', { class: 'grid c' + t.cells });
for (let i = 0; i < t.cells; i++) {
grid.appendChild(el('div', { class: 'cell', text: '?' }));
}
const result = el('div', { class: 'result' });
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
button.dataset.play = t.id;
button.onclick = () => playScratch(t, grid, result);
const table = el('table', { class: 'odds' },
el('tr', {},
el('th', { text: 'prize' }),
el('th', { text: 'pays' }),
el('th', { text: 'chance' })));
for (const o of t.odds) {
table.appendChild(el('tr', {},
el('td', { text: o.tier }),
el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
}
wrap.appendChild(el('div', { class: 'card' },
el('h3', { text: t.name }),
el('p', { class: 'muted small', text: t.blurb }),
grid,
result,
button,
table,
el('p', { class: 'small' },
'Return to player: ',
el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
'. These odds are read from the same table that generates results.')));
}
}
async function playScratch(t, grid, result) {
result.textContent = '';
result.className = 'result';
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
let out;
try {
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
} catch (e) {
result.textContent = e.message;
return;
}
setBalance(out.balance_msat);
// Reveal cells one at a time — the outcome is already fixed, this is pacing.
const cells = out.outcome.cells;
const counts = {};
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
const winner = Object.keys(counts).find((k) => counts[k] >= 3);
cells.forEach((sym, i) => {
setTimeout(() => {
const cell = grid.children[i];
cell.textContent = SYMBOLS[sym];
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
if (i === cells.length - 1) {
const won = out.outcome.payout_msat > 0;
result.className = 'result' + (won ? ' win' : '');
result.textContent = won
? `${out.outcome.tier_name}${sats(out.outcome.payout_msat)} sats`
: 'No win this time';
}
}, i * 130);
});
}
/* ---------------- wallet ---------------- */
async function loadHistory() {
const { entries } = await api('GET', '/api/history');
const wrap = $('history');
clear(wrap);
for (const e of entries || []) {
wrap.appendChild(el('div', { class: 'entry' },
el('span', { class: 'kind', text: e.Kind }),
el('span', {
class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
}),
el('span', { class: 'after', text: sats(e.BalanceAfter) })));
}
}
async function sendSats() {
const hint = $('send-hint');
try {
const r = await api('POST', '/api/transfer', {
to_pubkey: $('to-key').value.trim(),
amount_msat: Math.round(Number($('send-amt').value) * 1000),
});
setBalance(r.balance_msat);
hint.textContent = 'Sent.';
hint.className = 'hint good';
loadHistory();
} catch (e) {
hint.textContent = e.message;
hint.className = 'hint bad';
}
}
/* ---------------- verifier ----------------
* Recomputed here, in the browser, from published values only. */
async function sha256(bytes) {
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
}
async function hmacSha256(keyBytes, msg) {
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
}
function concat(...arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; }
return out;
}
function kvRow(label, value) {
return el('div', { class: 'kv' },
el('span', { text: label }),
el('code', { text: value }));
}
async function verifyRound() {
const out = $('verify-out');
clear(out);
out.appendChild(el('p', { text: 'checking…' }));
let r;
try {
r = await api('GET', '/api/verify/' + Number($('verify-id').value));
} catch (e) {
clear(out);
out.appendChild(el('p', { class: 'bad', text: e.message }));
return;
}
// 1. The revealed seed must hash to the commitment published beforehand.
const seedBytes = unhex(r.server_seed);
const seedHash = hex(await sha256(seedBytes));
const commitOK = seedHash === r.commitment;
// 2. The client seed must be the hash over participants, length-prefixed.
const parts = [];
for (const p of r.participants || []) {
const pk = unhex(p);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, pk.length, false);
parts.push(len, pk);
}
const clientSeed = await sha256(concat(...parts));
const clientOK = hex(clientSeed) === r.client_seed;
// 3. The round seed follows from both, and determines the crash point.
const nonceBytes = new Uint8Array(8);
new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));
const allOK = commitOK && clientOK;
clear(out);
out.appendChild(el('p', {
class: allOK ? 'ok' : 'bad',
text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
}));
out.appendChild(kvRow('commitment', r.commitment));
out.appendChild(kvRow('sha256(seed)', seedHash));
out.appendChild(kvRow('client seed', r.client_seed || ''));
out.appendChild(kvRow('recomputed', hex(clientSeed)));
out.appendChild(kvRow('round seed', hex(roundSeed)));
out.appendChild(kvRow('crash point',
r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
out.appendChild(el('p', {
class: 'muted small',
text: 'Computed on this device. The server was asked only for the published '
+ 'values, not for its opinion.',
}));
}
/* ---------------- wiring ---------------- */
function selectTab(view) {
document.querySelectorAll('.tab').forEach((t) =>
t.classList.toggle('active', t.dataset.view === view));
document.querySelectorAll('.view').forEach((v) =>
(v.hidden = v.id !== 'view-' + view));
if (view === 'wallet') loadHistory();
}
function setStake(v) {
stake = v;
document.querySelectorAll('.chip').forEach((c) =>
c.classList.toggle('on', Number(c.dataset.stake) === v));
document.querySelectorAll('[data-play]').forEach((b) => {
b.textContent = `Scratch for ${sats(stake)} sats`;
});
}
async function init() {
sizeCanvas();
keypair = await loadOrCreateKey();
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
$('enter').onclick = () => signIn().catch((e) => {
$('keynote').textContent = e.message;
});
$('action').onclick = onAction;
$('sound-toggle').onclick = toggleSound;
$('send').onclick = sendSats;
$('do-verify').onclick = verifyRound;
$('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);
document.querySelectorAll('.tab').forEach((t) =>
(t.onclick = () => selectTab(t.dataset.view)));
document.querySelectorAll('.chip').forEach((c) =>
(c.onclick = () => setStake(Number(c.dataset.stake))));
const pick = $('gamepick');
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
b.onclick = () => {
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
connect(id);
};
pick.appendChild(b);
});
setStake(stake);
}
init();