Tactiq
AI football match-analysis platform. Designed, built and operated solo.
Pre-match and live tactical analysis, outcome probabilities and supporting statistics for individual fixtures, aimed at fans who want reasoning rather than a single number. One Flutter codebase across iOS, iPadOS, Android and macOS. 32 languages. Subscriptions priced across 175 countries using World Bank purchasing-power data. A free tier plus three paid tiers, settled server-side. Most engineering effort sits in the prediction and language layers rather than the interface, because the analysis is the product.
A layered pipeline rather than a single model
Strength, scoreline distribution, recent form, qualification scenarios and match context are each answered by their own component rather than by one model that does everything.
- Separately measurable. The point of splitting them is not accuracy for its own sake. A wrong answer has to be traceable to the part that produced it, and that part has to be changeable without moving the others.
- Two estimators, compared rather than blended. A statistical engine and the language model each produce an implied view of the same fixture, and the system compares them instead of averaging them. Every fixture carries the verdict of that comparison, because disagreement is information: agreement raises trust and disagreement earns caution, where a blend would have quietly manufactured false precision out of two answers that did not agree.
- Bounded outputs. Every probability the system produces is bounded before it leaves, so that one extreme input cannot produce a distribution no reader would believe. Sanity is enforced at the boundary rather than hoped for from the model.
- Directional priors. Recent form enters as a direction of travel rather than an absolute level, because a mid-table team improving and a strong team declining are different bets that an average hides.
Built in-house, in pure Python
Scikit-learn, gradient boosting and deep-learning runtimes were deliberately not adopted. Serverless functions need small memory footprints and fast cold starts, the models must stay interpretable so a wrong result traces to a cause, and at this data scale a transparent model correctable per league beats an opaque one that is hard to debug when a single league drifts.
- Online rating updates. Team ratings update from every graded result with no offline retraining step, so the strength model moves with recent reality rather than a static seed.
- Calibration feedback loop. Every past prediction is graded against its actual outcome, scored with a Brier metric and a reliability breakdown by confidence band, and fed back into both probability shaping and the drift thresholds that raise an alarm.
- Clustering for thin history. Where two teams have played each other too rarely to say anything, similar teams inform the prior instead. Written by hand rather than pulled from a library, for the same footprint and interpretability reasons as everything else in this layer.
The learning stays legible. Every number a user sees traces to a rating, a calibrated probability or a cluster, each of which can be inspected and corrected.
Two model tiers, and nothing trusted on the way out
Amazon Bedrock with two model tiers: a cheaper model via cross-region inference profiles for the free tier, a stronger model on the global profile for paid tiers. Output tokens dominate cost, so the stronger model is reserved for users whose spend justifies it.
- Server-authoritative grounding. Team, player and tournament names and statistics are never read from model output. They are injected from verified data after generation and the model is instructed to treat them as fixed. The model structurally biases toward famous clubs and invents plausible but wrong entities, so identity is treated as data rather than as something the model may decide.
- Confidence floor. Generated probabilities are bounded so the model cannot express certainty the data does not support. An overconfident wrong call damages trust more than a hedged one.
- Structured output contracts. A fixed JSON schema, validated before use. A result failing validation or post-checks is never written to cache, because a bad cached result is served to every later reader of that key, turning one error into many.
- Language guard. Generated prose is classified against the requested language, using script dominance for non-Latin scripts and stopword and diacritic scoring for Latin ones. On mismatch it regenerates once at temperature zero with a correction directive, and if it still fails it is not cached. For a 32-language product, answering in the wrong language is a correctness failure rather than a cosmetic one.
- Fallback and decline. A status gate blocks analysis on finished match states and during penalty shootouts. Until the provider reports a fixture finished, any minute of it can be analysed; there is no cut-off before the whistle. Guard failures fail open on the user path but fail closed on security and quota: a guard error should never silently deny a paying user, and an auth error must never grant free access.
The system grades its own past work
A weekly monitor, scheduled Sunday at 22:00 UTC, joins graded fixtures back to their predictions and reports hit rate for the rating engine against the language model, broken down per league, per tier and per confidence band, alongside calibration error, latency and null rate. Results are written to dedicated calibration record and summary tables rather than logs, so a historical read is always available. The language model runs as a live baseline rather than an absolute target, because beating a competent baseline is the honest test of whether the extra machinery earns its place.
Drift detection uses calibration error and per-league accuracy, with minimum sample sizes attached to every alarm so a thin window cannot trigger one. Hit-rate floors are defined for the overall and free-tier paths; when one is breached the system raises an alarm and the rollback is performed from a runbook. Detection is automated, the response is not, and that distinction is documented rather than blurred.
An automated response is designed and staged rather than live. Its first stage deliberately holds no write authority, because a system that can revert itself on a signal it may have measured badly is a system that can take itself down on a bad week.
A worked example. A calibration alarm fired on a thin off-season sample of around eighty graded events. I chose not to refit, because the high-confidence bucket held only ten events. Two weeks later, on roughly four hundred events, calibration measured as good and the restraint was confirmed correct.
Verify sample size before acting on an alarm. A small sample produces alarms that look like model failure and are not.
Enriched where it is worth it
The competition catalogue runs to four figures, and a featured subset receives deeper enrichment. Full-depth enrichment everywhere would exceed provider quotas on leagues almost nobody opens, so depth is spent where it is read rather than spread evenly to look thorough.
Normalisation runs at both write and read time. Group-stage standings, for instance, are de-duplicated by team because the provider doubles rows once results arrive, and the read path cleans stale shapes so a fix takes effect without waiting for a re-sync. Freshness is matched to volatility rather than set to one interval: what changes by the minute is polled by the minute, and what changes twice a season is not.
Missing fields resolve to safe defaults or to an empty state the interface renders honestly, and provider schema changes are absorbed by defensive normalisation, because a silently changed field upstream should degrade to a blank and never to a fabricated value.
Three regions, active-active, and one deliberate exception
One hundred and seventy-six Lambda functions in production across three regions, behind latency-based routing with sixteen Route 53 health checks. Forty-three distinct functions are replicated to all three in an active-active layout; the remainder are deliberately single-region scheduled and backfill jobs, because running a cron in three regions produces three writers competing over the same state. The distribution is deliberate rather than symmetric: us-east-1 carries eighty and runs the batch, calibration and tournament workloads, while eu-central-1 and ap-northeast-1 carry forty-four and fifty-two and serve the user path. On the analysis path this means two regions can fail and the third still serves. Runtime is split roughly evenly between Python 3.12 and Node.js 24. The function estate is high by design: prediction, language, calibration and data layers are separate functions so each deploys, tests and rolls back independently, which matters more than minimising surface when correctness sits on the revenue path.
State lives across forty-one DynamoDB tables. Thirty-six replicate to all three regions as Global Tables. Five are held outside global replication deliberately: paid entitlement and refresh tokens sit in a single-region authority with one writer using atomic conditional writes keyed on the last event timestamp, then propagated outward to the multi-region user tables. Propagation and reconciliation are two different jobs and worth naming separately: propagation is event-driven and settles in minutes, while reconciliation is a weekly pass comparing the ledger against store exports.
Entitlement does not inherit the resilience of the analysis path, and the trade is deliberate. The authority table has no replica. If its region is lost, existing paid access carries on being served from the multi-region user tables that propagation has already written, while new tier changes are not accepted until the region returns. Nobody is silently granted access they did not buy and nobody loses access they did, which is the direction this is supposed to fail in.
Paid entitlement is derived state rather than a system of record. The upstream truth about who has paid is held by the stores, not by this table, so losing the region is a rebuild rather than a failover: the reconciliation job that runs against store exports can construct the table from nothing. That the weekly check runs against the stores rather than against our own history is the practical consequence of the same fact. If the store is the source of truth, drift has to be measured against it. The mechanism exists. A measured recovery time does not, and this is not claimed as a disaster recovery plan.
Every request passes three fail-closed checks before any logic runs: app attestation, with Play Integrity on Android and App Attest on Apple, an HMAC signature over the request, and a signed token. Infrastructure is Terraform, with CI running syntax, tests and localisation completeness gates on every push.
Multi-region last-writer-wins can silently drop a tier change, and a customer losing paid access is the failure the whole design exists to avoid.
Settled server-side, never trusted from the device
Four subscription products plus promotional grants. Usage is metered as a per-tier daily allowance rather than a purchasable balance, decremented by atomic conditional write and only on successful generation, because charging for a failed request is both wrong and a support cost.
A store event reaches a webhook, updates the single-region authority, and propagates to the multi-region user tables, so the client displays state it cannot itself grant. Purchases span the Apple ecosystem, where iOS and iPad share a purchase and macOS is a separate Mac App Store product, and Google Play, merged into one cross-platform entitlement. Plan changes follow store semantics, upgrades immediate and downgrades deferred to period end, with refunds and chargebacks on the same event path and the weekly reconciliation against store exports catching drift.
Prices are set per country from purchasing-power data rather than by flat currency conversion, because a flat price is unaffordable in some markets and undersells in others.
The ceiling was set before the scaling
Two hard budget caps sit on the account with alarms attached: three hundred dollars a month for Bedrock inference and five hundred for everything else. They are not forecasts. Setting the ceiling first forced the two-tier model routing, the caching rules and the decision about which requests justify a stronger model.
A generative product without a ceiling discovers its unit economics in a billing email.
32 languages, no machine translation in delivered strings
A football audience notices wrong terminology immediately, and a mistranslated tactical term reads as incompetence. Three surfaces are managed separately: in-app strings with a CI completeness gate so no language ships partial, store listings, and legal texts. The runtime language guard closes the loop, since native static strings do not help if the model answers in English.
Built once, shipped everywhere, reverted by hand
Infrastructure is Terraform and every push runs the gates before anything moves: syntax, tests, and a localisation completeness check that refuses to ship a language with holes in it. The artifact is built once and the same bytes go to all three regions, because a region running slightly different code is a failure that only shows up as an inconsistency nobody can reproduce.
Parity is checked after the deploy rather than assumed, and a drift pass compares what is actually running against what is declared. Rollback is a runbook step. A breached hit-rate floor is what raises the alarm; a person reads it and decides, and the reversion is a documented procedure rather than something the system performs on its own.
Ahead of the business logic, not bolted on after
Every request passes attestation, request signing and authorisation before it reaches anything that does work, and each of those fails closed. Cloud permissions are least privilege by default rather than by review. The data model stores what a feature needs and not what might one day be useful.
Export and erasure are product features rather than support tickets, designed in from the first release. Retrofitting either one into a system that was not expecting them is how a small compliance question becomes a quarter of engineering time.
An isolated fleet, removable without touching the core
A high-traffic seasonal event needed to be built, tuned and later removed without touching the main system, so it was built as a separate fleet. It carried its own handling for the ways a tournament differs from a league season, and a controlled live-analysis setting where many users repeatedly analysed the same fixtures, which produced unusually dense calibration data that fed back into the main engine.
The measurable outcome was a per-league accuracy and calibration record from real tournament traffic, and a set of improvements validated there before being considered for the wider league season.
Written by Murat Can Ümit, who designed, built and operates all of the above. The rest of the work, and a contact form, are on the main site.