A scorekeeper that will not lock a court it cannot map — and a rules companion that cites the pack it read, or says plainly that it doesn't cover you.

The problem

An app that keeps the score is easy. One that admits it can't see the court is the hard part.

Point a phone at a court and the tempting product writes itself: it watches, it calls, it scores. The failure mode arrives on the tenth point, when the light shifts or somebody stands in front of the tripod, and the system has to choose between admitting it has lost the court and quietly carrying on with a mapping that no longer holds. Most carry on, because carrying on demonstrates better.

aiRef is built the other way round. Four corners on a screen do not make a court: the marked quadrilateral goes through a real projective solve, and a corner set that is duplicated, collinear, too small, self-intersecting, mirrored or numerically singular comes back as a named rejection rather than a matrix. Until that solve succeeds there is no button on screen that starts scoring, and none that overrides it.

Clear when it knows. Clear when it's close.

How it works

The pipeline, step by step

Ten steps, and the fourth is the one the product is really about. The rest is a very careful scorekeeper; that one is the reason to trust it.

  1. 01

    Pick the sport and the format

    Pickleball, padel, tennis or your own. The format is not a preset — best of one, three or five, advantage or no-ad, golden point, a full final set or a match tiebreak, all chosen before the first point.

    rule_pack.dart
  2. 02

    The score is an event log, and it only grows

    Five kinds of event and nothing else can change a score. Undo is not a deletion — it appends an event of its own, so a correction leaves the trail it corrected intact and the running score is always a fold over the whole log.

    UndoRequested
  3. 03

    Mark the four corners of the court

    A hand-written direct linear transform with Hartley normalisation, in pure Dart — no plugin, no native library, and unit-tested against synthetic camera poses rather than against a lucky recording.

    estimateHomography()
  4. 04

    The court refuses to lock

    Four taps are not a court. A corner set that is duplicated, collinear, too small, self-intersecting, mirrored, behind the camera plane or numerically ill-conditioned comes back as one of seven typed rejections rather than a mapping. The screen says “Nearly there”, and the button that starts scoring is not drawn at all. There is no override.

    HomographyRejection × 7
  5. 05

    Move the camera and the lock drops

    A mapping is only true of the frame it was solved in, so moving the phone invalidates it rather than degrading it quietly. The app says so and asks you to frame the court again.

    markCameraMoved()
  6. 06

    Score by hand, correct by index

    Every event carries where it came from, and today the only two values the database will accept are a manual tap and a correction. Camera-assisted is reserved in the schema and refused on the wire.

    provenance in ['manualTap','correction']
  7. 07

    Mark the moment that was close

    A rolling buffer keeps the seconds either side of the point you flagged, so the disputed rally can be watched back immediately. The clip is shown and then discarded rather than kept.

    before: 6s, after: 2s
  8. 08

    Ask the ref, and make it show its source

    The rulebook is on the phone: thirteen sections across three packs, plus a glossary. An answer has to name the pack and section it came from, and outside those packs it abstains and tells you which ones it has.

    13 sections · 3 packs
  9. 09

    Nothing reaches the model without a reservation

    The client cannot choose the model, the token ceiling, the tools or the system prompt — the gateway substitutes its own before it counts anything. Every call reserves its worst-case spend before it dispatches, and the whole surface fails closed if one control document is missing.

    claimDecision()
  10. 10

    Lose the network and keep scoring

    The match carries on locally and says so in as many words. Event ids are the sequence number itself, so a write replayed after a reconnection overwrites rather than duplicating the point.

    Still scoring on this device

Honest by design

An app that will not start scoring until it can prove it found the court.

The whole product could be one confident line — point it at the court and it keeps the score. aiRef will not say that, because the part that would make it true does not exist yet, and the part that does exist is honest about its edges. What ships is a scorekeeper whose calibration refuses bad input with a reason, whose confidence is drawn as a level rather than a number nobody has measured, and whose rules companion names the pack it read or names the packs it has.

  • Confidence draws as a level — never as a number or a percentage
  • A court locks only on a valid mapping — never on four taps alone
  • Answers cite the pack and section they came from — never a rule it hasn't got
  • Every score event is appended, corrections included — never a rewritten history

Nearly there

Move back until the last corner turns cyan. Until the four corners resolve to a valid mapping, the button that starts scoring is not on the screen.

Until the mapping holds

Illustrative

Everything inside

A whole world in one app

Four corners, and a real solve behind them

The calibration is the product's argument. It is written by hand, it is tested against synthetic camera poses, and its whole design is the failure path rather than the happy one.

  • A transform by handA direct linear transform with Hartley normalisation, in pure Dart — no plugin and no native library between you and the maths.
  • Seven ways to be told noDuplicate, collinear, too small, self-intersecting, mirrored, behind the plane, ill-conditioned — each comes back named.
  • A rejection is a state“Nearly there” is a normal screen with normal copy, and the calibration keeps listening rather than throwing.
  • Court geometry as dataEach court's real dimensions are stored and pinned by test, so a wrong number fails a build rather than a match.

The court locks on a valid mapping — never on four taps alone.

homography.dart

A rules companion that names its source

