Track: Consumer & Fan Experiences · TxODDS World Cup Hackathon 2026 Live: hatrick.xyz · Repo: github.com/project-hattrick/hatrick Network: Solana devnet · test tokens only — no real money moves.
This is the complete technical writeup for the judges: what Hatrick is, how the TxLINE feed drives
every surface, how the four Solana programs work, and an honest account of what is real versus
simulated. For the two-minute version see submission.md; for the verified TxLINE
provider reference see txline-provider.md; for the event-flow deep-dive see
architecture.md.
- TL;DR for reviewers
- Product overview
- System architecture
- Repository layout
- TxLINE integration — the required live input
- Solana / on-chain (devnet)
- Fantasy mechanics
- Live mode mechanics
- Tech stack
- Running it
- Compliance & responsible gaming
- Honest scope — real vs simulated
- Team
- A working product, not a mockup. Live on devnet with real transactions end-to-end — pack mints, bet escrow, duel stakes, and settlements all link straight to Solscan.
- TxLINE is the primary input, not a garnish. Score, clock, play-by-play, lineups, odds, replays, settlement, and fantasy card progression all derive from the feed. Nothing on screen is typed in.
- The data becomes play. Instead of another dashboard or pundit bot, the feed powers a playable 2D live match you watch and bet, and a 1v1 fantasy duel simulated from real player stats.
- A non-crypto fan can use it in under a minute. Email sign-in silently creates a Solana wallet (Privy); test funds are one tap; no extension, no seed phrase.
- Real-time is structural. Every event fires twice — an optimistic
duringfor instant UI and a confirmedafterfor money — so responsiveness and trust are separate, visible guarantees.
Hatrick unifies what a World Cup fan does across five disconnected apps — watch, follow odds, play fantasy, manage a wallet, socialize — into one platform, one profile, one wallet, powered by a single real-time feed. Two connected modes:
- Live — a real match rebuilt as a watchable 2D game: full play-by-play with real player names, a live odds board, and on-chain betting (devnet) that settles itself from the authoritative full-time result. Finished matches replay in the same engine at 1× to 8×.
- Fantasy — buy packs of limited-edition Metaplex Core NFT cards (serial-numbered, capped supply, provably-fair pulls), build an XI, and fight 1v1 duels simulated server-side from the cards' real tournament stats, with stakes escrowed on-chain. After each match, TxLINE data buffs or nerfs the cards involved.
The originality is the pairing: not a dashboard about the data — a game made of it.
The backend (api/) is a single event-driven NestJS application built around
@nestjs/event-emitter and a two-state emission contract. One feed comes in; a typed event bus
fans it to every consumer; a socket.io gateway broadcasts to the frontend. No microservices, no
message broker — the whole pipeline is in-process and easy to reason about.
TxLINE SSE (scores + odds)
│ fetch() stream · reconnect + resume (Last-Event-ID) · heartbeat
▼
api/src/txline ── ingest → mapper → normalizer ── emits typed domain events ─┐
│ keeps in-memory tournament state (scores, odds, stats, lineups) │
▼ ▼
EventEmitter2 bus *.during (confirmed=false → optimistic, animate now)
│ *.after (confirmed=true → authoritative, settle)
▼ │
listeners: live (odds/markets) · fantasy (attributes) · chain (betting │
│ settlement) · crowd · statistics · duel orchestration ◄─────────┘
▼
api/src/realtime ── socket.io gateway ── broadcasts both states ──► front/ (Next.js)
animate on .during · reconcile on .after
One feed, many consumers get the same event at the same instant → consistency between the Live view and Fantasy attribute engine. New listeners (crowd, narration, analytics) attach without touching the ingest path. A client joining mid-match receives the current in-memory tournament state on connect — no feed replay needed — then streams deltas from there.
One public repository, three independent apps — each installs, builds, and deploys on its own; no shared packages, no cross-imports.
hatrick/
├── api/ NestJS — TxLINE ingest, event bus, WebSocket gateway, chain clients
│ └── src/
│ ├── txline/ SSE ingest, mapper, normalizer, in-memory tournament state
│ ├── realtime/ socket.io gateway (broadcasts *.during / *.after)
│ ├── events/ shared enums (event names, actions, markets) + typed DTOs
│ ├── live/ odds/market projection + in-match bet intake
│ ├── fantasy/ packs, XI, duel simulation, attribute engine
│ ├── chain/ Solana clients + services (betting, duels, packs, provably-fair)
│ ├── crowd/ chat + social intake, ranked speech-balloon stream
│ └── users · wallet · store · market · room · common
├── front/ Next.js (App Router) — Zustand, React Query, services/, canvas match engine
├── contracts/ Anchor 0.31.1 — four on-chain programs (betting, fantasy, packs, provably-fair)
└── docs/ this document · submission.md · txline-provider.md · architecture.md
Every data request carries two headers:
Authorization: Bearer <guest JWT>— fetched live viaPOST /auth/guest/start(valid ~30 days, auto-refreshed on 401).X-Api-Token: <api token>— the free-tier API token obtained viaPOST /api/token/activateafter an on-chainsubscribetransaction to the TxLINE devnet program (the World Cup service level is free — atransferCheckedof 0 TxL).
Gotcha we learned: the host is the network selector. Devnet auth, activation, and data must all
use https://txline-dev.txodds.com; mixing hosts fails silently with a 30-second 504.
Verified against the running code — this is the complete list.
| Method · Endpoint | What it carries | How Hatrick uses it |
|---|---|---|
POST /auth/guest/start |
guest JWT | live auth, auto-refresh |
POST /api/token/activate |
API token (after on-chain subscribe) | provisions the X-Api-Token sent on every call |
GET /api/scores/stream (SSE) |
live events: goals, cards, corners, possession-state, lineups, clock | the heartbeat — drives the 2D arena, score, play-by-play |
GET /api/odds/stream (SSE) |
TxODDS consensus odds (1X2 / match-winner, over-under) | odds board + bet slip; the price you bet is the feed's price |
GET /api/fixtures/snapshot |
fixtures (optional ?startEpochDay=) |
group table, upcoming matches, replay catalog, id→name maps |
GET /api/scores/snapshot/{fixtureId} |
latest score-event snapshot per action | authoritative score/stats + late-join backfill |
GET /api/odds/snapshot/{fixtureId} |
latest odds (optional ?asOf= point-in-time) |
pre-match odds where the live stream is empty |
GET /api/scores/updates/{epochDay}/{hour}/{interval} |
historical 5-min interval of score events | replay of finished matches |
GET /api/odds/updates/{epochDay}/{hour}/{interval} |
historical 5-min interval of odds | replay odds board |
On trustless verification (honest note). We do not consume TxLINE's
/api/scores/stat-validationendpoint or itsvalidate_statCPI — verifying the feed's own Merkle proofs on-chain is on our roadmap, not in this build. Ourhattrick_bettingprogram settles via its own oracle-signed Merkle of results — that's our settlement mechanism, independent of TxLINE's validation endpoint. The organizers confirmedvalidate_statis recommended, not required, and is aimed at the Prediction Markets & Settlement track; a consumer app that settles from the authoritative confirmed result is in scope. We call this out so the claim stays honest.
The feed's real value — and where most of the engineering went — is turning a terse, quirky data stream into something correct enough to move money and playable enough to watch. The traps we solved (all verified against the live API):
- The wire uses Capitalized keys (
FixtureId,Action,Confirmed,Score,Stats) — not the lowercase shape the public schema implies. A single defensive mapper adapts wire → internal event, tolerates both casings, never throws, and logs unknown actions once as a drift watch. - Score comes from the
Scoreobject, never by countinggoalactions. A goal is emitted once unconfirmed, re-confirmed twice, and can be reversed viaaction_discarded— counting double-counts.Score.ParticipantN.Total.Goalsis authoritative and sparse (absent key = 0). - Full-time is the
game_finalisedaction, notGameState(which can read"scheduled"all match). We surface it as full-time so settlement fires. - Settle 1X2 / Over-Under on regulation (H1 + H2), not
Total—Totalincludes extra time, so a knockout decided in ET would settle standard markets wrong. We derive and carry the regulation score separately; shootout goals live isolated and never contaminate the result.
The feed emits a handful of discrete rows per minute — perfect truth for settlement, not enough motion for a continuous pitch. Between rows we run a gap-filler: it derives a possession lean + tempo from the stats the feed does carry (shots, shots-on-target, corners, possession-family events, fouls, offsides) and drives autonomous connective play — dribbles, passes, ball-steals, never a fabricated shot. Any real event always overrides it. What's on screen between rows is still a function of real data, not fiction.
Events carry playerId only; there is no queryable roster endpoint. The single source of a name in
the whole feed is the action=lineups frame, embedded in the scores stream ~40 min before kickoff. So
we pre-scan history backward to before kickoff, reconstruct a playerId → {name, shirt} map, and join
it onto every event — server-side for replay, and the same join again on the client for live.
The feed totals only goals, corners, and cards. Shots-on-target, fouls, and offsides are tallied client/server-side from the discrete rows (deduped by a stable per-event sequence, so a during/after pair isn't double-counted). Possession has no percentage in the feed, so we derive a territorial lean from the stats it does carry. A mid-match joiner still gets correct running totals — we replay the snapshot to rebuild them.
Because the 104 group-stage matches end before judging, the demo replays real recorded TxLINE
history through the exact live pipeline. Historical 5-minute intervals feed a client-side clock
(useReplayClock): play/pause/restart, speed switchable live from 1× to 8×, key-event markers on the
bar. Every event, name, and score on screen is real feed data — the replay path is byte-for-byte the
same normalizer the live path uses.
State is chain-authoritative with a Postgres mirror: the source of truth lives on-chain, the DB mirrors it for fast reads and to gate the UI. Four independent Anchor programs (Anchor 0.31.1):
| Program | ID (devnet) | Role |
|---|---|---|
hattrick_betting |
GhGZEarksLDfSDHg98PKm2xZuHJjSE8Zujd9mBB8zqXc |
Pari-mutuel match markets; positions in on-chain pools; oracle-signed settlement |
hattrick_fantasy |
67QvSGTacjzuQFJujCREMWtRVonCLypqKmK2dszVdDwz |
1v1 duel USDC escrow; both players deposit; settlement gated on seed reveal |
hattrick_packs |
BtgAtofxN8RJxaC824XUeamCZL13b63u2YaVTvhVGfFo |
Provably-fair card packs; mints Metaplex Core NFTs; layer controls vaults |
hattrick_provably_fair |
DsWff3P22AsnXAk7EthHNythzDWLc7bg5JS3aPpNjsLQ |
Commit-reveal seed oracle; gates fantasy settlement and pack fulfillment on is_revealed |
Email login through Privy creates an embedded Solana wallet invisibly — no extension, no seed phrase. A faucet mints test USDC on devnet in one tap (the platform wallet is the mint authority). Phantom also works for users who prefer it.
Place bet → backend builds an unsigned place_position tx → user signs → confirmed on-chain → DB
mirror updated. On the authoritative full-time result (match-end.after), the backend settles the
market — no human approves a payout.
- Backend (layer-signed)
initialize_duel— creates the escrow — andcommit_seedto the provably-fair program, before kickoff. - Each player signs their own
deposit_stakeinto the escrow. - At full time the seed is revealed on-chain and
settle_duelruns — gated onis_revealed, so the result cannot be manipulated after the commitment. Not even we can rig it.
Pack purchase commits a seed to provably-fair (before opening) and mints Metaplex Core NFTs —
serial-numbered, capped supply. Fulfillment is gated on the seed reveal. Card ownership and metadata
live on-chain (OwnedCard.assetMint mirrors it in the DB).
Every pack buy, bet, and settlement surfaces a "View on Solscan" link (?cluster=devnet). A
single flag CHAIN_ENABLED=false routes the same flows through a DB test-USDC ledger — a safe
fallback if anything on-chain misbehaves during judging, with no other change needed.
Terminology note: the cards are Metaplex Core NFTs, not compressed "cNFTs" — the
hattrick_packsprogram mints viampl_core.
Open packs to reveal cards (fixed ratings, nation, rarity), build an XI, then challenge another real user (direct challenge or ranked queue; a CPU fallback keeps the demo alive if no human is waiting). Set a stake, both sides escrow on-chain, the duel runs on both screens.
The simulation is server-authoritative: each card carries its player's latest real stats
(loadSquadPayload), the two squad payloads go into the sim, and the outcome is deterministic,
seeded from the on-chain seed commitment (the same replay log animates the arena on both devices).
Squad rating biases chance probability — a stronger XI earns more and better chances. No client can
influence the result.
After real matches, cards move based on the feed:
- Nation layer — a fixture's authoritative final score buffs/nerfs every card of that nation (±1–3 form).
- Player layer — the real scorers from the match timeline react on top of the nation swing.
- Matching — real named players match by name; fictional cards resolve by nation flag + shirt number + position (never by name), which is compliance-safe and works for any card.
- Upcoming impact — a forward-looking panel maps real scheduled fixtures to the nations you own and shows the projected swing per card, so you can see which cards are on the line before you set your XI.
The home is a live match dashboard, every widget sourced from the feed:
| Widget | Source |
|---|---|
| Scorebar (score + clock) | scores SSE |
| Events feed (full play-by-play with names) | discrete feed events + lineups; replay uses the history endpoint, live uses the socket + Redis backfill |
| Team stats (shots on target, fouls, offsides, possession) | totals computed from discrete events; possession derived |
| Lineups | official lineups from the feed |
| Odds board + bet slip | odds SSE (1X2 / over-under) |
| Upcoming odds | odds SSE merged with asOf snapshots |
| My bets | locked odds vs live price; position mirrored from chain |
| Group table | fixtures snapshot |
The 2D arena itself is driven by the feed's events — shots, corners, goals, and cards become scenes in the engine.
| Layer | Stack |
|---|---|
| Frontend | Next.js (App Router) · React · Tailwind · shadcn/ui · Zustand (state) · React Query (fetching) · framework-free canvas 2D match engine · Privy (email → embedded wallet) |
| Backend | NestJS · @nestjs/event-emitter (in-process event bus) · @nestjs/websockets + socket.io · Prisma · Postgres · Redis (ioredis) · Zod DTOs · TypeScript |
| Blockchain | Solana devnet · Anchor 0.31.1 (4 programs) · Metaplex Core · @solana/web3.js · SPL tokens |
| Data | TxLINE (SSE scores + odds, REST snapshots, historical intervals) |
Frontend never calls fetch/axios directly from components — all network access lives in
src/services/. All match/market/game constants are enums, never magic strings. Files are kept under
600 lines.
- Live: hatrick.xyz. Sign in with an email, tap Get test funds, buy the
starter pack, then watch/bet the live match on the home or duel from
/duelists. In a restricted region, append?geo=demo. - Local:
api/→docker compose up -d && npm i && npm run prisma:deploy && npm run start:dev;front/→npm i && npm run dev(NEXT_PUBLIC_USE_MOCK=false). Envs are documented in each app's.env.example.
- No FIFA IP — no official branding, logos, marks, or implied affiliation. Player names arrive as factual TxLINE data (OK to display); card art is stylized and does not match real players (no likeness).
- 18+ age gate — blocking, non-dismissible.
- Self-exclusion + stake limits — in Settings, gating both the UI and the API.
- Geo-blocking — betting surfaces are blocked in restricted jurisdictions (judges append
?geo=demoto bypass). - Devnet only — fictitious test tokens; no real money moves. Play-money disclaimer in the footer.
- Essential-only cookies — no analytics gate.
We would rather show the seams than oversell.
| ✅ Real (live / on-chain) | 🎭 Simulated (for the demo) |
|---|---|
| TxLINE SSE ingest — scores + odds | Crowd speech balloons & social picks |
| On-chain TxLINE token activation (devnet) | CPU opponent fallback when no human is queued |
| 4 Anchor programs — betting escrow, duel escrow, packs/mint, provably-fair | — |
Betting markets + settlement from *.after |
— |
| Fantasy 1v1 vs real users (direct challenge + ranked queue) | — |
| Match replay through the real pipeline | — |
Known gaps: the card marketplace settles off-chain (a DB ledger; the mint and all money flows are on-chain); synthetic betting markets beyond 1X2 / over-under aren't derived yet; ambient crowd reactions are front-simulated; and because the feed carries no possession percentage, the pitch's territorial lean is derived from the stats TxLINE does provide.
| Name | Role | GitHub |
|---|---|---|
| Kauã Miguel | Owner | @Kc1t |
| Deborah Pavanelli Colicchio | Member | @eudehh |
| Pedro Henrique | Member | @opedrooz |