Cashing out was the least approachable thing here: open your wallet,
create an invoice for exactly the right amount, copy it, come back, paste
it. That is the step people abandon, leaving sats behind.
LNURL-withdraw replaces it with a scan. The arcade shows a code, the
wallet pulls the funds, and the player never handles an invoice or types
an amount. The paste path is kept for wallets without LNURL support, but
folded away.
The withdraw token is a bearer instrument, so it is random, single-use,
bound to one account and one amount, and expires in five minutes. Sixteen
goroutines racing one code yield exactly one payment. Funds are debited
when the code is issued — otherwise a player could cash out and bet the
same sats before the wallet claimed them — and a sweep refunds any code
that is never scanned.
bech32 is verified against the BIP-173 vectors, including the invalid
ones. Getting this wrong produces codes that silently fail to scan with
no useful error for the player.
Adds a three-card first-run walkthrough, an explanation of what a
multiplier target means, and a one-time confirmation before a player's
first real-money action — the interface is deliberately frictionless, and
that is the one place a moment of friction is worth it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deposit shows a scannable QR, because nobody types a 400-character
invoice at a party. The QR encoder is written rather than imported: a CDN
script is a dependency on the outside world, and this box has to work on
a network with no internet.
Its tests caught a real bug — the format-information loop wrote eight
cells down column 8, but the eighth is the dark module, which is fixed.
Overwriting it produces symbols some readers reject.
The deposit and withdraw cards stay hidden unless the server reports a
node, so a play-money deployment does not advertise a deposit it cannot
honour. Settlement is polled and confirmed server-side against the node,
so the client cannot claim a payment that never arrived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing alby.go against real money found two bugs and one deployment
hazard.
The fee cap was accepted and ignored: maxFeeMsat appeared only in the
signature. The Service sizes that cap against what the house will lose on
routing and relies on the node refusing anything above it, so ignoring it
turned a bounded cost into an unbounded one. It is now requested from
Alby and checked again on the result.
Amounts were truncated from millisatoshis to satoshis. A 1500 msat
withdrawal debited 1500 and sent 1000, and the missing 500 was
unaccounted — drift that surfaces weeks later as a books-do-not-balance
alarm. Amounts that are not whole satoshis are now refused.
The dev faucet and a real node could both be enabled. The faucet mints
balance backed by nothing, so a player could withdraw it as real
satoshis and drain the node. The server now refuses to start with both
set. Withdrawal processing is also gated on solvency: paying the front of
a queue while short leaves the players behind it with nothing.
11 tests against a mock of the Alby REST API covering auth, unit
conversion, the fee cap, unsettled payments, error propagation, and
context cancellation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WebCrypto has Ed25519 but no ML-DSA, so the post-quantum half is
compiled from the same pkg/pqid the server verifies with. One
implementation of the scheme in the project means a client and server
cannot disagree about signing.
The Ed25519 half is stored as its 32-byte seed rather than the expanded
key, since the seed cannot encode an inconsistent pair, and the public
key is derived rather than stored so a client cannot present one that
does not match what it signs with.
Verified end to end in a JS runtime: 1984-byte public key, 3373-byte
signature, derived key matches, malformed input returns an error rather
than crashing the module. 3.4MB, 0.9MB gzipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entry fees collect into a real ledger account rather than a number in a
row, so tournament money obeys the same double-entry invariants as
everything else and every movement is explained by a posting.
Settlement distributes the entire pool: dividing a pool across percentage
shares leaves a remainder, and dropping it would destroy money and break
conservation, so it goes to first place. Settlement claims the tournament
before paying, so two instances cannot both pay out. Cancellation refunds
every entrant and asserts the pool empties exactly.
18 tests including concurrent entry, concurrent settlement, unfunded
entry taking no seat, and books balancing after payout.
Removes an append-only trigger that had been over-applied to entry rows.
An entry is a seat reservation, not a financial record: a seat claimed
but unpaid must be releasable so the player can retry once funded. The
money side stays immutable because it is a ledger posting.
The journey test now derives the expected payout from the published fee
schedule instead of hardcoding it, so it keeps checking something real if
the rake changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The costs page states both deductions and, for each game, the maths
return alongside what players actually receive. It is rendered from
/api/fees, which the server generates from the same schedule it charges,
so the published terms cannot drift from the behaviour.
Backups every five minutes with a rolling 24 hours. Each dump is checked
for size and format before replacing the previous one — a backup script
that reports success on a truncated file is worse than none, because it
turns a recoverable outage into silent loss found only when needed. A
nightly job restores the newest snapshot and asserts the ledger balances.
The standby continuously restores into a shadow database and swaps only
after verifying the books, so it is never mid-restore when needed and
never promotes a corrupt copy. Promotion does not contact the dead
machine, and it refuses to start if the ledger does not balance.
Kernel tuning is tied to measured limits, not copied defaults. Alby Hub
is explicitly excluded from snapshot restore: publishing a stale channel
state can lose the channel balance outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fees now flow through settlement. The payout and the deduction are posted
as separate ledger transactions rather than netted, so a player's history
shows the full win and the charge as itemised lines instead of a quietly
smaller win.
The admin console shows treasury, liability, revenue, every posting,
every round, and risk flags. Auth is a constant-time token compare and
the surface is not mounted at all unless ARCADE_ADMIN_TOKEN is set, so a
default deployment has no admin endpoint to attack. The token lives in
browser memory only.
It is read-only over game outcomes by design: seeds show only after
settlement and nothing can alter a crash point. A control that could
would make the fairness proof a lie.
The console immediately found a real bug: 343 unresolved rounds, because
the reconciler only considered rounds with bets and abandoned empty ones
accumulated forever, burying the signal. Now cleared automatically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the money bug flagged earlier. When an instance died mid-round its
players had already been debited, so their stakes sat with the house:
balanced books, quietly robbed players. Every instance now sweeps for
unresolved rounds and refunds them.
Such a round is marked void, not settled. The schema caught this: the
reveal_is_complete constraint requires a settled round to publish its
seed, and an abandoned round has no outcome to reveal. Void is a distinct
state with its own column and a check that the two are exclusive.
Claiming happens before money moves, so concurrent reconcilers on
different instances refund exactly once.
Adds TestFullPlayerJourney: sign-in with no account, fund, scratch, bet
with an auto target, settle, verify the round independently, check the
ledger history is continuous, transfer to a friend, and confirm the books
still sum to zero. It asserts against the ledger rather than the API's
own summary.
Adds cmd/loadtest. One instance on 4 cores held 25,000 concurrent
websocket connections with zero failures at 586MB RSS, about 26KB per
connection, with the load generator competing for the same CPU.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance decides what it is at startup instead of being told: it
generates its own identity, registers a heartbeat, and campaigns for
each game. Exactly one instance drives a game's rounds and publishes
frames; the rest relay them and forward mutations to the leader. Clone
the VM, boot it, done.
Sessions and the scratch nonce move to Redis. Both were per-instance
state that would have broken behind a load balancer: a token minted by
one clone was unknown to the others, and two clones would have handed
the same nonce to different players, which for the same key means the
same outcome.
Fixes a bug found by running two instances: /api/games read the local
room object, so a follower reported a permanently settled game and its
clients never saw a betting window. Hubs now serve the last frame they
saw, produced or relayed.
Failover measured at 6s after kill -9 on an instance leading two games.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured, then fixed, the three things that made a crowd impossible.
Ledger: Post issued three round trips per posting, so settlement scaled
in network latency rather than work. It is now two statements regardless
of leg count — settling 1000 winners went 844ms to 220ms. The lock and
the balance read must stay separate statements: a single statement, even
one whose CTE does FOR UPDATE, evaluates against a snapshot taken before
the locks are held, so concurrent transactions read stale balances and
money disappears. The conservation tests caught exactly that.
Broadcast: every connection marshalled its own copy, ~355us each. At any
real crowd that exceeds the tick interval by orders of magnitude. Frames
are now serialised once per broadcast and shared.
Feed: the player list is capped at 24 and carries no public keys, and
running rounds broadcast at 5Hz instead of 60Hz. Clients compute the
multiplier locally from the round start time, which the deterministic
curve makes exact. Frame size fell from 3.6KB to 1.8KB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three WebGL scenes on one renderer and one context, since a phone should
not allocate a context per game: a rocket straining against gravity, a
decaying orbit, and a tower that sways harder the higher it stacks. The
loop stops when the tab is hidden.
Navigation moves to the bottom, where a thumb already is. New Stats view
with balance history, cash-out rate, and a distribution chart that plots
observed crash points against what the published maths predicts — the
honest version of a hot-numbers board.
Charts are hand-built SVG, ~300 lines, rather than a library that would
cost more to load than the 3D engine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
At u=1 the unsigned quotient exceeded int64 and wrapped negative, so the
rarest and most valuable outcome silently became an instant 1.00x loss.
At u=2 it produced a 2.1-billion-times payout the house could never
cover, which would have left settlement failing and the player unpaid.
The crash point is now capped at the largest multiplier the curve can
express, which is unreachable anyway since the round hits its tick
ceiling first.
FromInt now panics outside the Q32.32 integer range instead of wrapping
a positive input into a negative value.
Raises coverage to 88% overall; adds a Makefile with db-reset, since the
append-only ledger steadily consumes bridge headroom across test runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.
Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>