The rulebook ships inside the app, and an answer has to point at the part of it that it used. Outside the packs it has, it says so rather than reasoning from memory.

  • The rulebook is localThirteen sections across three digests plus a glossary, all shipped in the app rather than fetched per question.
  • Read-only toolsIt can search the packs, read a section, read the match state and open a screen. It cannot write anything.
  • The server owns the promptWhatever the client sends, the gateway substitutes its own declarations and system prompt before a token is counted.
  • Rules, not verdictsOn a disputed live point it points back at the replay rather than adjudicating a rally it did not see.

Every answer cites the pack and section — never a rule it hasn't got.

5 read-only tools

Everything inside

The rest of it

A score that is an append-only log

Five event kinds, and the running score is always recomputed by folding them. Nothing edits history.

  • Undo is an eventIt appends rather than deletes, so the correction and the thing corrected both survive in the trail.
  • State is always a foldEvery screen reads a replay of the log rather than a cached mutable score that could drift from it.
  • Recorded matches in CIOne real match per rule pack is stored and replayed call by call, so a scoring change fails a test not a game.

Rule packs with the awkward variants in them

The formats people actually argue about before a match, rather than one generic scoreboard with a target number.

  • TennisBest of one, three or five; advantage or no-ad; a full final set or a ten-point match tiebreak.
  • PadelGolden point on by default, and deliberately no match tiebreak, because padel does not have one.
  • PickleballEleven, fifteen or twenty-one; side-out or rally scoring; the midpoint switch where you actually play it.
  • CustomYour own target, win-by-two and serve rotation, for the format your club made up and never wrote down.

Local first, and honest about it

A court is exactly where the signal is worst, so losing it is a normal state rather than an error, and the app says which one it is in.

  • The banner is the truthIt tells you the match is still being scored locally and will sync when you reconnect, rather than hiding the state.
  • Idempotent by idAn event's id is its sequence number, so a write replayed after a reconnection overwrites instead of duplicating.
  • A lock in front of the appWhile the device lock is engaged the routed subtree is not mounted, painted, or exposed to assistive tech at all.

One locked palette, and a colour it hardly ever uses

Paper, ink, two flag colours and a signal lime — with a build test that greps for the lime outside the three widgets allowed to use it.

  • Cyan and pink are sidesThey mark which end of the court, and are never mapped to good and bad or win and lose.
  • Lime means one thingThe detected object and the confirmation, at most one on screen at a time, enforced by a test rather than a convention.
  • Motion named after paperSnap for the court lock, fold for a score change, tear for a major reveal — the vocabulary the collage is made of.
aiRef

Any court, and the honest half of the problem

Finding a court in a camera frame is geometry, and geometry either solves or it does not. The part that decides what happened on it is designed, tested and deliberately not connected yet. Illustration, not a screenshot.

Architecture

How it's put together

A Flutter client of roughly 27,000 lines that does the geometry itself, a TypeScript Functions surface that owns every decision with a cost attached, and exactly one model call that no client can reach. Data, functions and email all sit in the EU.

App

Web and Android from one codebase
Flutter
Riverpod and go_router; no ML dependency of any kind
Vision, in Dart
The homography solve, the calibration state machine and the court specs
Device lock
Wraps the routed subtree; explicitly not a sign-in and stores no credential

Engine

Pure Dart, no Flutter import, pinned by test
Event sourcing
Five event kinds; the score is always a fold over the log
Four rule packs
Each with a recorded match replayed call by call in CI
Property tests
Run across every pack rather than one worked example

Backend

Cloud Functions, London region
AI gateway
The only paid boundary; reserves worst-case spend before dispatch
Billing
Play Billing wired end to end; App Store and Stripe still in progress
Admin console
Separate host, mandatory second factor, strict content policy

Data

Deny by default, EU multi-region
Firestore
Match events are append-only — update and delete are refused by rule
Cloud Storage
Every client path denied; the app has no storage dependency at all
Gemini via Vertex AI
On the EU endpoint, a deliberate residency boundary

Background

The story

The research came first, and it argued against the obvious product. A long study of what a phone camera can honestly be asked to do — rolling shutter skewing a fast ball, automatic exposure and lens switching making a fixed court appear to move, and the plain arithmetic that 4K at 120 frames a second is close to a billion captured pixels every second — did not conclude that any of this was impossible. It concluded that one product promising the same accuracy on every phone and every sport would be a lie.

So the first thing built was the part that can be finished honestly: the scorekeeper. The engine is event-sourced and append-only, undo is implemented as another event rather than a deletion, and the score is always recomputed by folding the whole log. Four rule packs sit on top of it, each shipping with a recorded match that CI replays call by call, so changing a scoring rule breaks a test rather than a game.

The camera does the geometry it can prove, and nothing else. Four-corner calibration is a hand-written direct linear transform, unit-tested against synthetic camera poses, and its entire design argument is the failure path — a bad corner set is rejected with one of seven named reasons, and until the solve succeeds the button that starts scoring is not on the screen.

What is deliberately absent is the detector. There is no machine-learning dependency in the app at all, and the database will not even accept a camera-assisted event. The three-outcome evidence contract — confirmed, probable, not enough evidence — is fully designed and fully tested and connected to nothing, because the honest order is to build the thing that decides before shipping the screen that reports it.

The one live AI surface is the rules companion, and everything expensive about it belongs to the server. The client cannot choose the model, the ceilings, the tools or the prompt; the gateway substitutes its own before it counts a token, every call reserves its worst case before it dispatches, and the whole surface fails closed if a single control document goes missing.

Next

Pinch

Know why you're pinching it