Python
XGBoost
LightGBM
Machine Learning
.NET
C#
Flutter
Node.js
Express
MongoDB
TypeScript
Next.js
React
Firebase
Cloudflare Workers
Docker
OpenAPI
YouTube API

Score Buddy is a football prediction and match analysis app I built for iOS and Android. It covers 50+ leagues and brings predictions, xG, form, head-to-head records, standings, match statistics, and match video into one place.

The part I wanted to own was the prediction layer itself. Instead of putting an interface around a third-party prediction feed, I built an in-house xG and forecasting pipeline, then added Buddy Insights to cut the full model output down to the calls worth showing.

The app runs on a larger system: Python data and model workers, a .NET video service that links highlights and previews to fixtures, a dedicated Node.js API, an operations dashboard, multilingual notifications, and a separate public site at scorebuddy.pro, localized into 15 languages with full RTL support for Arabic.

Score Buddy started as a football stats and prediction product, but most of the interesting work ended up happening before anything reaches the screen.

I did not want to buy an xG or prediction feed and put a nicer interface on top of it. Score Buddy builds its own expected-goals estimates from raw match statistics, then uses those estimates together with form, team strength, standings, recent performance, and other pre-match data to produce probability-based forecasts. The current modelling stack uses XGBoost and LightGBM alongside statistical calibration rather than treating one model as the answer to every market.

That distinction matters in the app because a match is not reduced to a single home/draw/away guess. A match page shows the main result probabilities next to expected goals, goal totals, both-teams-to-score, corners, cards, team form, head-to-head history, season comparisons, standings, and a PageRank-style network strength score built from who beat whom. The aim is to let someone glance at the prediction first and only dig into the supporting numbers when they want to.

Buddy Insights came from a fairly practical problem: generating probabilities is easy compared with deciding which ones are actually worth putting in front of a person. The curation layer removes redundant and conflicting candidates, ranks what remains, and produces a much smaller shortlist for each match. Those insights are resolved after the match as well, so I can measure the selection logic on finished fixtures instead of judging it by a few good-looking examples.

Match video, in .NET

Every match page also carries video: previews and press material before kickoff, highlights and reactions after the final whistle. That feed comes from a dedicated video discovery service written in C# on .NET 10, running as a hosted worker in its own container next to the rest of the system.

The service watches around 90 YouTube channels from clubs, competitions, and broadcasters. Every 30 minutes it reads the channels that cover upcoming or recent fixtures, and a slower sweep catches up on every channel twice a day. Each upload is linked at the most specific level it can prove: a fixture, otherwise a team, otherwise a league. A video only links when its own title or description points at the match, never because of which channel posted it.

Matching football video sounds simple until it meets real titles. Names arrive in a dozen languages, in hashtags, in all caps, and in styled Unicode letters. "Real" is not Real Salt Lake on a channel that also covers Real Madrid, "Ligue Europa" is not the club Europa, and "Bundesliga 2" is a different division. Reserve sides, youth and women's football, and other sports have to link to nothing, because the fixtures in the database are men's senior football. The matcher handles this with general rules instead of per-case patches: names must be written as names, short names are derived from the data only where no rival team shares them, Wikidata aliases are graded by trust, and channels learn which leagues they really cover from what they publish. Before shipping the current matcher I hand-audited two days of uploads from every channel; fixture links came out at 99.7% precision.

Each stored video also gets a precomputed feed block with time bucket, importance, language, region restrictions, and embed eligibility, so the Node.js API can serve and rank the video feed without doing any matching work at request time.

The service is built to be safe to restart at any moment. A channel's read cursor only moves forward once every page of its uploads was read and every match was stored, so a failed run simply picks up from the previous cursor. Configuration is validated at startup, curated name lists refuse to load without a stated reason and evidence for each entry, and every link records the matcher version that made it. Maintenance commands such as re-matching old videos are explicit, idempotent, and guarded against running alongside the live worker. CI publishes versioned container images on every tagged release.

Workers, API and dashboard

The model code runs separately from the public API. Python workers handle data synchronization, feature generation, training, backfills, scheduled inference, and other long-running jobs. They pull work from a MongoDB-backed queue, claim each job atomically so two workers never run the same one, and report heartbeats so a dead worker is visible rather than silently missing. Trained model artifacts are versioned per run and stored in Cloudflare R2.

The Node.js API serves the mobile client and also acts as the control surface for the system. The public API is stateless: there are no sessions on its routes, so any instance can answer any request. A Cloudflare Worker in front of it verifies RS256-signed access tokens at the edge and caches responses per language with stale-while-revalidate, so bursts of identical reads are absorbed before they reach the origin. The OpenAPI contract is generated from typed schemas in code and checked for coverage of every route, and contract tests run in CI against a throwaway database.

I built an internal dashboard around it for leagues, fixtures, teams, model runs, worker health, jobs, pipelines, Buddy Insights, videos and their sources, and notifications. That became important once Score Buddy moved from something I could run manually into something expected to refresh itself throughout the day.

Alongside it runs Integrity Watch, a separate monitor that continuously checks the live data for things that should not happen: a match that should have kicked off but has not, an upcoming fixture missing its pre-match statistics, a standings table that does not add up. It keeps its findings in a separate database, runs fast and deep scan tiers, and uses a leader lease so a standby can take over without two monitors scanning at once. Its dashboard also answers the harder question of whether the monitor itself can be trusted, by showing which checks actually reached a conclusion and which only stayed quiet.

Mobile app and website

The mobile client is built with Flutter. There is no required account: users can open the app, browse upcoming fixtures, open a match, and move between the prediction, the underlying stats, and the match video without creating a profile first. Firebase handles multilingual push notifications, while the API keeps the mobile payloads intentionally smaller than the much denser data available internally.

I built scorebuddy.pro as a separate Next.js application rather than making the marketing site another route inside the backend. It has its own prediction-card components, product walkthrough, screenshots, legal pages, store routing, SEO metadata, sitemap and structured data. The site currently ships in 15 languages, with locale-specific URLs and metadata and a proper RTL layout for Arabic rather than a translated LTR page.

Score Buddy is now released on iOS and Android. A user sees a match card, a prediction, the numbers behind it, and the video that goes with it; the data work, model runs, video matching, evaluation, and operations needed to keep all of that fresh stay out of the way.