Inspiration

Patterning a shotgun is the ritual that decides whether you take the shot. You staple a sheet of paper at 40 yards, fire one shell, then walk up and count the pellet holes with a marker — every one of them, usually 120 to 180, inside a 30-inch circle you drew by hand. It takes about forty minutes per target. Turkey hunters do it five times in an afternoon, with five different loads, at $4–6 a shell, and then argue about a 10% difference.

At the end of all that you have a number on a piece of paper that you cannot compare to last season's, because last season's sheet is in the garage.

Every adjacent app I could find either scores rifle targets — score rings, which is the wrong arithmetic for a shotgun — or predicts trajectory instead of measuring what your gun actually threw. So the interesting question was not "can a model count dots". It was: what does an honest tool look like when the model is not good enough to be trusted on its own?

What it does

Photograph a patterned target and PatternPro gives you real patterning data in seconds:

  • Pellet count, marked on the image, from an on-device model. In the demo video it counts a Dirty Bird splatter target in under four seconds and lands on 135 hits.
  • A correction step. The model misses some holes and invents a few. You tap to add or remove, and the percentage recomputes live. This is not a fallback — it is the product. The number you keep is one you checked.
  • Point of impact against your aim point, percentage inside the standard circle (30 inch at 40 yards, or 75 cm at 35 m — it follows your region), spread, pattern shape, quadrant balance, ring density and the largest in-pattern gap.
  • A level indicator in the camera, because an angled photograph is the single thing that ruins a count.
  • Offline. Counting runs on the phone. There is no signal at a pattern board.
  • History, Insights and Compare — every target saved against its gun, choke, load and distance, so you can settle a choke argument with your own data instead of the box copy.
  • AI Insights, which answers questions about your saved results: which load patterned tightest, whether your point of impact drifts, what changed between two sessions.
  • Squad and Team, for hunt clubs sharing a pattern record.
  • English, Spanish, French, German, Italian, Portuguese, Polish, Japanese, Chinese, Arabic and Urdu, with metric and imperial handled per region.

Analysing, correcting, saving and exporting work signed out. An account is only needed for cross-device Pro, cloud backup and AI Insights, and you can delete it, and everything in it, from inside the app.

How I built it

React Native on Expo, TypeScript throughout, Android first.

Counting is on-device. A CSRNet density model trained on Modal, exported to ExecuTorch and run through react-native-executorch. Density map in, count and peak centroids out. The reason it is on-device is not privacy theatre — it is that the marginal cost of an analysis is effectively zero, which is what lets the free tier be genuinely usable in a niche this small.

The maths is not in the model. core/patternStats.ts is plain, tested TypeScript: point of impact, percentage in circle, spread, shape, rings, quadrant balance, POI offset. Pellet counts per ounce come from core/shellData.ts so the percentage is against a real payload rather than a guess. Keeping the arithmetic deterministic means it is testable, it is fast, and it is right — a model is worse at counting than the code you already wrote.

Correction is the accuracy step and the data flywheel. Every tap is a labelled example. With consent, corrected targets go back to the training set, so the model gets better the more the app is used, and the correction burden falls.

Backend. Supabase for auth (Google + email magic link), Postgres with row-level security for chat history, sync and credits, and Edge Functions for the webhooks. A Modal service proxies AI Insights: it verifies the Supabase JWT, decrements a server-side credit balance, builds a compact fact block out of the user's own aggregates — sessions, best and worst average, POI bias, trend — and calls OpenRouter. The model never sees a photograph or a written note, and the API key never goes near the bundle.

Billing is RevenueCat, and it is load-bearing in three places:

  1. react-native-purchases in the app, entitlement PatternPro Pro, offering default, monthly / annual / lifetime.
  2. The AI server reads the same entitlement to size the monthly allowance. The paywall and the thing the paywall sells are enforced from one source of truth rather than two.
  3. A RevenueCat webhook into a Supabase Edge Function attributes each purchase to the creator code the user entered and books their share. That is the whole partner programme, and it exists because of the constraint below.

Challenges I ran into

The model is not good enough to be trusted alone, and pretending otherwise would have been the real failure. Roughly 31 mean absolute error on held-out real photographs, against about 4 on synthetic — a generalisation gap, not a training bug, and one that only more real labelled targets fixes. Every competitor in the adjacent space claims an automatic count. I shipped the correction step in front, said out loud in the store listing that automatic counting is an estimation tool and not absolute truth, and made the final number the user's. That claim is defensible, and it is the one that survives contact with a user whose first count came back wrong.

