All projects
In Progress · since July 2026 ·
react nativeexposqlitedrizzle

Pinball Tracker

A local-first Android app for logging real pinball scores, built for one-handed entry while standing at the machine.


Pinball Tracker

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

FeatureWhat it does
🔢 Purpose-built numpadFull-screen, oversized keys, optional haptics per press, live thousands-grouping as you type. The screen stays awake for the whole session.
🏆 Grand Champion boardYour 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. malfunctionTwo 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 databaseSearch 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 somethingPersonal 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 scoresOptionally record the cumulative score after each ball, however many balls the game actually ran to.
🕓 Backdated entryLog a session you forgot to track at the time, at the date it actually happened.
☁️ Opt-in Drive backupBack up and restore through your own Google Drive. Off by default; the app is complete without ever touching it.
🎚️ Preferences that matterDefault 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

LayerChoice
RuntimeExpo SDK 57 (managed), React Native 0.86, React 19.2
LanguageTypeScript (strict)
NavigationExpo Router - file-based
StylingNativeWind 5 + Tailwind v4 (CSS-first config)
Databaseexpo-sqlite (on-device only)
ORMDrizzle ORM 1.0 + drizzle-kit, useLiveQuery for reactive reads
GraphicsSkia - runtime shader for the dot-matrix panel
Statezustand, scoped to the in-progress session only
Local KVreact-native-mmkv
Animationreact-native-reanimated 4 + worklets
Machine dataOPDB (Open Pinball Database) API
BackupGoogle Drive REST v3 via fetch, behind an auth adapter
TestsJest over lib/**/*.test.ts
Releasesrelease-please + GitHub Actions → eas build --local → APK on the release

Engineering decisions

  • One rule for what counts, in one function. malfunction invalidates a score everywhere - best, average, trend, Grand Champion - while still being stored, displayed struck through, and counted toward games played. tilt does 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 as scoringGames() in lib/stats.ts and 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()+1 subquery 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.ts is 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 export alongside lint, typecheck and tests. Expo Router builds its route tree with require.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_TOKEN as the only repository secret, and eas build --local inside 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

  1. Add your first machine - search OPDB by name, or create it manually if it isn’t in the database.
  2. Start a session, optionally naming the venue so the history means something later.
  3. Log each game on the numpad. Flag a tilt when you deserve it and a malfunction when the machine does.
  4. 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.