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

@@ -278,3 +278,67 @@ func itoa(v int64) string {
}
return string(buf[i:])
}
// An auto cash-out target must be accepted by the API and reflected in the
// round, and an invalid one must be refused before any money moves.
func TestAutoCashOutThroughTheAPI(t *testing.T) {
c := newClient(t)
c.signIn("autoplayer")
start := c.fund(50_000_000)
var out map[string]any
// A target at or below 1.00 is meaningless and must be rejected.
code := c.do("POST", "/api/bet", map[string]any{
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1.0,
}, &out)
if code == 200 {
t.Fatal("a 1.00x auto cash-out target was accepted")
}
// An absurd target must be refused rather than overflowing the conversion.
code = c.do("POST", "/api/bet", map[string]any{
"game": "rocket", "stake_msat": 1_000_000, "auto_cashout": 1e12,
}, &out)
if code == 200 {
t.Fatal("an absurd auto cash-out target was accepted")
}
// Neither rejection may have moved money.
var bal struct {
BalanceMsat int64 `json:"balance_msat"`
}
c.do("GET", "/api/balance", nil, &bal)
if bal.BalanceMsat != start {
t.Fatalf("balance = %d after rejected bets, want %d", bal.BalanceMsat, start)
}
// A sensible target should be accepted during a betting window.
deadline := time.Now().Add(90 * time.Second)
placed := false
for time.Now().Before(deadline) && !placed {
var games struct {
Rooms []struct {
Game string `json:"game"`
State string `json:"state"`
} `json:"rooms"`
}
c.do("GET", "/api/games", nil, &games)
for _, rm := range games.Rooms {
if rm.Game == "rocket" && rm.State == "betting_open" {
var res map[string]any
if code := c.do("POST", "/api/bet", map[string]any{
"game": "rocket", "stake_msat": 1_000_000,
"auto_cashout": 2.5, "nickname": "autoplayer",
}, &res); code == 200 {
placed = true
}
}
}
if !placed {
time.Sleep(400 * time.Millisecond)
}
}
if !placed {
t.Fatal("could not place an auto cash-out bet within 90s")
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/drjones/quantum-arcade/pkg/fair"
"github.com/drjones/quantum-arcade/pkg/fixed"
"github.com/drjones/quantum-arcade/pkg/identity"
"github.com/drjones/quantum-arcade/pkg/ledger"
"github.com/drjones/quantum-arcade/pkg/room"
@@ -31,6 +32,10 @@ import (
//go:embed static
var staticFiles embed.FS
// maxAutoCashOut bounds the target a client may request, keeping the
// conversion to fixed-point well inside the representable range.
const maxAutoCashOut = 1_000_000
// Games offered as shared rounds. They share one engine and differ in how the
// client renders the climb.
var games = []string{"rocket", "orbital", "tower"}
@@ -361,6 +366,9 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
Game string `json:"game"`
StakeMsat int64 `json:"stake_msat"`
Nickname string `json:"nickname"`
// AutoCashOut is an optional target multiplier, e.g. 2.5 for 2.50x.
// Zero or absent means no target.
AutoCashOut float64 `json:"auto_cashout"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "malformed request")
@@ -371,7 +379,18 @@ func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, "no such game")
return
}
if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat); err != nil {
// Convert the target to fixed-point at the boundary; everything past this
// point is integer arithmetic.
var target fixed.F
if req.AutoCashOut > 0 {
if req.AutoCashOut > float64(maxAutoCashOut) {
writeErr(w, http.StatusBadRequest, "auto cash-out target is too large")
return
}
target = fixed.F(req.AutoCashOut * float64(fixed.One))
}
if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat, target); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}

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]) => {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quantum Arcade</title>
<title>QUANTUM ARCADE</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
@@ -11,15 +11,13 @@
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
<svg class="filigree" aria-hidden="true">
<defs>
<pattern id="orn" width="120" height="120" patternUnits="userSpaceOnUse">
<g fill="none" stroke="currentColor" stroke-width="0.6">
<circle cx="60" cy="60" r="46"/>
<circle cx="60" cy="60" r="30"/>
<circle cx="60" cy="60" r="14"/>
<path d="M60 0 L60 120 M0 60 L120 60"/>
<path d="M17 17 L103 103 M103 17 L17 103"/>
<path d="M60 14 q26 22 0 46 q-26-24 0-46"/>
<path d="M14 60 q22 26 46 0 q-24-26-46 0"/>
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
<g fill="none" stroke="currentColor" stroke-width="0.7">
<path d="M15 0 L45 0 L60 26 L45 52 L15 52 L0 26 Z"/>
<path d="M30 26 L60 26 M30 26 L15 0 M30 26 L15 52"/>
<circle cx="30" cy="26" r="1.6"/>
<circle cx="0" cy="26" r="1.2"/>
<circle cx="60" cy="26" r="1.2"/>
</g>
</pattern>
</defs>
@@ -37,13 +35,13 @@
<!-- Sign-in: a nickname and nothing else. -->
<section class="panel center" id="signin">
<h1>Enter the arcade</h1>
<p class="muted">
No account, no email, no password. Your device generates a key that
<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.
</p>
<input id="nickname" maxlength="20" placeholder="pick a name" autocomplete="off">
<button class="primary" id="enter">Enter</button>
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
<button class="primary" id="enter">Connect</button>
<p class="fineprint" id="keynote"></p>
</section>
@@ -76,6 +74,13 @@
<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>
<button class="primary big" id="action">Place bet</button>
<div class="hint" id="hint"></div>
</div>

View File

@@ -1,19 +1,20 @@
/* Quantum Arcade — obsidian and indigo, dense ornament, and vivid colour
reserved strictly for the things that matter: the multiplier, the balance,
and the button that takes your money out of danger. */
/* Quantum Arcade — terminal aesthetic.
*
* Phosphor green on black, monospace everywhere, CRT scanlines and a faint
* flicker. Colour is rationed: amber for money at risk, magenta for the
* multiplier, red for the crash. Everything else is green on black, because
* a terminal that shouts about everything says nothing. */
:root {
--void: #05060d;
--obsidian: #0a0c18;
--indigo: #131a3a;
--indigo-hi: #1d2757;
--ink: #c8cbe6;
--muted: #6a719c;
--cyan: #38f2e4;
--magenta: #ff3ec8;
--gold: #ffc857;
--danger: #ff4d6d;
--ornament: #1a2350;
--black: #000000;
--panel: #030806;
--green: #00ff9c;
--green-dim: #0a7a52;
--green-ghost: #063a29;
--amber: #ffb000;
--magenta: #ff2e88;
--red: #ff3355;
--grid: #06181200;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
@@ -21,132 +22,178 @@
html, body {
margin: 0;
min-height: 100%;
background: radial-gradient(ellipse at 50% -10%, var(--indigo) 0%, var(--obsidian) 45%, var(--void) 100%);
color: var(--ink);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--black);
color: var(--green);
font: 14px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
overscroll-behavior: none;
text-shadow: 0 0 6px #00ff9c40;
}
/* The ornament sits behind everything, busy but very low contrast. */
/* Faint hex-grid ornament behind everything. */
.filigree {
position: fixed; inset: 0;
width: 100%; height: 100%;
color: var(--ornament);
opacity: 0.55;
color: #0d2b21;
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
/* CRT scanlines and a slow flicker over the whole page. */
body::after {
content: "";
position: fixed; inset: 0; z-index: 50;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
#00ff9c08 0px, #00ff9c08 1px,
transparent 1px, transparent 3px);
animation: flicker 5s infinite steps(60);
}
@keyframes flicker {
0%, 96%, 100% { opacity: 0.5; }
97% { opacity: 0.75; }
98% { opacity: 0.35; }
}
body > * { position: relative; z-index: 1; }
/* ---------- chrome ---------- */
.topbar {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px calc(14px);
border-bottom: 1px solid #ffffff10;
backdrop-filter: blur(6px);
padding: 10px 14px;
border-bottom: 1px solid var(--green-ghost);
background: #00120c;
}
.brand {
font-weight: 700; letter-spacing: 0.22em; font-size: 13px;
color: var(--ink);
font-weight: 700; letter-spacing: 0.18em; font-size: 12px;
}
.brand span { color: var(--cyan); margin-left: 6px; }
.brand::before { content: "> "; color: var(--green-dim); }
.brand span { color: var(--amber); margin-left: 5px; }
.balance { margin-left: auto; text-align: right; line-height: 1.1; }
.balance { margin-left: auto; text-align: right; line-height: 1.15; }
.balance .label {
display: block; font-size: 9px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--muted);
display: block; font-size: 9px; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--green-dim);
}
.balance .value {
font-variant-numeric: tabular-nums;
font-size: 18px; font-weight: 700; color: var(--cyan);
text-shadow: 0 0 18px #38f2e455;
font-size: 17px; font-weight: 700; color: var(--amber);
text-shadow: 0 0 14px #ffb00066;
}
.sound {
background: none; border: 1px solid #ffffff20; color: var(--muted);
width: 34px; height: 34px; border-radius: 50%; font-size: 15px;
background: none; border: 1px solid var(--green-ghost); color: var(--green-dim);
width: 32px; height: 32px; border-radius: 3px; font-size: 14px;
}
.sound.on { color: var(--cyan); border-color: var(--cyan); }
.sound.on { color: var(--green); border-color: var(--green); }
/* ---------- panels ---------- */
.panel {
max-width: 460px; margin: 0 auto; padding: 40px 22px;
}
.panel { max-width: 460px; margin: 0 auto; padding: 34px 20px; }
.center { text-align: center; }
h1 { font-size: 26px; margin: 0 0 10px; letter-spacing: 0.02em; }
h2 { font-size: 14px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--muted); margin: 0 0 10px; }
h1 {
font-size: 19px; margin: 0 0 10px; letter-spacing: 0.1em;
text-transform: uppercase;
}
h1::after {
content: "_";
animation: blink 1.1s steps(2) infinite;
color: var(--green);
}
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
.muted { color: var(--muted); }
.small { font-size: 12.5px; }
.fineprint { font-size: 11px; color: var(--muted); margin-top: 18px; word-break: break-all; }
h2 {
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--green-dim); margin: 0 0 10px;
}
h2::before { content: "// "; }
.muted { color: var(--green-dim); }
.small { font-size: 12px; }
.fineprint {
font-size: 10.5px; color: var(--green-dim); margin-top: 16px;
word-break: break-all;
}
input {
width: 100%; padding: 13px 14px; margin: 8px 0;
background: #ffffff08; border: 1px solid #ffffff18; border-radius: 10px;
color: var(--ink); font-size: 16px; /* 16px stops iOS zooming on focus */
width: 100%; padding: 11px 12px; margin: 7px 0;
background: #00140e; border: 1px solid var(--green-ghost); border-radius: 3px;
color: var(--green); font-size: 16px; /* 16px stops iOS zooming on focus */
font-family: inherit;
}
input:focus { outline: none; border-color: var(--cyan); }
input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 10px #00ff9c33; }
input::placeholder { color: var(--green-ghost); }
button {
font: inherit; cursor: pointer; border-radius: 10px;
border: 1px solid #ffffff20; background: #ffffff0c; color: var(--ink);
padding: 11px 16px;
font: inherit; cursor: pointer; border-radius: 3px;
border: 1px solid var(--green-ghost); background: #00140e; color: var(--green);
padding: 10px 14px; letter-spacing: 0.06em;
}
button:active { transform: translateY(1px); }
.primary {
background: linear-gradient(135deg, var(--cyan), #21b6ff);
color: #04121a; border: none; font-weight: 700; letter-spacing: 0.04em;
box-shadow: 0 0 26px #38f2e444;
width: 100%; padding: 15px;
background: #00291c; color: var(--green);
border: 1px solid var(--green); font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase;
box-shadow: 0 0 18px #00ff9c33, inset 0 0 18px #00ff9c11;
width: 100%; padding: 14px;
}
.primary.big { font-size: 17px; padding: 18px; }
.primary.big { font-size: 15px; padding: 17px; }
/* Money at risk turns amber and pulses — the one element that demands a
decision looks different from everything else. */
.primary.cashout {
background: linear-gradient(135deg, var(--magenta), var(--gold));
color: #1a0512; box-shadow: 0 0 34px #ff3ec866;
animation: urge 900ms ease-in-out infinite;
background: #2a1c00; color: var(--amber); border-color: var(--amber);
box-shadow: 0 0 26px #ffb00055, inset 0 0 18px #ffb00011;
animation: urge 800ms ease-in-out infinite;
}
.primary:disabled { opacity: 0.4; box-shadow: none; animation: none; }
.primary:disabled { opacity: 0.35; box-shadow: none; animation: none; }
@keyframes urge {
0%, 100% { box-shadow: 0 0 26px #ff3ec855; }
50% { box-shadow: 0 0 40px #ff3ec8aa; }
0%, 100% { box-shadow: 0 0 18px #ffb00044, inset 0 0 14px #ffb00011; }
50% { box-shadow: 0 0 34px #ffb000aa, inset 0 0 22px #ffb00022; }
}
/* ---------- tabs ---------- */
.tabs {
display: flex; gap: 6px; padding: 12px 12px 0;
display: flex; gap: 4px; padding: 10px 10px 0;
max-width: 620px; margin: 0 auto;
}
.tab {
flex: 1; padding: 10px 4px; font-size: 12px; letter-spacing: 0.08em;
text-transform: uppercase; background: none; border: none; color: var(--muted);
border-bottom: 2px solid transparent; border-radius: 0;
flex: 1; padding: 9px 3px; font-size: 11px; letter-spacing: 0.1em;
text-transform: uppercase; background: none; border: none;
color: var(--green-dim); border-bottom: 1px solid var(--green-ghost);
border-radius: 0;
}
.tab.active {
color: var(--green); border-bottom-color: var(--green);
text-shadow: 0 0 10px #00ff9c88;
}
.tab.active { color: var(--cyan); border-bottom-color: var(--cyan); }
.view { max-width: 620px; margin: 0 auto; padding: 14px 14px 60px; }
.view { max-width: 620px; margin: 0 auto; padding: 12px 12px 60px; }
/* ---------- crash stage ---------- */
.gamepick { display: flex; gap: 6px; margin-bottom: 12px; }
.gamepick { display: flex; gap: 4px; margin-bottom: 10px; }
.gamepick button {
flex: 1; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
padding: 9px 4px; color: var(--muted);
flex: 1; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
padding: 8px 3px; color: var(--green-dim);
}
.gamepick button.on {
color: var(--green); border-color: var(--green); background: #00291c;
}
.gamepick button.on { color: var(--cyan); border-color: var(--cyan); background: #38f2e412; }
.stage {
position: relative; border-radius: 16px; overflow: hidden;
border: 1px solid #ffffff14;
background: linear-gradient(180deg, #070a16 0%, #05060d 100%);
position: relative; border-radius: 3px; overflow: hidden;
border: 1px solid var(--green-ghost);
background: #000603;
aspect-ratio: 4 / 3;
}
#canvas { width: 100%; height: 100%; display: block; }
@@ -156,100 +203,152 @@ button:active { transform: translateY(1px); }
align-items: center; justify-content: center; pointer-events: none;
}
.multiplier {
font-size: clamp(46px, 17vw, 88px); font-weight: 800;
font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
color: var(--cyan); text-shadow: 0 0 40px #38f2e466;
font-size: clamp(42px, 16vw, 82px); font-weight: 700;
font-variant-numeric: tabular-nums; letter-spacing: -0.01em;
color: var(--magenta); text-shadow: 0 0 30px #ff2e8877;
}
.multiplier.crashed { color: var(--danger); text-shadow: 0 0 40px #ff4d6d66; }
.multiplier.won { color: var(--gold); text-shadow: 0 0 46px #ffc85777; }
.multiplier.crashed {
color: var(--red); text-shadow: 0 0 34px #ff335599;
animation: glitch 260ms steps(2) 3;
}
.multiplier.won {
color: var(--amber); text-shadow: 0 0 40px #ffb000aa;
}
@keyframes glitch {
0% { transform: translate(0, 0); }
25% { transform: translate(-3px, 1px); }
50% { transform: translate(3px, -1px); }
75% { transform: translate(-2px, -2px); }
100% { transform: translate(0, 0); }
}
.state {
font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase;
color: var(--muted); margin-top: 6px;
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
color: var(--green-dim); margin-top: 5px;
}
.controls { margin-top: 14px; }
.stakerow { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 12px 4px; }
.chip.on { border-color: var(--cyan); color: var(--cyan); background: #38f2e414; }
.unit { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; }
.controls { margin-top: 12px; }
.stakerow { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
.chip { flex: 1; font-variant-numeric: tabular-nums; padding: 11px 3px; font-size: 13px; }
.chip.on { border-color: var(--green); color: var(--green); background: #00291c; }
.unit { font-size: 10px; color: var(--green-dim); letter-spacing: 0.1em; }
.hint { min-height: 18px; margin-top: 8px; font-size: 12px; color: var(--muted); text-align: center; }
.hint.bad { color: var(--danger); }
.hint.good { color: var(--gold); }
/* Auto cash-out target row. */
.autorow {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
border: 1px solid var(--green-ghost); border-radius: 3px; padding: 8px 10px;
}
.autorow label {
font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--green-dim); white-space: nowrap;
}
.autorow input {
margin: 0; padding: 6px 8px; text-align: right; width: 90px;
font-variant-numeric: tabular-nums;
}
.autorow .x { color: var(--green-dim); font-size: 13px; }
.autorow.armed { border-color: var(--amber); }
.autorow.armed label, .autorow.armed .x { color: var(--amber); }
.autorow.armed input { color: var(--amber); border-color: var(--amber); }
.players { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
.hint {
min-height: 17px; margin-top: 7px; font-size: 11.5px;
color: var(--green-dim); text-align: center;
}
.hint.bad { color: var(--red); }
.hint.good { color: var(--amber); }
.players { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; }
.player {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #ffffff06; border: 1px solid #ffffff10; border-radius: 9px;
font-size: 13px;
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
background: #00120c; border: 1px solid var(--green-ghost); border-radius: 3px;
font-size: 12.5px;
}
.player .who { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player .amt { font-variant-numeric: tabular-nums; color: var(--muted); }
.player.out .amt { color: var(--gold); }
.player .amt { font-variant-numeric: tabular-nums; color: var(--green-dim); }
.player.out { border-color: #4a3300; }
.player.out .amt { color: var(--amber); }
.proof { margin-top: 18px; }
.proof { margin-top: 16px; }
.proof summary {
cursor: pointer; font-size: 11px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--muted); padding: 8px 0;
cursor: pointer; font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--green-dim); padding: 7px 0;
}
.kv { display: flex; gap: 10px; font-size: 11px; padding: 4px 0; }
.kv span { color: var(--muted); min-width: 96px; }
.kv code { word-break: break-all; color: var(--ink); opacity: 0.8; }
.kv { display: flex; gap: 8px; font-size: 10.5px; padding: 3px 0; }
.kv span { color: var(--green-dim); min-width: 92px; }
.kv code { word-break: break-all; color: var(--green); opacity: 0.75; }
/* ---------- scratch ---------- */
/* ---------- cards / scratch ---------- */
.card {
background: #ffffff07; border: 1px solid #ffffff14;
border-radius: 14px; padding: 16px; margin-bottom: 14px;
background: #00120c; border: 1px solid var(--green-ghost);
border-radius: 3px; padding: 14px; margin-bottom: 12px;
}
.card h3 {
margin: 0 0 4px; font-size: 15px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--green);
}
.card h3 { margin: 0 0 4px; font-size: 18px; }
.grid {
display: grid; gap: 8px; margin: 14px 0;
}
.grid { display: grid; gap: 6px; margin: 12px 0; }
.grid.c9 { grid-template-columns: repeat(3, 1fr); }
.grid.c6 { grid-template-columns: repeat(3, 1fr); }
.cell {
aspect-ratio: 1; display: grid; place-items: center;
font-size: 26px; border-radius: 10px;
background: linear-gradient(140deg, var(--indigo-hi), var(--indigo));
border: 1px solid #ffffff14;
transition: transform 160ms ease, background 260ms ease;
font-size: 24px; border-radius: 3px;
background: #001f16; border: 1px solid var(--green-ghost);
color: var(--green-dim);
transition: background 200ms ease, color 200ms ease;
}
.cell.revealed { background: #000603; color: var(--green); border-color: var(--green-ghost); }
.cell.hit {
border-color: var(--amber); color: var(--amber);
box-shadow: 0 0 16px #ffb00055;
}
.cell.revealed { background: #05060d; border-color: #ffffff22; }
.cell.hit { border-color: var(--gold); box-shadow: 0 0 18px #ffc85755; }
.odds { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.odds th, .odds td { text-align: left; padding: 6px 4px; border-bottom: 1px solid #ffffff10; }
.odds th { color: var(--muted); font-weight: 500; font-size: 10px;
letter-spacing: 0.12em; text-transform: uppercase; }
.odds td:last-child, .odds th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.rtp { color: var(--gold); font-weight: 700; }
.odds { width: 100%; border-collapse: collapse; font-size: 11.5px; margin-top: 8px; }
.odds th, .odds td {
text-align: left; padding: 5px 3px; border-bottom: 1px solid var(--green-ghost);
}
.odds th {
color: var(--green-dim); font-weight: 400; font-size: 9.5px;
letter-spacing: 0.14em; text-transform: uppercase;
}
.odds td:last-child, .odds th:last-child {
text-align: right; font-variant-numeric: tabular-nums;
}
.rtp { color: var(--amber); font-weight: 700; }
.result { text-align: center; padding: 10px 0; font-size: 16px; }
.result.win { color: var(--gold); font-weight: 700; }
.result { text-align: center; padding: 8px 0; font-size: 14px; }
.result.win {
color: var(--amber); font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ---------- wallet ---------- */
.pubkey {
display: block; word-break: break-all; font-size: 11px;
background: #00000055; padding: 10px; border-radius: 8px; margin: 8px 0;
color: var(--muted);
display: block; word-break: break-all; font-size: 10.5px;
background: #000603; padding: 9px; border-radius: 3px; margin: 7px 0;
color: var(--green-dim); border: 1px solid var(--green-ghost);
}
.history { display: flex; flex-direction: column; gap: 3px; }
.history { display: flex; flex-direction: column; gap: 2px; }
.entry {
display: flex; gap: 10px; font-size: 12px; padding: 8px 10px;
background: #ffffff05; border-radius: 8px;
display: flex; gap: 8px; font-size: 11.5px; padding: 7px 9px;
background: #000603; border-radius: 3px;
}
.entry .kind { flex: 1; color: var(--muted); }
.entry .kind { flex: 1; color: var(--green-dim); }
.entry .delta { font-variant-numeric: tabular-nums; }
.entry .delta.pos { color: var(--cyan); }
.entry .delta.neg { color: var(--muted); }
.entry .after { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 74px; text-align: right; }
.entry .delta.pos { color: var(--amber); }
.entry .delta.neg { color: var(--green-dim); }
.entry .after {
font-variant-numeric: tabular-nums; color: var(--green-dim);
min-width: 70px; text-align: right;
}
.verify-out { margin-top: 12px; font-size: 12px; }
.verify-out .ok { color: var(--cyan); font-weight: 700; }
.verify-out .bad { color: var(--danger); font-weight: 700; }
.verify-out { margin-top: 10px; font-size: 11.5px; }
.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; }