Developer API

← back to the races

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.

Why it's build-on-able (and how fair it is)

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:

The race lifecycle

race_announced T−30s
The 5-marble roster, the track seed, and 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.
race_start T+0
The window closes, the server fetches a public randomness beacon pulse, computes 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.
race_result ≈ T+60–120s
Official finishing order, revealed after the marbles visibly cross the line, together with the same seed inputs. Results are final. Ranks 1–5; a stuck marble records a timeSec: null DNF.
tournament_complete every ~25 races
A champion is crowned and the master seed + commit salt are revealed; a fresh 100-marble tournament starts about 30 seconds later, forever.

Timing tip: compare scheduledStart against the serverNow included in every message — never your local clock alone.

Quick start

// 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.

REST endpoints

GET/api/state

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.

GET/api/next

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
  }
}
POST/api/race/:key/client-seed

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 }),
});

Seed derivation — exact byte layout

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).

GET/api/history?limit=50

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 },
    …
  ]
} ] }
GET/api/champions?limit=50

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
  } ]
}
GET/api/hall-of-fame

All-time aggregates: tournaments completed, distinct champions, the title table (mostTitles[]), repeatChampions[], the longestStreak of back-to-back titles, and the currentChampion.

WebSocket events — wss://marblerun.fun/ws

typewhenpayload highlights
snapshoton connectsame shape as /api/state
round_builta round's bracket formsround.races[] (rosters; seeds only as disclosed)
race_announcedT−30srace (roster, trackSeed, scheduledStart — no raceSeed), clientSeedWindow, serverNow
race_startgate opensraceKey, trackSeed, raceSeed, publicContribution, publicSource, clientSeeds[], beacon, serverNow
race_resultafter the finishraceKey, result[] (ranks/times), the same seed inputs as race_start, fresh standings
pausedadmin pause/resumepaused: true|false
tournament_completechampion crownedchampion {id, name}, masterSeedHex, commitSalt, commit (masterSeed is a legacy uint32 view)

Reconnect with backoff and re-sync from /api/state — snapshots are idempotent.

Verifying a result yourself

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

Determinism — what we guarantee, and where

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.

House rules