All projects
01 / 06 Flagship Case study

CarSpotter

A daily car-spotting game: five cropped details, four answers, ten seconds. Designed, engineered, and shipped solo for iOS.

Role
Sole designer, engineer & founder
Period
2026
Scope
iOS game, durable content pipeline, shared design system, marketing site
Status
iOS · on the App Store
Focus
  • Daily game loop
  • Content-addressed media pipeline
  • End-to-end solo build
Stack
  • Expo / React Native
  • TypeScript
  • Zustand
  • MMKV
  • Turborepo monorepo
  • Astro
  • ImageMagick pipeline
Outcomes at a glance
  • Daily 5 Cars a day
  • 12 Detail categories
  • 401 Tests across the build
  • One person Designed and built by
CarSpotter portfolio poster. The headline "Guess the car from the detail" in lime and white over a near-black wall of car photographs, beside three tilted iPhones showing a clue, the full-car reveal, and the run result.

The premise

Every enthusiast has done it. A single headlight in traffic, a quarter of a grille, the shape of a wheel arch, and you already know the car. CarSpotter is that instinct turned into a daily game.

Each day is a Daily 5: five cropped details of real cars, four answers each, ten seconds a clue. Same five for everyone. Score, rank up, hold a streak, and brag without spoiling it. It’s a Wordle-shaped daily for the people who already know the car before the badge shows up.

The Daily 5

The whole loop is built to be felt in under a minute. A detail locks into a lime viewfinder, a tachometer drains the ten seconds, four arcade buttons wait in the thumb zone. You pick, the crop blooms out to the whole car, and a one-line fact lands. Five times, then you’re done until tomorrow.

The constraints are the design. Everyone gets the same five, so bragging rights are earned, not bought. Practice is XP-capped, so the leaderboard can’t be farmed. And an answer is the name a person actually says: “GMC S-15”, not “GMC C/K (3rd gen Squarebody · 1973-87)”. No trick questions, no AI mush, no filler.

The answers used to be the hardest part, for the wrong reason

For months the four buttons read like spreadsheet rows: chassis codes, generation numbers, year ranges. It felt rigorous. It was actually unfair, because none of it is visible in a headlight crop. Worse, the catalog held 431 rows across only 240 real nameplates (seven Corvettes, seven F-150s), so a third of the questions offered the same car twice under two spellings. One question asked you to choose between “GMC C/K (1967-1968)” and “GMC C/K (square-body)”.

The fix was one idea: an answer is a name, and a name is the same name whoever badges it. Generation and year still exist on every car. They’re just metadata now, used to pick decoys from the right era and never shown. A decoy has to be a different nameplate a person could genuinely mistake for the answer (CR-V and Pilot, Forester and RAV4), never another trim of the answer itself.

The subtle half is what counts as “the same name”. A Chevrolet C/K and a GMC C/K are one name on two badges: indistinguishable in a crop, and two near-identical buttons. But a Silverado and a Sierra are the same truck with different names, two things a player can learn apart, which makes them one of the best questions in the game. So the rule ignores the badge and keys on the name. One migration relabelled 376 cars and rebuilt 444 questions across the whole schedule (past, live, and future), and every one of the 703 now shows four different cars.

Brag without spoiling

Sharing is the growth loop, so it had to be spoiler-free by construction. One tap turns a run into a card of colored squares and a clock. No car names, no photos, nothing that ruins the round for the group chat. It mirrors the in-app result exactly, down to the per-clue letters and the run tier, so the flex is honest.

The reveal, and the bug that almost shipped

The best second in the app is the reveal: the cropped detail zooms out to the whole car. Preparing the first App Store build, I found that second was broken for every authored day: the clue played, then the reveal bloomed to nothing.

