Garagey AI
A car spotting game played worldwide. AI identifies each car, and the server keeps score.
Overview
Garagey AI turns car spotting into a collecting game. You point the camera at a car, and the phone pixelates faces and text, cuts the car out as a sticker, and sends it off to be identified. Gemini returns the make, model, year, power, and market value, and each catch gets a rarity tier in your Garage. You can preview new paint finishes and wheels with AI, and a map shows where each car was spotted. The location is kept approximate on purpose.
World Ranking adds the competition: weekly and all-time boards for rarity, catches, and value, all judged on the server. It's live on iOS and in closed testing on Android. Aska Cheung designed the UX/UI. I built the rest: both native apps, the Firebase backend, the World services, billing, and the release process that ships iOS and Android together.
Full stack
- Swift · SwiftUI · iOS 18+
- Kotlin · Jetpack Compose · CameraX
- Apple Vision · Core Image · ML Kit · MediaPipe (on-device)
- Firebase Auth · Firestore · Cloud Storage · App Check / Play Integrity
- Cloud Functions (Node 22) · Cloud Run ×3 · Cloud Scheduler · Secret Manager
- Gemini 3.5 Flash-Lite (identification) · Gemini 2.5 Flash Image (restyling) via Vertex AI
- Cloud Vision SafeSearch (profile photo screening)
- StoreKit 2 · Google Play Billing · RevenueCat · AdMob
- XCTest · JUnit · Node test suites (2,000+ tests)





Engineering highlights
The parts a code review would find interesting.
01Faces and text are masked on the phone
Before a photo is saved, Apple Vision on iOS and ML Kit on Android find faces and text and pixelate them. There's no license plate detector, so all text gets masked, and if masking fails the original photo is thrown away. The car is then cut out of the frame, and an on-device classifier checks that the cutout really is a car. Finding the car was the hard part. On 124 real photos, detection on the full frame missed it 78 times. Retrying on 90% and 80% center crops found all 124, and if that ever fails, the user can tap the car.
02The server decides every score
World runs on three Cloud Run services. A public edge only checks the basics (path, size, and the format of the auth and app-integrity tokens), then forwards the request under its own service identity. A private gateway serves the app API. A worker, triggered every minute by Cloud Scheduler, handles deletions and data retention before it scores anything. Every capture is tied to a one-time challenge from the server, which is used up once the upload is verified, and each step reads the server's own saved record. Any score or verdict the app sends is ignored.
03One API contract, tested in three languages
I wrote World's API as versioned schemas and test fixtures before building it. A test runner has Node produce the expected results, and separately compiled Swift and Kotlin parsers have to reproduce them. There are 210 cases, and 82 of them must be rejected. They target the edge cases that tend to break cross-platform apps: 64-bit integers, millisecond timestamps, enum values the app doesn't know yet, and the difference between a null field and a missing one.
04How the leaderboard counts
Value and rarity are recalculated on the server from the analysis record. Each car design counts once, at its highest value, so catching the same design twice doesn't pad your score. On the weekly boards, that limit resets every week. Each player has a small running total per board that's only recalculated when their score changes, an hourly job reconciles it, and finished weeks are frozen. One problem came from late results: some analyses finished after the weekly cutoff and ended up in the next week. The rule now is simple. If a capture settles after the cutoff, it counts toward all-time only.
05Fixing 4,047 records without deleting any
Some players were missing their country flag. The cause was two hashing conventions: the code that wrote the record used the SHA-256 of the user ID, and the code that read it used the SHA-256 of the ID as a JSON string. I fixed the writer first, and a dry run found 4,047 affected records. The migration could only create records, never overwrite or delete them. It copied 4,046 (the fixed function had already written the last one), and then I re-read all 4,047 to confirm.
06Credits kept on a ledger
Each AI identification reserves a credit first, then either commits it or refunds it, all inside Firestore transactions. Credits from ads, purchases, and referrals are keyed by a hash of the transaction ID, so a retried grant lands on the same record instead of paying out twice. Ad and referral credits expire, and the ones expiring soonest are spent first. Rewarded ads are verified on the server with Google's signing keys, and Apple purchases against Apple's root certificates. Since these rules live on the server, an hour-long cooldown after 10 rewards in a row went out to every installed version at once, with no app update.