A deterministic, provably-fair 2D marble-race betting game — proof of concept.
Players bet on a marble race. The backend commits to a secret seed, runs a pure deterministic simulation to produce the race, and the browser replays it (it never simulates). After the round the seed is revealed, so anyone can verify the published commitment matches the seed and re-run the exact race to confirm the outcome bit-for-bit.
▶ Live demo (no login): https://marbles2d.troyanenko.com/play/demo.html An endless, self-generating cycle of races. Each one is generated and resolved on the backend; the page only plays it back, and the panel shows the result before the race starts to make that visible.
This is a small but complete reference implementation of the hard part of a provably-fair betting product: a result that is computed once, on the server, and is independently verifiable by anyone, with a front end that is structurally incapable of changing it.
- Same seed → same race, on every machine. The physics is reproducible across architectures, so a recording produced on the server reproduces identically in a browser, in CI, and in the standalone verifier.
- The player can't cheat the result. The replay player is physics-free by construction (enforced at build time) — it can only interpolate the recording it was handed. There is no code path in the front end that could produce a different outcome.
- Swapping cosmetics can't change odds. Marbles are cosmetic-only; every marble is physically identical, so which skins race is fairness-neutral by construction (see ADR-004).
seed_commit ──► pick pool ──► generate track ──► simulate ──► store Recording
(publish (content (deterministic (pure, (frames + events +
SHA-256 pool) procedural) seeded) result + commitment)
of seed) │
▼
reserve (assign nonce) ──► BETTING ──► RACE (browser replays the Recording) ──► REVEAL seed ──► SETTLE
- Commit. Before anything else the backend draws a 32-byte secret
server_seedand publishescommitment = SHA-256(server_seed). It can't change the seed afterwards without breaking the commitment. - Generate + simulate. A procedural track is generated and the race is simulated deterministically from the seed. The output is a Recording: 30 Hz snapshot frames, lifecycle events, the final result, and the provably-fair block. A buffer of ready rounds is kept warm.
- Replay. The browser player (PixiJS) loads the Recording and animates the marbles by interpolating frames against a single playhead clock. It draws the static track once and never runs physics.
- Reveal + verify. After the race the
server_seedis revealed. The player checksSHA-256(server_seed) == commitmenton its own; a separate verifier re-runs the whole simulation and asserts the recording reproduces byte-for-byte.
- Determinism is the contract. The simulator restricts itself to a fixed numeric
surface (
+ − × ÷ √) and a fixed RNG draw order. Cross-architecture reproducibility is pinned by golden vectors checked in CI; the test harness guards against fused-multiply-add (FMA) drift that would otherwise flip finish order by ~1 ULP. - Provably-fair commit/reveal. The commitment binds only the secret server seed, so the server fixes its seed before any client entropy exists and cannot grind it. The composite RNG seed mixes in a public client seed; the reveal lets anyone reproduce the draw.
- Recording-as-source-of-truth + a physics-free player. The authoritative simulation
lives in
packages/core; the player consumes data only. A dependency-cruiser rule and a "no physics symbols" build gate (tools/gates/check-no-physics.mjs) make it impossible for the player bundle to import the simulator — so the front end can never diverge from, or tamper with, the result. - ADR-004 — cosmetic-only marbles. A ball's
physicsOverrideis alwaysnull. Every marble is physically identical; only its appearance differs. This makes the entire ball-design / publish / roster-swap feature fairness-neutral: operators can author and swap skins with zero effect on physics, simulation, or outcome. - Schemas generated from types, validated everywhere. JSON Schemas are generated from
the TypeScript types (
pnpm gen:schemas) and validated against fixtures in CI and in-browser (the admin validates content in Web Workers using the same schemas as the server). - Append-only, hash-chained audit log. Seed commits/reveals and content/config changes are recorded in a tamper-evident chain (a modified row breaks the chain).
- Odds are cosmetic; settlement is provably-fair. Demo odds come from a Monte-Carlo prior with an RTP target, shrinkage, and a multiplier cap — but the outcome is fixed by the seed, independently of any pricing.
A TypeScript pnpm monorepo.
| Package | Role |
|---|---|
packages/core |
The deterministic engine: crypto (commit/reveal, HMAC seed chain), physics simulator, procedural track generation, fairness/odds analysis. |
packages/protocol |
The host ↔ embedded-player message bridge (READY / RACE_STARTED / RACE_FINISHED …). |
packages/verifier |
Independent re-simulation + commitment check — the open verification surface. |
packages/test-vectors |
Golden recordings / vectors that pin determinism. |
apps/backend |
Fastify service: round orchestration, ready-round buffer, REST (/v1 public contract + /admin), pluggable storage (in-memory by default; Postgres / Redis / S3 when configured), audit log. |
apps/admin |
Operator / designer SPA (React): track & ball editors with in-browser validation, content-pool & roster management, replay with "Verify Now", audit viewer, and a built-in Demo mode. |
apps/player |
The standalone replay player (PixiJS) — loads a Recording bundle and plays it; physics-free. |
tools/ |
Schema generation, the no-physics build gate, a CLI simulator, a track-fairness probe. |
The whole thing runs from one container in production: the Fastify backend serves the
built admin SPA and the player from the same origin. The public demo and the /v1 REST
surface are exposed without auth; the operator admin sits behind HTTP basic-auth.
pnpm install
# Backing services are optional — with no DATABASE_URL the backend uses in-memory
# storage, so tests and the demo run with zero infrastructure.
pnpm dev # backend + admin + player in watch mode
# Full quality gate (what CI runs):
pnpm check # typecheck + eslint + dependency-cruiser + vitest (22 test files)
# Player build is gated to stay physics-free:
pnpm gate:player
# Optional: Postgres + Redis + MinIO for the persistence path
docker compose up -d
cp .env.example .envThe standalone player can replay any round bundle: open
apps/player and load /play/?roundId=<id> (or &pub=1 for the public bundle).
commitment = SHA-256(server_seed)is published at round creation.- The Recording carries the commitment; the
server_seedis withheld until the round is revealed, so a recording of an unrevealed round can't be used to predict it. - After reveal,
packages/verifierre-runs the simulation from the seed and asserts the recording (and therefore the result) reproduces exactly.
Proof of concept. The simulation, provably-fair scheme, replay player, admin/designer tooling, public demo, and persistence path are implemented and deployed; external items (legal opinions, lab certification, pen-test) are explicitly out of scope for a POC.
Demo defaults in the compose files (marbles / local MinIO creds) are throwaway
development values for internal services; production secrets are read from the environment
(POSTGRES_PASSWORD, S3_SECRET_KEY) and the operator basic-auth credential must be
generated per deployment.

