A few weeks ago I shipped AgeDart, a game about one simple question: how old does this person look?
You see real faces (everyone who posts a photo opted in) and throw a dart at the age you think. The closer you land, the more points. And if you post your own selfie, strangers around the world guess your age and you get a verdict broken down by country, age group and gender.
I'm a solo developer from Spain and this is a side project, so I had two hard constraints:
- It has to cost almost nothing while nobody plays it.
- It must not fall over (or bankrupt me) if a video goes viral.
This post is about how those two constraints shaped the architecture. The app is Flutter (Android first, iOS in review), the backend is Firebase.
The stack in one paragraph
Flutter app with flutter_bloc, 9 languages via gen-l10n. Backend: Cloud Functions v2 (Node 22, about 118 functions), Firestore, Firebase Auth (anonymous first, Google/Apple later), Cloudflare R2 for images, Firebase Hosting for the website. No servers of my own, no Kubernetes, no Redis.
1. Anonymous first, account later
You can play the moment you open the app: an anonymous Firebase Auth user is created and a players/{uid} document is set up with a birth year and gender (only used to break down results by group). You only need a Google or Apple account when you want to post a photo.
When an anonymous player signs in with an account that already exists, a callable moves their progress to it and deletes the anonymous user. It sounds like a detail, but "I lost my streak when I logged in" is a classic one-star review.
2. Images: small, stripped and off Firebase
Photos are the expensive part of an app like this, mostly because of egress. So:
- The phone uploads to a private path; a Storage trigger re-encodes to WebP at 720 px with no metadata (bye, GPS in EXIF) plus a 96 px thumbnail, and deletes the original.
- Files are served from Cloudflare R2, which has no egress fees. Switching was a one-module change because every read of an image URL goes through a single
imageStorehelper. - The crop is centred on the face (the phone sends the face box from ML Kit, so the server doesn't need to run detection).
3. Moderation: two cheap checks instead of one expensive one
Every photo goes through Google Vision SafeSearch (strict) and a Gemini prompt that checks the things SafeSearch doesn't care about: is it a real selfie, is the face covered, is there a phone number or an @handle written on it, is the photo rotated. Each one can reject, publish, or send the photo to a human review queue in an admin panel.
The AI also gives an age estimate, but it's deliberately not the answer. The whole point of the game is that people decide; the AI is shown as a contrast after the crowd has spoken.
4. One troll can't move your age
If 20 people guess your age, one joker voting "90" shouldn't drag your result up. The aggregate is:
- median while there are fewer than 10 votes;
- above that, drop guesses further than
max(8, 3 × MAD)from the median and take a 10 % trimmed mean.
It's a few lines of pure JavaScript with unit tests, and it made the results feel noticeably more "fair" to players.
5. Hot documents are sharded
A popular photo can get many votes per second. Instead of incrementing a counter on the photo, each vote writes to one of N shard documents (voteShards/{photo}_{n}) and a scheduled function folds the shards into the photo every 5 minutes. The same trick is used for the daily challenge stats, the metrics and the "how old do I look?" links people share on Instagram.
The rule I ended up with: if many people can write to the same document at the same time, it's a shard, not a field.
6. Everything that only matters for a while expires
Votes, live 1v1 matches, duel rounds, rate-limit counters… all of them carry an expireAt field and a Firestore TTL policy. Photos and accounts never expire (they're deleted when the user asks), but nothing else grows forever. One gotcha: TTL fields must be excluded from indexing, and then you can't query by them — the emulator won't warn you, production will.
7. A budget that defends itself
Cloud billing alerts publish to a Pub/Sub topic, and a function listens:
- 50 % of the monthly budget: just a warning.
- 80 %: "saving mode" — AI features and new uploads pause, the game keeps working.
- 100 %: "essential mode" — the public website returns 503 and no new web players are created.
- 300 %: billing is detached from the project.
There's also an emergency brake: if Firestore reads go above a threshold per minute, a Cloud Monitoring alert flips a flag that throttles every callable per account and per IP until the incident closes. All callables go through one wrapper, so these switches (and per-user rate limits) live in a single place.
8. Cold starts vs. cost
Cloud Functions v2 run on Cloud Run, and a cold start was 2.5–4.6 seconds in my measurements — terrible for a game screen. Keeping minimum instances warm costs money per instance, so instead:
- each function only loads its own module (
FUNCTION_TARGETdecides whatindex.jsrequires); - a small scheduled function calls the 24 functions that open screens every 5 minutes with
{warm: true}, which the wrapper answers without auth or reads.
Warm calls take 50–300 ms. The scheduler costs a fraction of one always-on instance.
9. Tests that run against emulators
There are more than 300 unit tests for the pure logic and about 150 integration tests that run against the Firebase emulators (functions, Firestore, Auth, Storage) before every deploy. The Flutter app has its own widget tests, including a sweep that renders every screen at 320×568 with 130 % font size in all 9 languages to catch overflows. That last one has saved me more embarrassment than anything else.
What I'd tell myself at the start
- Write the abuse cases before the features. Rate limits, per-IP caps and kill switches were cheap to add early and would have been painful later.
- Measure in production, not in the emulator. Missing indexes, cold starts and TTL behaviour only show up there.
- Keep the game loop free of the network where you can. Prefetch the next faces while the player is still looking at the current one.
If you want to see how it plays, there's a daily challenge you can try in the browser without installing anything: agedart.app/daily. The Android app is on Google Play (iOS is in review). And if you've ever wondered how old you look to strangers, this is the guide I wrote about it.
Happy to answer questions about any of these pieces in the comments.



Top comments (1)
Any comments I will appreciate it, thank you!