Pinball Tracker
Always remember your best pins.
Pinball Tracker logs real games on real machines. Pick a pin, start a session, and punch each score into a numpad built to be used with one hand, standing up, in a dim bar. What comes out the other end is a Grand Champion board, a personal best per machine, and a trend line that tells you whether you’re actually getting better. It works with the phone in flight mode, because a basement arcade is exactly where you’d expect to have no signal.
What it is
The model is three levels deep and no deeper: Pin → Session → Game.
- A pin is a machine. Search the Open Pinball Database to pull in the name, manufacturer, year and backglass art, or add one by hand if it isn’t listed.
- A session is one visit to that machine. It carries an optional venue, opens when you start playing and closes when you walk away.
- A game is a single credit. Score, ball-by-ball if you want it, plus two flags: tilt and malfunction.
Entry happens on a full-screen numpad with the running score rendered in a dot-matrix panel, thousands grouped as you type. Everything else - the stats screen, the per-machine detail, the Grand Champion board - is derived from that pile of games.
There’s no backend. The only two network calls in the entire app are OPDB machine lookups and, strictly opt-in, a backup written to your own Google Drive.
Why it exists
Pinball scores evaporate. You play six games on a machine you’ll never see again, put up something genuinely good on the fourth, and a week later the number is gone. The alternatives are a notes app you’ll never open again or nothing at all.
The constraints came from where the logging actually happens:
- One hand, standing, bad light. You’re holding a drink or the machine. The numpad is deliberately oversized and the score panel is high-contrast amber-on-black, because squinting at a 12px input next to a backbox is miserable.
- No signal. Arcades are in basements. An app that needs a round trip to save a score is an app that loses scores.
- Honest numbers. A game the machine ate isn’t a real score, and pretending otherwise poisons every average you’ll ever look at.
That last point is most of the interesting design work in the project.
Core features
| Feature | What it does |
|---|---|
| 🔢 Purpose-built numpad | Full-screen, oversized keys, optional haptics per press, live thousands-grouping as you type. The screen stays awake for the whole session. |
| 🏆 Grand Champion board | Your best valid score per machine, ranked. Pro, Premium and LE variants of the same title collapse into one entry via OPDB’s group ID, so Godzilla is Godzilla. |
| ⚠️ Tilt vs. malfunction | Two separate flags. A tilt still counts - it’s your fault. A malfunction doesn’t count anywhere, but is still stored and shown struck through. |
| 🎰 OPDB machine database | Search thousands of real machines with artwork, manufacturer and year. Manual entry covers anything missing, and works with no API token at all. |
| 📈 Stats that mean something | Personal best and average per machine, a score trend chart, distinct machines played, games in the last 30 days, and a day streak. |
| ⚾ Ball-by-ball scores | Optionally record the cumulative score after each ball, however many balls the game actually ran to. |
| 🕓 Backdated entry | Log a session you forgot to track at the time, at the date it actually happened. |
| ☁️ Opt-in Drive backup | Back up and restore through your own Google Drive. Off by default; the app is complete without ever touching it. |
| 🎚️ Preferences that matter | Default venue, default entry mode, keypad haptics, keep-awake, and whether thousands are separated with ' or ,. |
Vision
The interesting thing about this project is the list of things it will never do. From the project’s own non-goals:
- No backend, ever. No API server, no hosted database, no Firebase. Local SQLite is the source of truth and there is no second copy to reconcile.
- No social features and no shared leaderboards. The Grand Champion board is yours. Competing with strangers is a different app.
- No account system. Google Sign-In exists for exactly one reason - reaching your Drive - and nothing else is gated behind it.
- No OTA updates. What you installed is what runs.
The one genuinely open direction is iOS, which is out of scope for v1 rather than out of scope forever. Everything else is a matter of sharpening what’s there: more stat surfaces over the same data, and a faster path from unlocking the phone to logging the first score.
Technical details
Stack
| Layer | Choice |
|---|---|
| Runtime | Expo SDK 57 (managed), React Native 0.86, React 19.2 |
| Language | TypeScript (strict) |
| Navigation | Expo Router - file-based |
| Styling | NativeWind 5 + Tailwind v4 (CSS-first config) |
| Database | expo-sqlite (on-device only) |
| ORM | Drizzle ORM 1.0 + drizzle-kit, useLiveQuery for reactive reads |
| Graphics | Skia - runtime shader for the dot-matrix panel |
| State | zustand, scoped to the in-progress session only |
| Local KV | react-native-mmkv |
| Animation | react-native-reanimated 4 + worklets |
| Machine data | OPDB (Open Pinball Database) API |
| Backup | Google Drive REST v3 via fetch, behind an auth adapter |
| Tests | Jest over lib/**/*.test.ts |
| Releases | release-please + GitHub Actions → eas build --local → APK on the release |
Engineering decisions
- One rule for what counts, in one function.
malfunctioninvalidates a score everywhere - best, average, trend, Grand Champion - while still being stored, displayed struck through, and counted toward games played.tiltdoes none of that; it’s a tag for flavour stats, never an invalidator. The distinction is easy to get subtly wrong on the fifth screen that needs it, so it exists once asscoringGames()inlib/stats.tsand every stat filters through it. All stat math is pure functions over plain arrays, which is what makes it unit-testable without a database. - Per-ball scores are a JSON column, not a table. They’re only ever read alongside their game, they’re cumulative running totals rather than independent values, and their length varies with extra balls. A join table would buy normalization nobody queries and force every read to assume three balls.
- A unique index is the concurrency control. A game’s index within a session is assigned by a
max()+1subquery inside the INSERT. That’s a read-then-write race, and “Next game” is a button people double-tap. The(session_id, index)unique constraint is what makes it safe. - The dot-matrix panel is a shader, not an image. The amber-on-black DMD texture behind the Grand Champion board and the entry score is a Skia runtime shader - dots on a 6px grid at ~0.13 alpha, one fill, resolution-independent. No asset to scale, no moiré at odd densities.
- The day streak steps by calendar date. Subtracting a fixed 24 hours lands on the same local date twice across a DST fall-back and skips one on spring-forward, which would either double-count a day or sever an unbroken streak. It walks
setDate(-1)instead. - Machine variants are one machine. Grand Champion entries and distinct-machine counts key on OPDB’s group ID, falling back to the pin’s own ID for manual entries - so a Pro and an LE of the same title don’t occupy two slots on your own leaderboard.
- The auth library is quarantined.
lib/google-auth.tsis the only module permitted to import the Google Sign-In package. Swapping providers - or dropping Drive entirely - touches one file rather than every screen that happens to know about a user. - CI bundles the app, not just typechecks it. Every PR runs an
expo exportalongside lint, typecheck and tests. Expo Router builds its route tree withrequire.context, so one unresolved import in any route file breaks startup for the whole app rather than just that screen - and a bundle is the only step that catches it. - A missing token fails the build instead of shipping. The OPDB and Google client IDs are
EXPO_PUBLIC_*, which Metro inlines at bundle time - a build made without them produces an APK with dead search and a broken backup screen, and no runtime error to explain why. The release workflow asserts both names exist in the EAS environment before it starts. - Signing is EAS-managed and builds run on the runner. One keystore per package variant,
android/gitignored and regenerated by prebuild,EXPO_TOKENas the only repository secret, andeas build --localinside GitHub Actions so there’s no cloud build queue to wait on.
Get & use Pinball Tracker
Install (Android APK)
Pinball Tracker is Android-only and isn’t on the Play Store. Grab the latest build from apps.ntrapp.net, then 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
- Add your first machine - search OPDB by name, or create it manually if it isn’t in the database.
- Start a session, optionally naming the venue so the history means something later.
- Log each game on the numpad. Flag a tilt when you deserve it and a malfunction when the machine does.
- Check the stats tab once you’ve got a few sessions in - the trend line needs data before it says anything.
Build from source
Requires Node 22, pnpm 10, and a development build - Expo Go won’t work, since the app depends on native modules (SQLite, MMKV, Skia, Google Sign-In). Building locally also needs JDK 17 and the Android SDK.
git clone https://github.com/elRatto21/Pinball-Tracker
cd Pinball-Tracker
pnpm install
cp .env.example .env # optional
pnpm start
Both environment variables are optional. Without EXPO_PUBLIC_OPDB_TOKEN machine search falls back to OPDB’s unauthenticated typeahead (no artwork, no offline cache) and manual pins still work; without EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID the backup screen simply reports that Drive backup isn’t configured.
pnpm typecheck
pnpm lint
pnpm test
Uses pnpm for all package management.
Pinball Tracker - the machine forgets, you don’t have to.