Skip to content
All work
03Virtual football platform2026

BetNG

A virtual football platform where every match is simulated once and seen identically on five clients: twelve TypeScript and Python services, integer-kobo money, and fourteen invariants each tied to a named test.

12
Backend services
5
Clients, one match model
187
Gateway routes
14
Invariants, each with a test
Role
Author
Year
2026
Status
Open source
Type
Virtual football platform

01Overview

BetNG runs four simulated football leagues of 80 clubs, round after round on a real clock. The platform simulates every match once, and every viewer sees that same match: the same fixture, prices, timeline, result and settlement. That holds on the web, on a phone, on a shop television, at a cashier terminal and in the admin console. It uses play money only.

I built it alone as a portfolio system. The gateway and seven TypeScript services run on Zudojs with Prisma. Four Python services handle simulation, odds, risk and analytics. Five clients share one set of wire contracts and one view-model layer.

The hard part was trust, not features. When the house prices the bets and also plays the match, the only credible design is one where nobody can influence, predict or edit the result. The database has to enforce that and tests have to prove it. A README promise is not enough.

02The problem

A virtual sports product is a chain of authorities. A scheduler creates fixtures, an odds engine prices them, a risk engine decides how much it will accept, betting closes, a simulation plays the match and settlement pays out. Each link must be the only one allowed to make its decision, and none may see further along the chain than it needs to.

The obvious failure is a result that bets can reach. The product is rigged, whether anyone means it or not, if any of three things is true: the simulation can see stakes, an admin can re-run a match, or the seed can be computed from public inputs.

The quieter failure is five clients drifting apart. A phone that works out its own payout, or a TV that runs its own clock, will eventually show a number the platform never agreed to. So the clients had to display state and never produce it.

03Architecture

  1. 01

    Twelve services, one schema each

    There is a public gateway, seven TypeScript services (match, betting, wallet, settlement, identity, event, email) and four Python services (simulation, odds, risk, analytics). Each service that stores data owns one PostgreSQL schema and has a login that can write only that schema. A write to another service's data goes through that service's `POST /rpc` and needs an internal token. Redis holds locks, caches and rate-limit counters, and is never the source of truth.

  2. 02

    The gateway as a route table

    The public API is 187 routes declared as data: the path, the service that owns it and who may call it. The gateway checks the session, removes any incoming `x-betng-*` header and rebuilds the actor from the session, so a service never trusts a user id taken from a path or body. Rate limits on bets, deposits and withdrawals fail closed. No route sets a score or a winner.

  3. 03

    A fifteen-state match lifecycle

    A match moves from FIXTURE_CREATED through BETTING_CLOSED, SIMULATION_STARTED and MATCH_FINISHED to SETTLEMENT_COMPLETED. There are also failure states and VOIDED. Every transition is stored as a row. The scheduler ticks once a second behind a Redis lock, so only one instance ever drives it. With the default settings a match minute lasts two seconds.

  4. 04

    A simulation that cannot be steered

    Betting closes before kick-off. The simulation then plays the match once, minute by minute, from the match id and the two teams' strengths. Its request models reject every other field, so sending a stake is a validation error. The seed is an HMAC of the match under a server secret, so nobody can work out the result from the source code. Database triggers block UPDATE, DELETE and TRUNCATE on results and events. Events are shown only when the match clock reaches them.

  5. 05

    Pricing and risk

    The odds service prices six market types from the score distribution the simulation produces. Pricing uses the same minute-by-minute core that decides the score. The risk service answers ACCEPT, LIMIT or REJECT for every stake, based on exposure across all accounts. It freezes that exposure when betting closes and has no way to touch a price or a result.

  6. 06

    Money as integer kobo

    Every amount is a BIGINT in kobo, from the database all the way to the screen. Payouts are calculated with integer arithmetic only, using the odds stored on the bet when it was accepted. The wallet ledger is append-only. The operator ledger is kept apart from customer wallets. A unique key makes settlement idempotent, so settling a match twice pays nobody twice.

  7. 07

    Five clients, one match model

    The web, TV, shop and admin apps are React 19 on Vite and share one component library. The mobile app is Expo 57 and uses the same view models. Components never call fetch or import the SDK. They render view models from a data-source interface, and a platform adapter behind it talks to the gateway. Realtime signals over WebSocket or SSE tell TanStack Query which cached data to refresh.

04Key decisions

Decision 01

Enforce invariants in the database, prove them in tests

`docs/invariants.md` lists fourteen rules, such as one result per match, bets never reach the simulation, settlement pays on stored odds, and admin cannot pick a winner. Beside each rule it names what enforces it and the test that proves it. Where a rule could be a unique index, a CHECK or a trigger, I made it one, because application code can be bypassed and a constraint cannot.

Decision 02

Close betting before the match is played

The simulation runs at kick-off, after betting has closed and exposure is frozen. No bet can be accepted once the result exists, so there is nothing for a bet to influence. The result is created at kick-off and only revealed as the match plays out.

Decision 03

TypeScript for the plumbing, Python for the maths

The gateway, ledgers, identity and match lifecycle are TypeScript on Zudojs and Prisma. The probability model, pricing, risk and analytics are Python with FastAPI and psycopg. Both sides use the same camelCase wire format: Zod schemas in `packages/contracts`, with matching Pydantic models on the Python side. So changing language never means changing the contract.

Decision 04

No stand-in backend for the clients

There is no offline mock. Development, the browser test suites and production all run against the real platform. `pnpm verify` fails if a production bundle references a mock, a data-source switch or a demo credential. The clients take longer to start, but they cannot drift from the API.

05Stack

  • TypeScript
  • Node.js 24
  • Zudojs
  • Prisma 7
  • Python 3.14
  • FastAPI
  • PostgreSQL 17
  • Redis 8
  • React 19
  • Vite
  • Expo
  • Tailwind CSS
  • TanStack Query
  • Zod
  • Playwright
  • Docker