Inspiration

Millions of people play cricket every weekend, and almost none of it is written down. Runs vanish into paper scorebooks that get lost, and a player can play for twenty years with no record of any of it.

The apps that would keep them exist, but they charge the one person doing three hours of unpaid work with a phone in one hand and eleven people waiting on them.

That inversion is the whole project. The scorer is the labour. The people reading the scorecard are the audience. So the scorer never pays and never sees an ad, and the app earns from the readers instead.

What it does

Ball-by-ball scoring for club, box and gully cricket — the games nobody else records.

  • One thumb, one tap per ball. Extras are armed modifiers: arm a wide, tap the runs, and the key tells you what it will score before you commit.
  • Works with no signal. Every tap writes to SQLite and to the screen and returns. A drain loop handles the network. Grounds have patchy signal or none, which is exactly when scoring cannot stop.
  • Any delivery can be corrected, not just the last one. The innings replays around the change, so the strike, the over and every figure after it follow.
  • A live link anyone can open — no app, no account, no signup to watch.
  • Careers that outlive the club. Public player pages, club pages, and the whole ball log exportable as CSV or JSON.

Every feature is free. Not a trial, not a tier. A supporter subscription removes ads on the viewer surfaces and unlocks nothing, because nothing is held back.

How we built it

ball_events is the only source of truth. Every other number is derived.

The scoring engine is a pure function — applyBall(state, event) → newState — in its own package with no I/O, no framework and no dependencies. The scorecard, career averages, the club page and the share cards are all folds over the ball log through that one function.

That buys three things. Undo is not a reverse operation, it drops a row and replays. A correction cascades correctly for free. And because the engine has no runtime assumptions, the same code runs on the server and on the phone — which is what makes offline scoring honest rather than an optimistic guess. The console folds pending deliveries through the same applyBall the API runs, against the API's own last answer.

The Laws live as exported sets rather than if statements, because the same question gets asked in more than one place. "Which dismissals credit the bowler" is Law 25, and the SQL that computes career figures imports BOWLER_CREDITED_WICKETS instead of retyping it. Three copies would drift; one export cannot.

Validate on write, tolerate on read. Replay runs on every read, so a rule tightened today would otherwise be applied retroactively to deliveries recorded before it existed — and a shared scorecard would break because the laws improved. Recording is strict; reading back is lenient and records the objection instead.

Expo / React Native, Next.js, PostgreSQL, Drizzle, in a pnpm monorepo so the engine and the API contract are shared rather than reimplemented.

How it earns — and why RevenueCat

The scorer is never charged, so the money has to come from the people watching. Ads run on the viewing surfaces — the public scorecard and the match list — and never on the scoring console. A Supporter subscription removes them everywhere.

RevenueCat carries that, and the design rule was that the app should never know what somebody bought — only what they are entitled to.

  • One entitlement, checked in one place. The app asks a single question — does this person have supporter? — and never names a product. Adding a plan, changing a price or running a promotion is a dashboard change, not a release.
  • Packages by type, not by name. One default offering with $rc_monthly (₹49) and $rc_annual (₹199). The paywall reads offering.monthly and offering.annual, so it renders whatever the offering currently holds.
  • Test Store and Play in the same offering. Each package holds a simulated product and a real Play product side by side, so development and production run from one configuration.
  • Tied to the account, not the phone. The app calls Purchases.logIn with the user's id at sign-in, so supporter status follows them to a new device, and Restore sits on the Supporter screen.
  • Honest when it cannot sell. A build without a store key shows "Purchases are not configured in this build yet" — a visibly unavailable button, never one that takes money and grants nothing.
  • The whole paywall is one line. AdBar returns nothing for a supporter. No feature sits behind it, because nothing is held back.

Challenges we ran into

Three bugs, and what they had in common is the interesting part: none of them crashed, none failed a test, and all three produced perfectly plausible output.

A pure function that wasn't runtime-pure. The engine minted ball ids with globalThis.crypto.randomUUID(). Correct on a server, correct in a browser, and absent in React Native's Hermes. Harmless for as long as the engine only ran server-side — then offline scoring moved it onto the phone and the first ball anyone scored threw Cannot read property 'randomUUID' of undefined. Scoring didn't work at all. No test caught it because every test runs under Node, where the global exists.

Two write paths that disagreed. POST /ball built the engine's input as an object literal and left out four fields, so overthrows and the batters-crossed override were silently stored as null. PATCH went through different code and carried all four — so correcting a delivery put back what recording it had lost. The correction suite proved overthrows worked while recording one never did.

A validation rule that was unreachable. A cross-field check sat below an early return taken by every event type that names its own runs — precisely the deliveries the rule was for. It was live for extras, which never carry shot placement, and dead for every scoring shot, which does.

Accomplishments that we're proud of

489 unit tests, plus smoke suites that play whole matches against a real database and a production build, then replay the stored log to prove it still parses. The engine is covered two ways: example tests for the cases somebody thought of, and property tests over any sequence of deliveries for the ones nobody did.

The property tests assert the Laws as invariants over randomly generated innings — the total is always the sum of every delivery, a wide is never a ball faced, a dismissed batter never faces another ball — so they hold for inputs no one wrote down.

And the cricket is right. A wide is not a ball faced. A leg-bye doesn't break a maiden and is never charged to the bowler. A free hit carries a no-ball's dismissals and survives an intervening wide. A single off the last ball keeps the strike. A retirement isn't a delivery, so it doesn't use one up. If the app refuses a ball, it tells you which Law says so.

What we learned

A pure function is only pure if it makes no assumptions about its runtime. "No I/O, no framework" was in the file header the whole time, and one call to a Web API broke it the moment the code moved to a phone.

Two code paths for the same write will disagree, and only one of them will be tested. The fix wasn't the missing fields — it was noticing that recording and correcting a delivery had become different programs.

Order matters in validation, and getting it wrong is silent. A rule after an early return isn't a weaker rule; it's no rule at all, for exactly the inputs it was written for.

What's next for Open Innings — Cricket Scoring

Tournaments. Clubs don't adopt a scoring app, they adopt a tournament system — fixtures, points table, a league page. It's the single biggest adoption gate, and it's already built on a branch with its own tests. It ships as the first update after launch.

Deep analytics, free. Every delivery is stored forever, so batting zones, matchups and over-by-over comparisons are pure compute over data we already hold. The incumbent charges a subscription for this; giving it away is the clearest way to be better rather than cheaper.

Offline everything. Scoring and undo work with no signal today; creating a match, starting an innings and correcting a delivery the server already holds still need the network. Closing that makes this the app that works at a ground with no bars — the exact moment the alternatives fail.

The share loop, and languages. A share button on the public scorecard, club pages that search engines can find — then Hindi and two or three southern languages, which is the real audience definer.

And the thing that stays fixed: no feature will ever be paywalled, and the person doing the scoring will never pay.

Built With

Share this project:

Updates

Submission history