Pins
Every frame, every pin, every ball.
Pins is a bowling tracker that records what actually happened on the lane. Not just “8 then spare” - which pins were standing, which ball you threw, whether the leave was a split and whether you converted it. Ten-pin scoring falls out of that record automatically, and so do the stats that tell you what to practise. It runs entirely on the phone, with no account and no network.
What it is
The hierarchy is Alley → Session → Game → Frame → Throw. A session is one sitting at one house; a game is ten frames; a frame is up to three deliveries.
Each throw stores the pins it knocked down as a 10-bit mask, so the app knows the rack state rather than just a number. That single decision is what makes everything downstream possible: splits are detected instead of declared, leaves can be named, and every stat can be sliced by which ball you had in your hand.
You can log at whatever level of detail the evening deserves:
- Pin tap - tap the pins that fell on a rack diagram. Full fidelity, and the only mode that feeds the deeper insights.
- Count - type how many fell. The keypad clamps to the pins actually standing.
- Final score only - just the number, for a game you didn’t track.
Your arsenal is modelled too: each ball has a name, colour, weight, coverstock, and a flag for house balls. Throws reference the ball that made them, so “which ball actually carries” is a question the data can answer.
Why it exists
Bowling apps tend to sit at one of two extremes. Either they’re a scoresheet that adds up numbers you already know, or they’re league-management software wrapped around a login screen. Neither helps you get better.
The useful question isn’t “what did I average”. It’s “when I hit the pocket, how often do I actually carry the strike, and when I don’t, what am I leaving”. Answering that needs pin-level data, and almost nothing collects it.
So Pins is built around three commitments:
- Record the rack, not the score. The score is derived. If you store the pins, you can compute anything later; if you store the total, you’ve thrown the information away.
- Local-first, permanently. There is no backend and not a single network call in the codebase. Your bowling history is a SQLite file on your phone, with a JSON export as the escape hatch.
- Entry has to be fast. You’re logging between frames with other people waiting. Three entry modes exist because the right level of detail changes with the evening.
Core features
| Feature | What it does |
|---|---|
| 🎳 Pin-accurate scoring | Tap the pins that fell on a rack diagram. Strikes, spares, splits, clean frames and the tenth-frame bonus all derive from the throw list - nothing is entered twice. |
| 🎯 Three entry modes | Pin tap, pin count, or final score only - set a default and override per game. The count keypad clamps to the pins actually standing. |
| 🏐 Ball arsenal | Name, colour, weight, coverstock and house-ball flag. Every throw records which ball made it, and balls are archived rather than deleted so old games keep their history. |
| 📊 Insights that go deep | Four drill-downs - first ball, spares & splits, by ball, by alley - over a rolling window. |
| 💥 Carry percentage | How often a pocket hit becomes a strike. The pocket is computed from your throwing hand, so it’s the 1-3 for a righty and the 1-2 for a lefty. |
| 🔀 Split tracking | Splits are recognised from the standing-pin mask via a pin-adjacency map, then reported as leave frequency, split rate and conversion rate. |
| ⏸️ Resume a session | A session with no end time is still live and surfaces on the home screen, so a half-finished night picks up where it left off. |
| 🌍 Four languages | English, German, Spanish and French, fully translated across eight namespaces. |
| 📤 JSON export | Dump every table to a file and hand it to the share sheet. Local-first means no cloud backup, so this is how your data leaves. |
| 🩺 Storage diagnostics | A built-in screen reporting database path, size, journal mode, migration history and row counts. |
Vision
Pins stays a single-player, single-device app. No leaderboards, no leagues, no accounts - the whole design leans on there being exactly one writer and one copy of the truth.
What’s genuinely unfinished:
- Practice reminders. The preference and its toggle exist; the scheduling behind them isn’t wired up yet.
- Season goals. You can set a target average and it shows on your profile. Turning that into progress tracking over a defined season is the obvious next step.
- More from the same data. The pin masks already contain far more than the four insight screens currently surface - pin-carry heatmaps and per-leave conversion breakdowns need no new data collection, only new views.
Alleys stay deliberately thin - a name and nothing else. Distances and coordinates are out of scope, because a bowling tracker that wants your location is solving someone else’s problem.
Technical details
Stack
| Layer | Choice |
|---|---|
| Runtime | Expo SDK 57 (managed), React Native 0.86, React 19.2 |
| Language | TypeScript |
| Navigation | Expo Router - file-based |
| Styling | NativeWind 5 + Tailwind v4 |
| Database | expo-sqlite - journal mode delete, synchronous = FULL |
| ORM | Drizzle ORM 1.0, with a change-listener-driven reactive layer |
| Lists | FlashList |
| Graphics | Skia + react-native-svg |
| Local KV | react-native-mmkv |
| i18n | i18next + react-i18next + expo-localization |
| Type | Bricolage Grotesque + Instrument Sans |
| Tests | Vitest over lib/ |
| Releases | GitHub Actions → eas build --local → APK |
Engineering decisions
- A source-level test guards against silent data loss. Everything written to the database used to vanish on relaunch. The cause: Drizzle’s expo-sqlite driver never finalizes its prepared statements, and
.get()stops stepping the moment it has a row. A statement parked onSQLITE_ROWhasn’t finished, and SQLite only commits an implicit transaction when the statement that opened it finishes - so.returning().get()left the insert uncommitted and held the write transaction open, taking every subsequent write down with it at process exit. It can’t be caught at runtime, because the app reads back exactly what it wrote; it can’t be caught in a unit test, because the behaviour is in the native module and Vitest has no SQLite. So it’s enforced at the source level instead: a test walks the whole tree and fails the build if.get()or.returning(appears anywhere outside a comment. - Journal mode
delete, not WAL - and it’s not a fix. The data loss above was uncommitted writes, which no journal mode would have saved.deleteis simply the better fit: one connection, a handful of rows per throw, nothing to gain from WAL’s concurrency, and every commit fsynced into one file with no sidecar whose fate needs separate reasoning. Existing WAL-mode installs get awal_checkpoint(TRUNCATE)folded in before the switch, and if the switch loses a race it degrades safely -synchronous = FULLstill fsyncs every commit. - Two boot counters as a differential diagnosis. One lives in SQLite, one in MMKV, and the pair distinguishes three failure modes that look identical from the outside: SQLite resets while MMKV climbs means the database file isn’t persisting; both reset means the sandbox is gone, i.e. a reinstall; both climb means storage is fine and the bug is further up. It’s a screen rather than a
console.logbecause it has to work in the release APK, where there’s no Metro console and no dev menu. - The database never stores a computed score. Ten-pin scoring is a pure function over an ordered throw list, which makes it exhaustively testable without a database and means a scoring bug is fixed by shipping a new function, not by migrating everyone’s rows. Game totals are denormalized only so list screens can read a cheap aggregate.
- Pin state is a 10-bit mask with an adjacency map. Knocked pins are stored per throw; standing pins are derived. Split detection reads the rack geometry from a hand-written pin-adjacency table rather than a hardcoded list of named splits, so it generalises to leaves nobody bothered to name.
- The pocket depends on your hand. Carry percentage - strike rate given a pocket hit - is the single most useful number in the app, and it’s meaningless if you compute it against the wrong pocket. Throwing hand is on the profile and feeds the pocket test directly.
- Frames are created lazily. A frame row appears with its first throw rather than ten empty rows appearing with the game, so an abandoned game leaves nothing to clean up. Uniqueness is enforced by the database at every level - one index per game in a session, one index per frame in a game, one order per throw in a frame.
- Balls are archived, never deleted. A throw references the ball that made it, and retiring a ball from your bag shouldn’t rewrite three seasons of history.
Get & use Pins
Install (Android APK)
Grab the latest build from apps.ntrapp.net, allow installs from your browser or file manager (Settings → Apps → Special access → Install unknown apps), and open the APK. Android will warn you the app isn’t verified - expected for a sideloaded build.
First run
- Work through onboarding - your name, throwing hand, and your first ball.
- Start a session and pick an alley. Add it if it’s your first time there.
- Bowl. Tap the pins that fell; the scoresheet fills itself in.
- After a few sessions, open Insights. The deeper screens need pin-tap data, so they stay quiet until you’ve thrown some frames in that mode.
Build from source
Requires a development build - the app depends on native modules (SQLite, MMKV, Skia), so Expo Go won’t run it.
git clone https://github.com/elRatto21/Pins
cd Pins
pnpm install
pnpm start
pnpm typecheck
pnpm lint
pnpm test # vitest
Uses pnpm for all package management.
Pins - the score is the easy part.