The cause was quiet and dangerous. The studio I author puzzles in had stored each reveal photo as an absolute file://…/Application/<container-UUID>/… path. iOS regenerates that container UUID on every reinstall, so every stored path silently dangled. The crops had survived only by accident (the shipping export happened to inline them as base64), while the reveal originals, stored the exact same way, were never captured anywhere. A whole schedule shipped with working clues and empty reveals.

It was a data-loss class of bug, not a one-off. So I rebuilt the pipeline to make it impossible to repeat.

A content pipeline that can’t lose your work

The rule now: never persist an absolute path. Media is content-addressed: every photo lives under the SHA-256 of its own bytes, the manifest holds stable keys, and the real URI is rebased against the document directory at runtime. The presence of a hash file is the integrity proof. Writes are atomic: download to a temp file, verify size and hash, then move into place.

Around that sits a recovery pipeline and a set of gates:

  • Recovery that runs on any machine. Every reveal can be re-derived by priority: a committed in-repo bake first, then a local raw cache, then the original source fetched by ID and re-verified against its dimensions and hash. Run against the full schedule, it recovered 374 of 375 reveals.
  • A bundler that fails loud. The launch bundler used to silently skip any puzzle whose reveal wouldn’t resolve. Now it ships complete crop-and-reveal pairs only, asserts every scheduled day is a full five, and exits non-zero on anything missing.
  • Referential integrity gates. A pure checker runs over the authored content for dangling photo references, half-built reveal pairs, and orphans. The same gate guards the studio, the export, and the build.

Exports are self-contained now: every crop and every reveal travels as bytes, so reinstalling a phone or moving to a new laptop can’t orphan a thing. The build carries 401 tests, and the parts that lose data when they’re wrong carry the most of them.

Every day authors itself into five videos

A daily game lives or dies on being seen daily, and hand-cutting five vertical videos every morning is a job, not a feature. So the same authored day that feeds the app feeds a renderer: five clues in, five narrated 9:16 films out.

Each one is the game’s best second, isolated: a hook, a real five-count, the crop blooming to the whole car, the fact, the stamp. 814 frames, 27 seconds, one beat sheet. It works on social for the same reason it works in the app: you already think you know the answer, so you stay to find out whether you were right.

The economics are the point. Rendering is headless Remotion, so a day’s five come out of one command instead of an afternoon in an editor. The narration is three permanent voice takes (an intro, a countdown, a sign-off), recorded once and reused by every render, so the hundredth video costs exactly what the first did. Even the countdown is deliberate: it’s one continuous read rather than five words spliced onto a frame grid, because a rigid grid is precisely what makes a countdown sound like a robot.

One person, end to end

The design, the iOS app, the game engine, the content pipeline, and the marketing site are one person’s work, in a Turborepo monorepo. The rules of the game live in a pure-TypeScript game-core package with no React anywhere near them, which makes them trivial to test. The design tokens are a shared package the app and the website both compile from, so the brand can’t drift between the two. Even the App Store screenshots and the poster on this page come out of a scripted ImageMagick pipeline, versioned next to the code.

A small game, built on a serious spine, so the fun part never breaks.

One detail. Ten seconds. Then the whole car.

The moment that hooks you
The Daily 5

A clue, a clock, and four ways to be wrong.

The loop is built to be felt in under a minute. A cropped detail locks into a lime viewfinder, the tachometer drains ten seconds, and four arcade buttons wait in the thumb zone. Pick, and the crop blooms out to the whole car. Same five for everyone, every day.

