Files
casino/cmd/arcade/static/app.js
drjones 48a9120fe4 feat: auto cash-out targets, 1% house edge, terminal aesthetic
Auto cash-out closes a position at exactly the chosen target rather than
the next tick's multiplier, and fires whenever the target is at or below
the crash point. This is the feature that makes the game playable over a
network, where manual timing is at the mercy of latency.

House edge drops from 2% to 1% across crash and scratch. Scratch prize
tables retuned so the published 99% RTP is exact.

Adds docs/API.md: the client uses no private endpoints, so anyone can
write a bot against the same API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:24:31 +00:00

664 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Quantum Arcade client.
*
* Identity is 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);
}
/* Reads the auto cash-out box. Returns 0 when empty or invalid, which the
* server treats as "no target". */
function autoTarget() {
const v = parseFloat($('auto-target').value);
return Number.isFinite(v) && v > 1 ? v : 0;
}
function refreshAutoRow() {
$('autorow').classList.toggle('armed', autoTarget() > 0);
}
/* ---------------- crash room ---------------- */
function connect(game) {
if (socket) socket.close();
currentGame = game;
myBet = null;
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
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
: 'Place bet';
action.className = 'primary big';
action.disabled = !!myBet;
break;
case 'locked':
$('state').textContent = 'launching';
action.textContent = 'Launching…';
action.disabled = true;
break;
case 'running':
$('state').textContent = 'in flight';
if (myBet === 'in') {
const payout = stake * 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
? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
: sats(p.stake_msat) + ' sats';
wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
el('span', { class: 'who', text: p.nickname || 'anon' }),
el('span', { class: 'amt', text: amount })));
}
}
async function onAction() {
const hint = $('hint');
hint.textContent = '';
hint.className = 'hint';
try {
if (snapshot.state === 'betting_open' && !myBet) {
const r = await api('POST', '/api/bet', {
game: currentGame, stake_msat: stake, nickname,
auto_cashout: autoTarget(),
});
setBalance(r.balance_msat);
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));
// 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;
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))));
$('auto-target').oninput = refreshAutoRow;
const pick = $('gamepick');
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
b.onclick = () => {
document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
connect(id);
};
pick.appendChild(b);
});
setStake(stake);
}
init();