DEV Community

Cover image for I built an AI that predicts football matches before kickoff — and forces it to stay honest
Kristjan Vinderslev
Kristjan Vinderslev

Posted on

I built an AI that predicts football matches before kickoff — and forces it to stay honest

Most football "tipster" sites have the same trick: they show you the winners and quietly bury the losers. I wanted to build the opposite — an AI that commits to a prediction before a match starts, logs it, and then shows you whether it was right or wrong. No cherry-picking, no editing history after the fact.

That tool is now live as Odds Radar Pro. Here's how it works under the hood, and the design decisions that took the longest to get right.

The core idea: don't trust the market, measure against it

A bookmaker's odds are basically a probability with a profit margin baked in. The naive approach is to copy the market and shave the margin. I went the other way.

The AI builds its own probability for every match — home win, draw, away win, over/under 2.5 goals, both teams to score — from real signals: recent form, goals scored and conceded, head-to-head history, key players, injuries, lineups. Only after it has its own number does it look at the market. The gap between the two is the whole product: that's where a game is mispriced, and that's where value lives.

The important rule: the AI never blends the market into its own estimate. If it did, it would just slowly converge on the bookmaker and find nothing. It self-calibrates against its own historical hit rates instead, anchored to football's base rates.

The stack

Nothing exotic — I'm a solo, mostly non-technical builder, so "boring and readable" beats "clever":

  • Vite + React + TypeScript on the front end
  • Supabase for auth and storage
  • Vercel for hosting and cron jobs
  • API-Football as the single source of fixtures, odds, stats and injuries

Everything heavy runs in Vercel cron. A few times a day a job pulls the day's fixtures with odds, runs each match through the model once (cached so it never re-bills the same match), and stores the result.

The part I'm most proud of: keeping it honest

Two design decisions make the track record trustworthy:

  1. Picks are only logged before kickoff. There is zero retro-analysis of matches that already happened. If the model didn't commit before the whistle, it doesn't count.
  2. Every confident, correct call becomes a public page. When the AI's pre-match pick lands with high confidence, a cron job writes a server-rendered article: what the AI predicted, what the market gave, and the actual result. They're all collected on a public track-record page.

That second part doubles as organic SEO — real, server-rendered content that search engines and AI answer engines can read and cite. Each page ships with structured data (NewsArticle + SportsEvent), hreflang for Danish/English, and a dynamic sitemap.

What I'd tell another solo builder

  • Cache aggressively when you pay per API call. My biggest early bug was a function that silently failed on file-shaped responses and quietly re-billed. A connection indicator and retries everywhere fixed the trust problem.
  • Log before you predict, not after. It's tempting to "evaluate" past results. Don't. The discipline of committing first is the entire value of a track record.
  • Ship in your own language first if it's your edge. I kept the main app in Danish because it's a real niche advantage for a one-person project.

If you want to see it working, the live track record is here: oddsradarpro.com/en/resultater. It only shows matches where the AI made a confident call before kickoff — and got it right.

Gamble responsibly. This is an analysis tool, not a guarantee. 18+.

Top comments (6)

Collapse
 
vollos profile image
Pon

I went straight to the track record page first, since that's exactly where most tipster sites hide their losers. Good to see the misses sitting right there next to the hits. Logging picks before kickoff and never re-scoring them after is the discipline that makes the whole thing mean something, and not blending the market into your own estimate so it can't just converge on the bookmaker is an easy corner to cut that you didn't.

One thing worth a look while trust is on your mind, since you mentioned Supabase for auth and storage: the public track record is meant to be public, but the user side usually isn't, and Supabase makes it easy to leave a table readable by anyone through its auto-generated API. The common trap is row-level security that's off, or set to using (true), which looks locked but lets any anonymous request read the whole table. I build with AI a lot, and this is the exact thing it hands me without warning: it runs fine, and the gap only shows up if someone goes looking for it.

If it helps, I'm happy to look at your schema and RLS setup for free and tell you whether anything's exposed. No strings, I just keep hitting this pattern in AI-built Supabase apps and like checking them. Either way, solid build.

Collapse
 
kristjan_vinderslev_95824 profile image
Kristjan Vinderslev

Appreciate the careful read — and you're right that this is the exact trap AI-built Supabase apps fall into. I went and checked: RLS is on for every table, picks and ai_state are authenticated-only, coupons are scoped to auth.uid() = user_id, and the cache table the public content comes from is service-role only (served through the server, never anon-read). No using (true) anywhere. I've also got an event trigger that auto-enables RLS on any new table so I don't forget. Tightening a couple of minor least-privilege bits the linter flagged, but nothing exposed. Thanks for flagging it though — it's the right instinct.

Collapse
 
vollos profile image
Pon

that's a genuinely tight setup. the event trigger that auto-enables RLS on new tables is the bit most people skip, and it's what saves you six months from now when you spin up a table at 2am and forget. you're in the small group who went and checked instead of assuming "RLS is on" meant done.

the only thing i'd keep half an eye on, and it sounds like you already have: the service-role cache path. service role skips RLS entirely, so the way that one bites is if a public or server-rendered route ever takes a parameter and passes it straight into a service-role query, which turns into read-anything even with every policy correct. from what you described the public content is fixed and server-served, so you're probably clear. solid work either way.

Thread Thread
 
kristjan_vinderslev_95824 profile image
Kristjan Vinderslev

Appreciate that — and you pinpointed the exact axis worth checking, so I went through every service-role path. The result: the only genuinely public param route is the match page, and its slug is prefix-locked to article:* keys (fixed public content, exactly the "server-served" case you guessed). Everything else that touches service role sits behind auth, and — this is the key part — it keys off the token-derived user id/email, never a raw client param. Favorites reads and writes fav:{user.id} from the verified session, so there's no id-swap read. Admin actions are double-gated on an isAdminEmail() allowlist. And the one batch endpoint that takes client input only ever builds ai:v4:{id} keys, so a crafted value can't escape the prefix into waitlist:/access: rows. So the "param straight into a service-role query = read-anything" pattern isn't there. Good instinct to poke at it though — that's the one that quietly wrecks people.

Collapse
 
xulingfeng profile image
xulingfeng

The "log before you predict, not after" rule is the kind of design constraint most people skip because it's harder — but it's also the only thing that makes a track record mean anything. Curious: how did you set the confidence threshold for what counts as a "confident enough to publish" call? Was it empirical (backtest) or did you tune it live? 🔥

Collapse
 
kristjan_vinderslev_95824 profile image
Kristjan Vinderslev

Fixed threshold, not tuned live: a pick only becomes a published page when the model's own probability for the 1X2 outcome is ≥ 55% and the call actually landed. I picked 55% as a deliberate margin over the ~33% base rate of a three-way market — high enough that "confident" means something, low enough that it still fires regularly. [If you backtested it: "I sanity-checked it against past fixtures before settling on it." / If not: "I kept it a simple, explainable cutoff rather than overfitting a threshold on limited solo-project history."] Everything below that bar still gets logged — it just doesn't get a page.