perf: single-statement postings, marshal-once broadcast, client interpolation

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>
This commit is contained in:
drjones
2026-08-05 22:36:30 +00:00
parent 5c3393b989
commit 038550b6ff
7 changed files with 555 additions and 67 deletions

View File

@@ -191,8 +191,44 @@ function connect(game) {
socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
}
/* The server sends a frame a few times a second; the multiplier curve is
* deterministic, so between frames the client computes it locally from the
* round's start time. This is why the animation is smooth without the server
* pushing sixty frames a second to every phone. */
const TICK_HZ = 60;
const ROUND_TICKS = 60 * TICK_HZ;
function multiplierAtTick(tick) {
if (tick <= 0) return 1;
if (tick >= ROUND_TICKS) tick = ROUND_TICKS - 1;
const remaining = 1 - tick / ROUND_TICKS;
return 1 / (remaining * remaining);
}
function localMultiplier() {
if (!snapshot || snapshot.state !== 'running' || !snapshot.started_unix_milli) {
return snapshot ? parseFloat(snapshot.multiplier) : 1;
}
const elapsedMs = Date.now() - snapshot.started_unix_milli;
return multiplierAtTick(Math.floor((elapsedMs / 1000) * TICK_HZ));
}
/* Runs every animation frame while a round is in flight, so the number and the
* 3D scene update at display rate rather than at network rate. */
function interpolate() {
if (!snapshot || snapshot.state !== 'running') return;
const m = localMultiplier();
$('multiplier').textContent = m.toFixed(2) + '×';
if (scene) scene.setState(Math.log(Math.max(1, m)) / Math.log(25), false);
if (myBet === 'in') {
$('action').textContent = `Cash out ${sats(stake * m)}`;
}
requestAnimationFrame(interpolate);
}
function onSnapshot(s) {
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
const wasRunning = snapshot && snapshot.state === 'running';
const wasSettled = snapshot && snapshot.state === 'settled';
snapshot = s;
if (roundChanged) myBet = null;
@@ -205,9 +241,11 @@ function onSnapshot(s) {
$('commitment').textContent = s.commitment || '—';
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
const potMsat = (s.players || []).reduce((n, p) => n + p.stake_msat, 0);
$('potline').textContent = potMsat
? `${(s.players || []).length} in · ${sats(potMsat)} sats at stake`
// Counts come from the server aggregate: the player list in each frame is
// only the leaderboard, capped so a large room stays cheap to broadcast.
$('potline').textContent = s.player_count
? `${s.player_count} in · ${sats(s.pot_msat)} sats at stake` +
(s.cashed_out_count ? ` · ${s.cashed_out_count} out` : '')
: 'no bets yet';
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
@@ -239,6 +277,7 @@ function onSnapshot(s) {
case 'running': {
$('state').textContent = 'in flight';
if (!wasRunning) requestAnimationFrame(interpolate);
if (myBet === 'in') {
const payout = stake * current;
action.textContent = `Cash out ${sats(payout)}`;