Both subscriptions were on sale as prepaid plans, not auto-renewing. Discovered in an audit six days before production access unlocked. The base plans were named monthly-auto and annual-auto and were prepaid: one payment, one period, access ends, nothing renews, nothing to cancel. Meanwhile the paywall said "Subscriptions renew automatically unless cancelled" and the revenue model assumed renewals. Play does not let you edit renewal type, so both had to be rebuilt and re-attached to the entitlement and offering, and the old ones deactivated before RevenueCat would save. Prepaid also greys out "Add offer" in Play, which is why there was no trial to begin with.

A paywall that charged for the wrong thing. The tier-to-package resolution fell through to packages[0], so a club admin tapping the Team tier was charged for a single-user monthly plan. RevenueCat reports a custom package identifier as packageType: CUSTOM, so the period matching silently missed and the fallback took over. The fix was to make the mapping a total Record<TierId, …> with no fallback, so a missing product now fails loudly, and to pull the Team tier until its products actually exist.

The highest-intent button in the app was dead. The export paywall called navigation.getParent()?.navigate('Pricing'). Analysis and Pricing are siblings on the root stack, so getParent() was undefined and the optional call swallowed it. Six gates now pass a feature through the route, the paywall names what was asked for, and the analytics event carries it, so conversion is computable per gate.

R8 ate the inference runtime, in release builds only. ExecuTorch installs its JSI bindings from C++ and finds the Java classes by name. R8 cannot see a FindClass call, so minification removed them and every release build crashed on launch with ClassNotFoundException: org.pytorch.executorch.Module — while every debug build worked perfectly. The keep rules had to go in app.json under expo-build-properties, not in android/app/proguard-rules.pro, because expo prebuild regenerates that file and would have silently reintroduced the crash.

Paid acquisition is closed to this app. Google Ads, Meta and Reddit all prohibit firearms advertising. PatternPro sells nothing of the kind — it measures holes in paper — but every one of those review systems is a classifier looking at "shotgun", "choke" and "shot size", and my screenshots are full of all three. So the growth engine had to be something else, which is where the creator revenue share came from: pay the people whose audience already trusts them, and pay them out of the revenue their video actually produces.

The season is the deadline after the deadline. Patterning is not year-round behaviour. It happens in short bursts before a season opens and then stops. Turkey — the peak by a wide margin — is February to April. Shipping in September means shipping into the quiet part of the year on purpose, to find the bugs on real users and bank corrected targets, and treating February as the real launch.

Accomplishments that I'm proud of

  • A working analysis loop that nothing else does for shotguns: photo → detect → correct → POI / percentage / spread / shape → export → history → insights → compare.
  • Inference on the phone, so it works where the users actually are — at a pattern board with no signal — and the free tier costs me nothing to run.
  • Shipped in eleven languages with metric and imperial handled properly, not bolted on.
  • RevenueCat wired end to end: app, server-side entitlement checks, and a webhook that turns a purchase into a creator payout.
  • Honesty as the differentiator. The app tells you the count might be wrong and hands you the tool to fix it. That is a harder thing to say in a store listing than "AI-powered", and it is the only version of this product that holds up.

What I learned

  • In RevenueCat, the identifiers are the contract. The entitlement id in the dashboard has to match ENTITLEMENT_ID in code exactly, and the $rc_monthly / $rc_annual / $rc_lifetime package identifiers are what produce the package types the purchase code matches on. A custom identifier reports CUSTOM and the match fails without an error.
  • Audit what is actually on sale, not what the docs say is on sale. A base plan named monthly-auto was prepaid. Nothing in the app could have told me that.
  • Read the entitlement server-side if the entitlement gates anything expensive. A client that says "I'm Pro" is a request, not a fact.
  • Let a missing product fail loudly. Every fallback in billing code is a way to charge someone for something they did not choose.
  • Pick the honest claim. In a suspicious niche, being the one product that admits its limits is a positioning advantage, not a weakness.

What's next for PatternPro

  • February. Turkey season is the launch; September is the rehearsal. Creator partnerships take months to warm, so they are being built now.
  • Retrain from contributed corrections and close the real-photo generalisation gap, so the correction step gets shorter every season.
  • A trial offer on annual, now that the base plans are auto-renewing and Play will let me attach one.
  • iOS, which is a key and a build, not a rewrite.

Built With

Share this project:

Updates

Submission history