CarSpotter clue screen: a tightly cropped grille inside a lime viewfinder, a draining ten-second tachometer, and four answer buttons: Chevrolet Aveo, Lincoln Aviator, Lincoln MKS, Chrysler 300.
The clue: one cropped detail, a ten-second clock, four cars to choose from. Tap before the tach hits zero.
CarSpotter reveal screen: the cropped grille has zoomed out to the full car, a silver Lincoln MKS, named simply 'Lincoln MKS', with a one-line fact about its split-wing grille.
The reveal. The crop zooms out to the whole car and the fact lands. It's the one second the entire app is built around.
CarSpotter home: a faint wall of car photographs behind a one-day streak and today's finished Daily 5 (833 points, graded A, 'Sharp eye'), with a countdown to tomorrow's set.
A fresh Daily 5 every morning, the same five for everyone, and a streak to keep alive. Play it and the day resolves to a grade and a countdown to the next one.
CarSpotter reveal: a correct answer on a Ford F-Series, the full truck shown with a one-line fact about the 1973-79 'Dentside' body crease, with the run score at 347.
Get it and the score climbs: consecutive hits stack a combo, and the fact lands while you're still pleased with yourself.
The flex, and the teach

Brag without spoiling. Miss without losing.

Sharing is the growth loop, so it's spoiler-free by construction: colored squares and a clock, never a car name. And every miss teaches the tell, so the detail you blow today is the one you nail on sight tomorrow.

CarSpotter result screen: 347 points, Learner level 2, and the spoiler-free share card (five coloured letter squares and a score, no car names), above a Share result button.
The share card mirrors the in-app result exactly: squares, letters, a clock, a tier, but never a car name. The flex is honest and the round stays unspoiled.
CarSpotter miss state: a red 'Not quite' badge over a 1958 Oldsmobile 88, telling you that you picked Honda Accord, with a fact about the chrome trim that runs the length of the car.
Miss one and it names what you picked, then shows you the tell. The game is quietly building detail literacy under the arcade.
CarSpotter clue: a cropped headlight under the viewfinder with one second left, and four answers: Tesla Model Y, Ford Police Interceptor Utility, Cadillac XT6, Lincoln Aviator.
Twelve detail categories: headlight, taillight, grille, wheel, badge, and more. The crop is the whole puzzle.
CarSpotter reveal after the clock ran out: an Infiniti QX60 shown whole, named simply 'Infiniti QX60', with a fact about its full-width light bar.
Real cars, real facts, and answers named the way a person would say them. No trick questions, no AI mush, no filler.

The clue stays secret, the reveal is the payoff, and the share card protects both. Every surface is designed around one rule: never spoil the round you want someone else to play.

The social engine

Every day authors itself into five videos.

A daily game lives or dies on being seen daily, and hand-editing five vertical videos a day is a job, not a feature. So the same authored day that feeds the app feeds the renderer: five clues in, five narrated 9:16 films out, cut to one beat sheet. No timeline, no editor, no hand.

One authored day Daily 5

Five clues in, five 9:16 videos out: one per clue, every one narrated, animated, and cut to the same beat sheet. No timeline, no editor, no hand.

ONE DETAIL. TEN SECONDS.
5
Lincoln MKS
CARSPOTTER
Narration
“CAN YOU guess this car?” “Five. Four. Three. Two. One.” “CarSpotter.”

Three voice takes, recorded once and reused by every render: a trailer-narrator read, not per-video TTS. The countdown is one continuous take: five words spliced onto a rigid frame grid is exactly what makes a countdown sound robotic. 27.1s

The reel is the game's best second, isolated: a hook, a real five-count, then the crop blooming to the whole car. It works on social for the same reason it works in the app: you already think you know the answer, and you have to stay to find out. Rendering is headless Remotion, so a day's five come out of one command; the narration is three permanent takes, so a hundred videos cost the same as one.

The brand surface

One brand, generated end to end.

The marketing poster, the App Store screenshots, and the game all speak the same midnight-arcade language. The design tokens are a shared package the app and the website both compile from, and the promo art comes out of a scripted ImageMagick pipeline, versioned next to the code.

CarSpotter portfolio poster: the headline 'Guess the car from the detail' in lime and white over a near-black wall of car photographs, beside three tilted iPhones showing a clue, the full-car reveal, and the run result.
The poster is a script, not a file: it composites the app's own marquee wall and its real screens in the app's own typefaces. Reshoot the screenshots, re-run it, and the poster can't drift from the product it sells.