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>
This commit is contained in:
drjones
2026-08-05 16:24:31 +00:00
parent dee3becd47
commit 48a9120fe4
14 changed files with 997 additions and 266 deletions

View File

@@ -115,6 +115,17 @@ 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) {
@@ -145,7 +156,9 @@ function onSnapshot(s) {
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.textContent = myBet
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
: 'Place bet';
action.className = 'primary big';
action.disabled = !!myBet;
break;
@@ -189,7 +202,7 @@ function renderPlayers(players) {
clear(wrap);
for (const p of players || []) {
const amount = p.cashed_out
? '↑ ' + parseFloat(p.cashed_out).toFixed(2) + '×'
? (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' }),
@@ -205,6 +218,7 @@ async function onAction() {
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';
@@ -250,11 +264,23 @@ function draw(s) {
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';
// 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.10 + st.r * 0.16;
ctx.globalAlpha = 0.12 + st.r * 0.18;
ctx.fillRect(st.x * w, y * h, st.r, st.r);
}
ctx.globalAlpha = 1;
@@ -267,13 +293,13 @@ function draw(s) {
function drawRocket(w, h, p, crashed) {
const x = w * 0.5;
const y = h * (0.88 - p * 0.66);
const accent = crashed ? '#ff4d6d' : '#38f2e4';
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 ? '#ff4d6daa' : '#38f2e4cc');
g.addColorStop(1, '#38f2e400');
g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc');
g.addColorStop(1, '#00ff9c00');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x - 7, y + 8);
@@ -291,7 +317,7 @@ function drawRocket(w, h, p, crashed) {
ctx.fill();
if (crashed) {
ctx.strokeStyle = '#ff4d6d88';
ctx.strokeStyle = '#ff335588';
ctx.lineWidth = 2;
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2;
@@ -306,17 +332,17 @@ function drawRocket(w, h, p, crashed) {
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.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 ? '#ff4d6d55' : '#38f2e455';
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 ? '#ff4d6d' : '#38f2e4';
ctx.fillStyle = crashed ? '#ff3355' : '#00ff9c';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
}
@@ -327,8 +353,8 @@ function drawTower(w, h, p, crashed) {
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}%)`;
? (crashed ? '#ff3355' : '#00ff9c')
: `hsl(${160 - i} 90% ${12 + i}%)`;
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
}
}
@@ -618,6 +644,7 @@ 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;
const pick = $('gamepick');
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {