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

288 lines
8.3 KiB
Markdown
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 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.
```python
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:
```python
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.