Inspiration

I started by looking around the room instead of at a screen. A cezve, a sewing machine, a cast iron stove. Things that get used a thousand times and never looked at twice. Roman households kept lares, small gods who lived in the house and watched over it. I wanted the phone to be the thing that finally notices them.

So I set one rule: the game ships with no roster. Whatever you fight with has to come out of your own kitchen. That single constraint decided almost every technical decision that followed.

What it does

Point the camera at an object. The server identifies it, gives it a name, a story and a set of stats, and paints it into a card that exists in nobody else's collection.

  • Fill a house. Six homes, from a small flat with twelve slots to a mansion with thirty two. Every one of them is earned by playing. None of them is for sale.
  • Battle. Send a card against another player's. The fight resolves on the server from a seed, so the same inputs always produce the same log and a modified client cannot invent a victory.
  • Raid. Cross a stranger's threshold, seize one object and carry it home. They have twenty four hours to come and take it back. Reputation is public.
  • Awaken. Set a card in a jar and leave it for five minutes, two hours, six hours. When you lift the lid it has changed shape and it has a portrait generated for that card and no other. After three awakenings and a thousand years of age the object leaves your house for good and takes its place in the Pantheon.

Everything the model writes is stored as a bilingual pair rather than a string. The client picks the language, so one card reads correctly in English and Turkish without a second generation pass.

How I built it

Client. About 35,000 lines of Swift across 100 files, SwiftUI throughout. Vision handles the on device cutout that separates an object from its background before the photo ever leaves the phone. ARKit, GameKit for leaderboards and achievements, AVFoundation for sound.

Server. 26 Cloud Functions in TypeScript, roughly 10,000 lines, Node 22 on Firebase, running in europe-west1. Firestore for state, Storage for portraits, anonymous Auth so there is no sign in wall, App Check so the functions only answer the real app.

Models. Gemini 2.5 Flash reads the photo and writes the identification and the story. Gemini 3.1 Flash Image paints the awakening portrait, with 2.5 Flash Image as a fallback.

Payments. RevenueCat, with a webhook into Cloud Functions. Entitlements are never trusted from the device.

The rule I held to throughout: the client renders, the server decides. Battles, raids, scan limits, awakening slots and rarity rolls all happen server side. The app cannot award itself anything.

The monetization is part of the design

This is the piece I care about most, and it is the reason the game is shaped the way it is.

No stat is ever sold. The subscription sells capacity and nothing else: ten scans a day become twenty five, three awakening jars become nine, three loot slots become twelve. The shop also sells decoration, frames and arenas and effects. Not one of them touches a number that enters a fight.

There is a structural reason this holds rather than a promise. Any bonus a card accumulates is written into its stats, not applied as a hidden battle multiplier, which means the matchmaker's power calculation sees it. Getting stronger hands you a stronger opponent. Power is a matchmaking tax, so buying your way up the ladder is arithmetically impossible rather than merely discouraged.

There is also a plain commercial reason for capacity limits. A scan costs about a third of a cent. A generated portrait costs about four cents, more than ten times as much. Daily ceilings are not artificial scarcity, they are the shape of the bill. Being honest about that made the design better: the free tier is genuinely playable and the paid tier is genuinely optional.

Challenges

A crash that only existed in Release. async let was miscompiling under optimization in the toolchain I was on. Nothing reproduced in Debug, which is the worst possible failure mode. Every concurrent fetch in the app got rewritten to use explicit tasks.

A matchmaker that filtered too late. Practice houses lived in the same Firestore collection as real players and were removed in JavaScript after the query came back. But limit(6) runs on the server and the filter runs on the client, so once a bot entered the window there was no way to pull a real player in to replace it. Worse, bots are deliberately generated around the player's own power level, so they crowded the window by construction. The fix was to make the wrong query impossible rather than to remember a filter: practice houses now live in their own collection.

A rejection caused by a file format. Apple's automated scan bounced the build because the camera permission prompt literally read "NSCameraUsageDescription" in English. A String Catalog uses the key as the value for the source language, which is correct for normal UI strings where the key is the English sentence, and completely wrong for Info.plist keys. Turkish was fine, so nothing looked broken on my own device for months.

What I learned

Making the server the only authority is more work up front and it pays for itself the first time you need to change a balance number without shipping a build.

And the more useful lesson: decide what you refuse to sell before you decide what you sell. Every monetization question after that answers itself.

What's next

More real players in the pool, which is the one thing I cannot build my way out of. Cosmetic sets per house tier. And an ordinary object I have not thought of yet turning out to be the best card in somebody's collection.

Built With

Share this project:

Updates

Submission history