Files
casino/docs/API.md
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

8.3 KiB
Raw Permalink Blame History

Quantum Arcade API

Everything the browser client does, it does over this API. There is no private back channel — you can write a bot that plays exactly as well as a human, and nothing in the protocol is reserved for the official client.

Base URL is wherever the server is listening, e.g. http://arcade.lan:8080. All request and response bodies are JSON. Amounts are millisatoshis (1 sat = 1000 msat) and always integers.

Authentication

Identity is an ed25519 keypair. You prove ownership by signing a server-issued challenge; there is no password and nothing to register.

1. Request a challenge

POST /api/auth/challenge
{ "pubkey": "<64 hex chars>" }

→ { "challenge": "<64 hex chars>" }

The challenge is single-use and expires after two minutes.

2. Sign it and verify

Sign the raw 32 bytes of the challenge (decode the hex first — do not sign the hex string).

POST /api/auth/verify
{ "pubkey": "<hex>", "signature": "<128 hex chars>", "nickname": "botto" }

→ { "token": "<hex>", "balance_msat": 0 }

Pass the token on every subsequent request:

Authorization: Bearer <token>

Tokens live in memory, so a server restart signs everyone out. Just re-authenticate — it costs two requests and no human interaction.

Balance and history

GET /api/balance
→ { "balance_msat": 4500000 }
GET /api/history
→ { "entries": [
     { "Kind": "payout", "AmountMsat": 2500000,
       "BalanceBefore": 2000000, "BalanceAfter": 4500000,
       "RoundID": 412, "CreatedAt": "..." }
   ] }

Every balance change has exactly one entry explaining it. Nothing moves without a record.

Peer-to-peer transfers

POST /api/transfer
{ "to_pubkey": "<hex>", "amount_msat": 100000 }

→ { "balance_msat": 4400000 }

Instant and internal. Fails with 400 if you cannot cover it.

Crash games

Three rooms share one engine: rocket, orbital, tower.

Watch the state

GET /api/games
→ { "rooms": [ {
      "round_id": 412,
      "game": "rocket",
      "state": "betting_open",     // betting_open | locked | running | settled
      "tick": 0,
      "multiplier": "1.000000",
      "commitment": "<hex>",       // published before betting opens
      "server_seed": "<hex>",      // present only once settled
      "crash_point": "3.472190",   // present only once settled
      "players": [ { "nickname": "botto", "pubkey": "<hex>",
                     "stake_msat": 100000, "cashed_out": "2.500000",
                     "auto": true, "payout_msat": 250000 } ],
      "next_phase_in_seconds": 12.4
    } ] }

For a live feed instead of polling, open a WebSocket to /ws/{game} and you will receive the same object on every tick.

Place a bet

Only during betting_open, once per round.

POST /api/bet
{ "game": "rocket",
  "stake_msat": 100000,
  "nickname": "botto",
  "auto_cashout": 2.5 }        // optional; omit or 0 for no target

→ { "balance_msat": 4300000 }

The stake leaves your balance immediately. auto_cashout must be above 1.00 and closes your position at exactly that multiplier — not at whatever the next tick shows — provided it is at or below the round's crash point.

Setting a target is the reliable way to bot this game: network latency makes manual cash-out timing unreliable, and the target is evaluated server-side against the tick sequence.

Cash out manually

Only during running.

POST /api/cashout
{ "game": "rocket" }

→ { "cashed_out_at": "2.317445" }

Payment lands at settlement, a moment later.

Scratch tickets

GET /api/scratch/catalog
→ { "tickets": [ {
      "id": "nebula-nine", "name": "Nebula Nine", "cells": 9,
      "rtp_bp": 9900,
      "odds": [ { "tier": "Double", "payout_bp": 20000,
                  "weight": 155000, "denominator": 1000000,
                  "one_in": 6 } ]
    } ] }

The odds table is generated from the same data that produces outcomes, so it cannot drift from reality. rtp_bp is in basis points: 9900 is 99%.

POST /api/scratch/play
{ "ticket_id": "nebula-nine", "stake_msat": 10000 }

