marblerun.fun runs a nonstop, verifiably fair marble tournament: 100 marbles, 25 deterministic races, one champion, forever. Everything the viewer knows is available over a free public API — build a stats site, a stream overlay, a Discord bot, a fan page for your marble, a research dataset, whatever rolls your marbles.
Every race is a pure function of two 32-bit integers: a track seed (builds the course) and a race seed (drives the physics). The server, every viewer, and your code reproduce the same finishing order from them — the sim is deterministic and open source (github.com/bryanbrinkman/marblefun).
Seeds are disclosed as late as they're needed, and the outcome-deciding one is built from two parties' inputs:
crypto.randomBytes). Only its commitment
commit = sha256(masterSeed ‖ commitSalt) (32-byte salt) is published while
the tournament runs; seed and salt are revealed at tournament_complete.
Courses and bracket draws derive from the master seed alone, so they are fixed
from the start.publicContribution
that does not exist until the gate opens: a hash of a public randomness beacon
pulse fetched at that moment plus every client seed submitted during the announce
window. Neither the operator nor any single contributor controls it.race_start, and could not
choose a master seed to favour a marble, because every race also depends on
inputs that arrive later.publicSource: "degraded" — those
ran on the house half alone and should be treated as unverified. It also does not
prove the physics code the server ran is the code in the repo; the replay check
below is how you verify that, race by race.scheduledStart (epoch ms) go public — not the race seed,
which does not exist yet. The client seed window opens: anyone may
POST /api/race/:key/client-seed 32 random bytes (one per IP) that
will be folded into the race seed.
publicContribution from the pulse + every
client seed, derives the race seed from its committed master seed and that
contribution, and broadcasts all of it. The outcome is determined and computable
from this instant — and not one moment before, by anyone.
timeSec: null DNF.
Timing tip: compare scheduledStart against the serverNow
included in every message — never your local clock alone.
// What's racing next?
const next = await fetch('https://marblerun.fun/api/next').then(r => r.json());
console.log(next.race.raceKey, next.race.roster, new Date(next.race.scheduledStart));
// Live events
const ws = new WebSocket('wss://marblerun.fun/ws');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'race_announced') onAnnounce(msg.race); // track seed + roster + start time (+ clientSeedWindow)
if (msg.type === 'race_start') onStart(msg); // raceSeed + publicContribution + clientSeeds + beacon
if (msg.type === 'race_result') onResult(msg.raceKey, msg.result); // [{rank, marbleId, …}]
};
CORS is open (Access-Control-Allow-Origin: *) on all
/api/* endpoints, so this works straight from a browser. The canonical
base is https://marblerun.fun; the same API answers on whatever origin
serves this page.
The full tournament snapshot — same payload a fresh viewer boots from:
all rounds and races (rosters, results so far, and each race's seeds as they're
disclosed — see the fairness rules above), standings for all 100 marbles,
the current race phase, the champion if crowned, serverNow and
announceLeadMs. The tournament object carries the
commit while running, and masterSeed + commitSalt
once it's complete.
Just the upcoming (or in-progress) race — roster, course, start time and whatever seeds are disclosed:
{
"type": "next_race",
"serverNow": 1786000000000,
"announceLeadMs": 30000,
"phase": "announced", // announced | running | paused | null
"tournamentId": 41,
"champion": null,
"race": {
"key": "heats:7",
"roundKey": "heats", // heats = qualifying, semis = finals, final = championship
"indexInRound": 7,
"trackSeed": 1970251534, // course = f(trackSeed) — present from announce
// once status is "running": "raceSeed", "publicContribution", "publicSource",
// "clientSeeds", "beacon" — outcome = f(trackSeed, raceSeed)
"scheduledStart": 1786000023456,
"status": "announced", // pending → announced → running → done
"roster": [
{ "slot": 0, "marbleId": 42, "marbleName": "Royal Flush", "lane": "RED", "color": "#c94a36" },
…4 more
],
"result": null // filled in once revealed
}
}
Contribute to the next race seed. Body {"seed": "<64 hex chars>"}
(32 random bytes). Accepted only while that race is announced (from
race_announced until scheduledStart); one seed per IP per race;
at most 256 per race. Returns { ok, accepted, count, closesAt }; 409
once the gate has opened, 400 for a malformed seed. Every accepted seed appears
verbatim in that race's race_start, race_result and
/api/history entry, so you can confirm yours went in — and since the seed is
folded in with everyone else's and a public beacon pulse, no single contributor (the
house included) can steer the result.
const seed = [...crypto.getRandomValues(new Uint8Array(32))].map(b => b.toString(16).padStart(2, '0')).join('');
await fetch(`https://marblerun.fun/api/race/${race.key}/client-seed`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ seed }),
});
All hashes are SHA-256; a "seed" is the first 4 bytes of the digest read as a
big-endian uint32. ‖ is byte concatenation, u32be a 4-byte big-endian
integer, strings are UTF-8.
masterSeed = 32 random bytes (revealed at tournament_complete as masterSeedHex)
commit = sha256( masterSeed ‖ commitSalt[32] ) // published from the first snapshot
drawSeed(round) = sha256( masterSeed ‖ u32be(tournamentId) ‖ "draw:" + roundKey )[0..4]
trackSeed(race, n) = sha256( masterSeed ‖ u32be(tournamentId) ‖ raceKey ‖ "track" ‖ u8(n) )[0..4] // n = trackAttempt (0 unless the course was re-rolled)
publicContribution = sha256( "marblerun-public-v1" ‖ beaconBytes ‖ seed_1 ‖ seed_2 ‖ … )
beaconBytes = beacon ? utf8( beacon.source + ":" + beacon.round + ":" + beacon.value ) : ""
seed_i = the accepted client seeds as 32-byte values, deduplicated, sorted as lowercase hex
raceSeed = sha256( masterSeed ‖ u32be(tournamentId) ‖ raceKey ‖ publicContribution[32] )[0..4]
publicSource tells you what went into the public half:
beacon+clients, beacon, clients,
stale-beacon… (the beacon was unreachable at the gate, so the last pulse the
server had seen was used — check its round), or degraded (no
beacon and no client seeds — the race ran on the house seed alone; treat such a race as
unverified-fair). The beacon pulse can be checked against the beacon's own archive
(beacon.url).
Recent completed races across tournaments, newest first (max 200). Each entry has the seeds, timing, and full results — everything needed to build statistics or audit a race after the fact:
{ "races": [ {
"tournamentId": 41, "raceKey": "heats:6", "roundKey": "heats", "indexInRound": 6,
"trackSeed": 2094821771, "trackAttempt": 0, "raceSeed": 912004183,
"publicContribution": "c0df65a2…", "publicSource": "beacon+clients",
"clientSeeds": ["9f3a…", …], "beacon": { "source": "drand", "round": 4711234, "value": "e1b0…", "url": "https://api.drand.sh/public/latest" },
"scheduledStart": 1785999900000, "startedAt": 1785999900012, "revealedAt": 1785999978450,
"results": [
{ "rank": 1, "marbleId": 73, "marbleName": "Molder", "lane": "GREEN", "color": "#4d9c56", "timeSec": 58.3 },
…
]
} ] }
The hall of fame — tournament winners, newest first (max 200). champions[]
is the flat record; history[] adds each champion's road through the bracket
and the final's finishing order (what /champions renders):
{
"champions": [ { "tournament_id": 41, "master_seed": 3456789012, "created_at": …, "completed_at": …,
"champion_marble_id": 73, "champion_name": "Molder" } ],
"history": [ {
"tournamentId": 41, "masterSeed": 3456789012, "createdAt": …, "completedAt": …,
"champion": { "id": 73, "name": "Molder" },
"path": [ { "raceKey": "heats:6", "roundKey": "heats", "indexInRound": 6, "rank": 1, "timeSec": 58.3,
"trackSeed": …, "raceSeed": … }, … ], // heat → semi → final
"final": [ { "rank": 1, "marbleId": 73, "marbleName": "Molder", "lane": "GREEN", "color": "#4d9c56", "timeSec": 61.0 }, … ],
"racesRun": 25
} ]
}
All-time aggregates: tournaments completed, distinct champions, the
title table (mostTitles[]), repeatChampions[], the
longestStreak of back-to-back titles, and the currentChampion.
| type | when | payload highlights |
|---|---|---|
snapshot | on connect | same shape as /api/state |
round_built | a round's bracket forms | round.races[] (rosters; seeds only as disclosed) |
race_announced | T−30s | race (roster, trackSeed, scheduledStart — no raceSeed), clientSeedWindow, serverNow |
race_start | gate opens | raceKey, trackSeed, raceSeed, publicContribution, publicSource, clientSeeds[], beacon, serverNow |
race_result | after the finish | raceKey, result[] (ranks/times), the same seed inputs as race_start, fresh standings |
paused | admin pause/resume | paused: true|false |
tournament_complete | champion crowned | champion {id, name}, masterSeedHex, commitSalt, commit (masterSeed is a legacy uint32 view) |
Reconnect with backoff and re-sync from
/api/state — snapshots are idempotent.
Clone the repo, serve public/, load the game, and replay any race
from its seeds. The finishing order will match the API's result, and finish times will
match to within floating-point rounding (see "Determinism" below for exactly what we
guarantee and in which environment):
// with public/marble_run.html loaded in a same-origin iframe or window
marbleAPI.newCourse(race.trackSeed); // from race_start / /api/history
const sim = marbleAPI.simulateRace(race.raceSeed);
console.log(sim.results); // same order as race_result
This is what the server itself does — it has no other source of truth.
To check a finished tournament wasn't rigged, verify the
reveal against the commit you saw while it was live, then re-derive the race
seed from the published inputs (layout in the seed-derivation box above):
const hex = (b) => [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join('');
const bytes = (h) => Uint8Array.from(h.match(/../g), x => parseInt(x, 16));
const sha = async (...parts) => {
const total = parts.reduce((n, p) => n + p.length, 0), buf = new Uint8Array(total);
let o = 0; for (const p of parts) { buf.set(p, o); o += p.length; }
return new Uint8Array(await crypto.subtle.digest('SHA-256', buf));
};
const u32be = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; };
const utf8 = (s) => new TextEncoder().encode(s);
// 1. masterSeedHex + commitSalt arrive in tournament_complete (and /api/state once "complete")
console.log(hex(await sha(bytes(masterSeedHex), bytes(commitSalt))) === commit); // true → seed fixed up front
// 2. re-derive one race's seed from race_start / /api/history fields
const beaconBytes = race.beacon ? utf8(`${race.beacon.source}:${race.beacon.round}:${race.beacon.value}`) : new Uint8Array(0);
const seeds = [...new Set(race.clientSeeds.map(s => s.toLowerCase()))].sort().map(bytes);
const pub = await sha(utf8('marblerun-public-v1'), beaconBytes, ...seeds);
console.log(hex(pub) === race.publicContribution); // true
const rs = new DataView((await sha(bytes(masterSeedHex), u32be(tournamentId), utf8(race.raceKey), pub)).buffer).getUint32(0);
console.log(rs === race.raceSeed); // true → then replay it above
The sim is a fixed-timestep (1/120 s) physics loop seeded from
raceSeed. Nothing on the outcome path reads a clock, Math.random,
or an implementation-defined function: track geometry and blade collisions use a
pure-arithmetic trig implementation (DMath in marble_run.html),
and everything else is IEEE-754 add/multiply/divide/sqrt/floor, which every JavaScript
engine rounds identically. Rendering (cameras, confetti, easing) is free to use
Math.* because it never feeds back into the physics.
Dockerfile pins playwright@^1.49), running the
public/marble_run.html at the deployed repo commit. That is what the
server uses, and what /api/history results were computed on.(trackSeed, raceSeed)
reproduces the recorded finishing order and finish times exactly. CI enforces
this on every push by replaying test/fixtures/races.json
(npm run test:replay)./api/history are a complete bug report./api/history stay
as recorded (they reproduce on the commit that produced them)./api/* endpoints (CORS preflights don't count); over that you get
429 with a Retry-After header — honour it. WebSockets:
5 concurrent connections per IP (a sixth upgrade is refused with 429).
Client→server WebSocket frames are capped at 4 KiB and ignored — the protocol is
one-way — and a client that keeps sending is disconnected. Prefer the WebSocket over
polling; if you must poll, one /api/state every few seconds is plenty.
This runs on a small box.