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>
8.3 KiB
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:
SHA256(server_seed)must equalcommitment.client_seedmust equalSHA256(each participant pubkey, each prefixed by its 4-byte big-endian length, concatenated in join order).- The round seed is
HMAC-SHA256(server_seed, client_seed || uint64be(nonce)). crash_pointis 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.