→ { "outcome": { "tier_name": "Double", "payout_bp": 20000,
                 "payout_msat": 20000, "roll": 481203,
                 "cells": [2,5,2,0,2,4,1,3,5] },
    "proof": { "commitment": "...", "server_seed": "...",
               "participants": ["..."], "nonce": 91,
               "round_seed": "..." },
    "balance_msat": 4310000 }

Resolves immediately. The proof is returned with the result, so a bot can verify every single play as it goes.

Verification

GET /api/verify/{roundID}
→ { "round_id": 412, "game": "rocket", "nonce": 412,
    "commitment": "<hex>", "server_seed": "<hex>",
    "client_seed": "<hex>", "crash_point": 14914127396,
    "participants": ["<hex>", "<hex>"] }

Returns 409 while a round is still open — the seed stays sealed until settlement, otherwise you could compute the outcome before betting closed.

To check it yourself:

  1. SHA256(server_seed) must equal commitment.
  2. client_seed must equal SHA256( each participant pubkey, each prefixed by its 4-byte big-endian length, concatenated in join order ).
  3. The round seed is HMAC-SHA256(server_seed, client_seed || uint64be(nonce)).
  4. crash_point is derived from that seed. It is Q32.32 fixed-point: divide by 2³² to get the multiplier.

Health

GET /api/health
→ { "status": "ok", "ledger_sum_msat": 0 }

ledger_sum_msat sums every account. Because each transaction balances to zero, it must always be zero. Anything else means the books are corrupt and status will say ledger_imbalance.

Errors

Failures return the appropriate status with { "error": "..." }. Common cases:

Status Meaning
400 Bad request, insufficient funds, betting closed, already in this round
401 Missing or unknown token
404 No such game or ticket
409 Round has not settled; the seed is still sealed

A complete bot

Plays every rocket round with a 2× target and verifies each result.

import time, requests
from nacl.signing import SigningKey   # pip install pynacl

BASE = "http://arcade.lan:8080"
key  = SigningKey.generate()          # persist this to keep your balance
pub  = key.verify_key.encode().hex()

chal = requests.post(f"{BASE}/api/auth/challenge", json={"pubkey": pub}).json()
sig  = key.sign(bytes.fromhex(chal["challenge"])).signature.hex()
tok  = requests.post(f"{BASE}/api/auth/verify",
                     json={"pubkey": pub, "signature": sig,
                           "nickname": "botto"}).json()["token"]
S = requests.Session()
S.headers["Authorization"] = f"Bearer {tok}"

seen = None
while True:
    room = next(r for r in S.get(f"{BASE}/api/games").json()["rooms"]
                if r["game"] == "rocket")

    if room["state"] == "betting_open" and room["round_id"] != seen:
        r = S.post(f"{BASE}/api/bet", json={
            "game": "rocket", "stake_msat": 10_000,
            "auto_cashout": 2.0, "nickname": "botto"})
        if r.ok:
            seen = room["round_id"]
            print(f"round {seen}: in, balance {r.json()['balance_msat']}")

    if room["state"] == "settled" and room.get("server_seed"):
        print(f"  crashed at {room['crash_point']}")

    time.sleep(1)

Verifying a settled round, using only published values:

import hashlib, hmac, struct

def verify(round_id):
    r = requests.get(f"{BASE}/api/verify/{round_id}").json()
    seed = bytes.fromhex(r["server_seed"])

    assert hashlib.sha256(seed).hexdigest() == r["commitment"], "bad commitment"

    h = hashlib.sha256()
    for p in r["participants"]:
        pk = bytes.fromhex(p)
        h.update(struct.pack(">I", len(pk)) + pk)
    assert h.hexdigest() == r["client_seed"], "bad client seed"

    round_seed = hmac.new(seed,
                          bytes.fromhex(r["client_seed"]) +
                          struct.pack(">Q", r["nonce"]),
                          hashlib.sha256).digest()
    print("verified:", round_seed.hex(),
          "crash", r["crash_point"] / 2**32)

Rate and fairness notes

There is no rate limiting, because this runs on a private network among people who know each other. If you point it at a hostile network, add some.

A bot has no edge over a human here beyond reaction time, and the auto cash-out target removes even that: the outcome was fixed by the committed seed before either of you acted.