Final Third
Technical documentation — TxODDS World Cup Hackathon, Track 2: Consumer & Fan Experiences
Core Idea
Live soccer feeds carry the tension of the match as structured events — possession, danger phases, escalation. Final Third collapses overlapping danger signals into a single prediction round: when a team’s pressure hits danger or high danger, you get ~4 seconds to call ATTACK (this pressure produces a shot, corner, or goal) or DEFENSE (the defense clears it). The phase plays out entirely event-driven — 2 to 58 seconds, never clock-driven — and your streak lives or dies on whether you read the pressure correctly.
Every call is graded against the verified TxLINE scores feed. No polling, no manual scoring, no ambiguity about who called what when. The relay server is the single source of truth for the round lifecycle and the grade. During quiet spells or half-time, a secondary “market call” round (higher/lower on a live odds market) keeps the screen alive.
The current version is free to play with no stakes. The Solana wallet connection exists purely for persistent leaderboard identity — no transactions are signed.
Architecture
┌──────────────────────────────────────────┐
│ Relay (Node/TS) │
│ persistent process, not serverless │
│ │
TxLINE ──────► │ FeedSource ──► RoundEngine ──► GameRoom │ ──► Browser
(devnet) │ (Live or (pure, (SSE + │ (Next.js)
│ Replay) deterministic) REST) │
│ │ │
│ ▼ │
│ LeaderboardStore │
│ (Redis, or in-memory) │
└──────────────────────────────────────────┘FeedSource — A single interface with two implementations: LiveSource (SSE from /api/scores/stream, filtered by FixtureId) and ReplaySource (recorded JSONL replayed at configurable speed). Everything downstream is agnostic to which is running. This is the dev environment, demo engine, and post-deadline survival plan in one abstraction.
RoundEngine — Pure, deterministic round lifecycle: danger trigger → 4s lock window → event-driven resolution → 60s timeout backstop. No I/O, no timers — driven by explicit timestamps, unit-tested against recorded fixtures.
GameRoom — Owns the FeedSource → RoundEngine pipeline, fans events to connected browsers over SSE, grades guesses server-side, persists streaks to Redis (or in-memory).
Web client — Next.js + React + TypeScript + Tailwind + Framer Motion. Solana wallet-connect sign-up (pubkey = identity). Server-rendered PNG share cards via Satori / next/og.
Business Highlights
Primary: B2B white-label
White-label to sportsbook and media operators as an engagement and acquisition product. TxODDS’s customers are exactly the operators who want a compliant, low-friction way to keep users engaged during a match. The core loop — collapse a live feed into a short, gradeable prediction — is generic to any sport with structured possession/pressure events. The free-to-play version serves as a natural top-of-funnel, with the option to layer in stakes for operators who want it.
Secondary: Sponsored match rooms
Brand-sponsored themed rooms for marquee fixtures (broadcaster, telco, sponsor). Same mechanic, sponsor branding on the theme layer — no product changes required since team theming is already config-driven.
Technical Highlights
The time-domain problem
Replayed historical Ts values are real match-time milliseconds — not wall-clock. Replaying at 20x for dev compresses a 4-second lock window to 200ms of real time. FeedSource exposes both now() (feed-domain time, used by the engine) and realMsUntil(ts) (wall-clock ms remaining, used for the client countdown). The client only ever diffs against lockDeadlineRealMs, never raw lockDeadlineTs — that decoupling is what lets the same client code work at any replay speed.
Server-authoritative grading
The round lifecycle, guess timestamps, and grading are entirely server-side. The relay records submittedTs using its own clock at receipt time, and gradeGuess() (a pure function of Round + PlayerGuess) is the only thing that decides WIN/LOSS/PASS/VOID. The client never grades its own guess — it just renders whatever the server sends.
Reliability guard
The TxLINE feed can emit suspend / unreliable events signaling data-quality interruptions. The round engine pauses new-round triggers during these windows and auto-clears after 10 seconds if no explicit “reliable” signal arrives — matching the observed real-data behavior where suspend fires as a bare momentary event.
TxLINE Endpoints Used
All requests against https://txline-dev.txodds.com. Auth: Authorization: Bearer <jwt> + X-Api-Token: <token> on every request. The API token is server-side only and never reaches the browser.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /auth/guest/start | Guest JWT acquisition. Short-lived, refreshed automatically on 401. |
| GET | /api/scores/historical/{fixtureId} | Historical replay source. Returns the full match as an SSE-formatted string (not a real stream). Used to record fixtures for offline replay. |
| GET | /api/scores/stream | Live SSE scores stream. Global across all live fixtures — filtered by FixtureId client-side. Carries bare keepalive pings between events. |
| GET | /api/odds/stream | Live odds SSE stream. Used for lull-fallback rounds (higher/lower market calls during quiet periods). |
| GET | /api/odds/snapshot/{fixtureId} | Point-in-time odds snapshot. Fallback for when stream reconnection is needed. |
Round Lifecycle
- Trigger —
danger_possessionorhigh_danger_possessionfor a team, with no round currently open. Overlapping danger signals collapse into the same round. - Lock window — 4 seconds from trigger (real wall-clock, translated from the feed’s time domain via
FeedSource.realMsUntil()). - Resolution — Event-driven. First matching confirmed event wins: goal/penalty (jackpot), shot/corner (attack win), free kick by attacker (minor attack win), safe possession/goal kick by defender (defense win).
- Timeout backstop — 60s with no resolution voids the round. No streak change.
- Reliability guard — Feed suspend/unreliable events pause new-round triggers until cleared. Doesn’t affect an already-open round’s resolution.
API Observations
Notes from building against real fixture data (France–Spain semi, England–Argentina semi — ~2,000 messages total):
Score is keyed Participant1/Participant2 (not “1”/“2”), nested by period (H1/HT/H2/Total) before Goals/Corners/YellowCards. Clock is { Running: boolean, Seconds: number }, not a formatted string.
/api/scores/stream carries events for every live fixture simultaneously. Consumer must filter by FixtureId. Keepalive pings use seconds (not ms) for their Ts field.
Fixtures that just finished return empty responses. The documented 6h–2wk window has an undocumented processing delay at the start.
Spec says they carry Unreliable/Reliable flags. Real data shows neither — just a bare {Action:"suspend",Confirmed:true} with normal events resuming immediately after.
Running Locally
npm install # installs shared + relay npm run install:web # installs the web package (bun) npm run build:shared # Replay mode (default, no credentials needed) npm run dev:relay # starts relay on :4000 npm run dev:web # starts Next.js on :3000 # Live mode (needs TXLINE_API_TOKEN in packages/relay/.env) FEED_MODE=live LIVE_FIXTURE_ID=<id> npm run dev:relay