Inspiration

A hide-and-seek game needs two things that almost no mobile game has at the same time: a place that belongs to somebody, and somebody who cares enough to look for it there. Your camera roll is already both.

Hidloo started as a narrower question than that, though. Not what should the game be — can the loop close?* Player A does something, player B does something back, and A gets a reason to return. Most social games never close that loop; they just notify you that something happened. So the whole project became an argument about one sentence:

You hide a figure in a photo of your own room, send a six-letter code, and watch somebody hunt for thirty seconds.

What it does

The one-photo game. Pick a photo. Drag a figure onto it — behind a lamp, in the pattern of a rug. A six-letter code comes out. Whoever opens it has thirty seconds and three taps. Three taps, not unlimited: a round you can brute-force is not a round.

The miss map is the actual product. When the round ends, the person who hid it sees every wrong tap from every player who tried, drawn on the same photograph, plus how many were "near misses" — within one figure-width of the answer. That screen is the only thing in Hidloo that makes you hide better next time, and it is the reason the loop closes at all. A score tells you that you won. A miss map tells you how close they came to your idea.

Sudden death. A twelve-second variant with exactly one tap, scored by how far that tap landed from the figure in units of the figure's own half-size. Hit, near, or far — three bands, computed on the server from one number the phone reports.

Six Windows. The newest mode, and the one that changes what the game is. Six photographs of the same room are projected as the six faces of a cube, with the camera inside it. You drag to look around; the room continues behind you. Four figures instead of one, ninety seconds and six taps

How we built it

lutter and Dart on the phone; Firebase for everything a phone has no right to decide. 79 Dart files, 28,006 lines. Two languages (English as Default and Spanish as second option), 1,464 localized strings in one file that cannot drift from itself.

Six Windows is a CustomPainter, not a 3D engine

No three.js, no Unity, no Flutter 3D package. Six ui.Images and a CustomPainter:

  • The cube is kCubeHalf = 1000 units, a 72° field of view, pitch clamped to ±88° so you can never quite look straight up and gimbal-lock the yaw.
  • Each face is split into a kFaceGrid = 6 grid. Each cell gets its own canvas.transform(Float64List) and one drawImageRect. Thirty-six draws per face instead of one gives you enough of a piecewise-affine approximation that a flat photograph reads as a wall you are standing next to.
  • Cells are culled against a near plane at 0.12, cell by cell.

The insight that made it work is small and not obvious. depthAt is affine across a face, which means a cell's minimum depth is always at one of its own four corners. So the culling grid has to be the grid of whatever it guards — a kFaceGrid + 1 square of corner depths.

We learned that the hard way. In one iteration the picture was drawn on a panel grid while being depth-tested against the face grid. The two grids were not nested, so a cell could survive the test with a corner sitting at depth ≈ 0 — and a divide-by-almost-zero in a perspective transform does not produce a small error. It produces a triangle that explodes across the entire screen.

The server owns every number that is worth cheating for

4,099 lines of Cloud Functions across 17 deployed functions in us-central1. The phone reports what happened; the server decides what it was worth. Scoring, badges, streaks, glints, referrals, moderation and notifications all live there.

The replay exploit, and the fix that is one line of naming. A seeker who ran out of time is shown where the figure was — that is how a round ends, and it is right. But they could press back, re-enter the same code, and tap the spot they had just been handed. The app wrote a second result, and the scoring function fires on document creation, so it paid them. Worse, the same trick turned a slow honest find into a fast fake one — the tempting half, and the half nobody noticed.

The fix is that Firestore's create only applies to an empty path. Derive the path from the pair:

results/{code}__{seekerId}

…and the second attempt becomes an update, which the rules allow for exactly three named fields. Then the rule that actually matters:

allow create: if request.resource.data.code is string
              && request.resource.data.seekerId is string
              && id == request.resource.data.code + '__'
                       + request.resource.data.seekerId
              && ...

Without that id == check a modified client writes to a fresh random path and gets paid again. With it, the only path this player may ever write for this code is the one they have already used. The type checks come first on purpose: && short-circuits left to right, and concatenating a non-string would fail the whole rule with an evaluation error instead of a clean denial.

471 lines of security rules, and every unbounded field is bounded. misses is capped at 3 — or 6 in a room — because that is the most taps a truthful client can ever have spent. It is the one field on the one document a phone may write that has no natural size, which makes it the cheapest way to run up somebody else's storage bill.

Moderation that knows who the photo is going to

Every photograph passes Cloud Vision before a code exists. But the filter is not the same filter for everybody:

  • Between people who have played together: SAFE_SEARCH_DETECTION + LABEL_DETECTION.
  • Between strangers: those two plus TEXT_DETECTION and FACE_DETECTION.

SafeSearch grades a photo for skin and blood. It does not read, and it does not know who is in frame. Between two people who chose each other, a real relationship sits behind the picture. Between strangers, those two blind spots are the attack surface: a screenshot of an insult and a photograph of somebody's child both sail straight through a filter that is only looking for adult content.

And screening is paid for once. A room is six photographs. Each is screened as it is picked, and the result is stored as a receipt keyed by sha256(base64). When createRoom runs, it checks six cheap receipts instead of paying Cloud Vision six more times.

Six photos do not fit in a document

A Firestore document is capped at one mebibyte, keys and all. Six photos at the size the one-photo game already uses come to roughly 700 KB of base64, and the placements and names still have to fit. So the room's rules live in challenges/{code}, where every existing lookup already finds them, and the six pictures live one per document in challenges/{code}/faces/{0..5}.

That split created a security bug that is worth naming, because it is a rule people assume and it is false: Firestore security rules do not cascade. Blocking the parent challenge left the subcollection fully readable to anybody still holding the code — which is precisely the thing withdrawing a room was supposed to stop. A one-photo challenge keeps its picture inside the document, so blocked: true was enough and the habit was already formed. dropFaces() now takes the six photographs away explicitly, and the challenge document itself survives, blocked, so the app can say what happened instead of pretending the code was never real.

Purchases: one entitlement, two stores, and the app never learns which

purchases_flutter 10.7.0 — which resolves to the native com.revenuecat.purchases:purchases 10.16.0, purchases-hybrid-common 18.28.0 and com.android.billingclient:billing 8.3.0 in the shipped Android bundle.

Exactly two files in the app import the RevenueCat SDK: lib/purchases.dart (the whole surface) and lib/paywall_screen.dart (only for the Offering and Package types). Nine other files call through a wrapper. The entire decision the rest of the app ever makes is one line:

void _apply(CustomerInfo info) {
  isPro.value = info.entitlements.active.containsKey('pro');
}

The same product id, hidloo_pro, on both stores, attached to the same pro entitlement. The app never learns which store it is on, never sees a receipt, never sees a refund. And Pro.start() hands Purchases.configure the same player id the rest of the app uses — the Firebase uid when signed in, the guest id otherwise — which is what makes a purchase follow an account to a new phone instead of being stranded on the old one.

The guard that exists because the failure already happened. A test_ key once shipped to twenty-one closed testers. The SDK does not degrade quietly: it puts up a native "Wrong API Key" dialog and takes the process with it, before any Dart code could catch anything. Every tester got a game that closed itself on launch.

if (!kDebugMode && (key.startsWith('test_') || key.endsWith('REPLACE_ME'))) {
  debugPrint('RevenueCat: sandbox key in a release build; shop disabled.');
  return;
}

Refusing to configure costs the shop and nothing else. A build that cannot sell is a bad afternoon. A build that will not open is a bad launch.

The rest of the stack

Firebase Auth (Apple, Google, email, and a guest identity that survives sign-in), Firestore, Cloud Functions v2, Secret Manager, Hosting. OneSignal 5.6.8 for push. Codemagic for iOS. Rate limits everywhere a phone can write: 40 challenge creations per hour and 150 per day, 60 screens per hour and 300 per day, 60 sends per hour and 200 per day, fan-out capped at 10.

Challenges we ran into

The seam — three attempts, three rejections

Six different photographs of the same room do not line up at the edges. Three fixes were tried and all three were thrown away:

  1. A gradient at the borders. Rejected without building it: a shadow cast by nothing is a filter. And on a photo with real directional light it would fight the light that is actually in the picture.

  2. Inset panels with a gap. Built completely, reviewed, shipped to a device — and reverted on sight. On paper it is correct: it stops pretending there is continuity. On screen the gaps read as blue wedges, and where three faces meet they form a thick Y that is louder than the seam it was hiding.

  3. Changing the input to a 360 photo. Built, and then verified numerically before being deleted. The finding: a phone "Panorama" is a 120–270° cylindrical strip, not a 2:1 equirectangular sphere. All three candidate mappings were simulated in Python against a real panorama taken on the author's own phone — four-slice was grotesquely zoomed, true cylindrical left a solid hole behind the player, and treating it as equirectangular produced a pinwheel at the ceiling and the floor, breaking the one promise the mode makes. Nobody with an ordinary phone can shoot a real photo sphere, so the feature was removed entirely rather than shipped as a trap.

The seam stayed. A visible seam is more honest than a frame that shouts.

Documentation that lied twice

Twice this project had a file that said the shop worked when it did not. First an entitlement named Hidloo Pro, which can never match containsKey('pro') — answering false forever, on both platforms, with no error and nothing to search for. Then a hidloo_pro typed by hand into the RevenueCat dashboard over a Play catalogue that was empty; the dashboard happily accepts an identifier without checking that a store has one, which is exactly what its Not found status is reporting.

Both times, the written record was the thing that stopped anybody from looking. The only proof a purchase works is a purchase that survives a reinstall.

Apple, twice

Guideline 2.3.10: the binary contained non-iOS status bar images — Samsung screenshots used as in-app illustrations. Cropped to 560×1101, and the same four images fixed on the marketing site, where the width/height attributes had to change with them. A string reading "Android" was also being shown on iPhones. Small, all of it, and none of it ships without a review round.

A gate that could not be opened

Six Windows was locked behind 1,500 points, 300 glints and one player referred. Good intent — and mathematically impossible for anyone playing alone, because recordResult drops self-plays on purpose:

if (hiderId.isEmpty || seekerId == hiderId) return null; // skip self-plays

No result means no award, which means no points, no glints and no badges. Every one of the six glint sources needs a second human. A new player with nobody to swap codes with watched three zeroes that could not move — and the practice round that would have taught them the mode was itself behind the same door. A lock with the key inside the locked room.

Finishing one practice round now opens the mode as well. Nothing was removed; the three counters still stand and still open it.

Accomplishments that we're proud of

  • It is published, not staged. Google Play and the App Store available.

  • The miss map. The one screen that turns a result into a better next hide.

  • Scoring that refuses to reward cruelty — the room curve peaks at three of four figures found, on purpose.

  • 471 lines of security rules where every writable field is typed and every unbounded one is bounded, and a replay exploit closed by naming a document after the pair it belongs to.

  • Moderation that is stricter for strangers than for friends to ensure safety and provide a secure environment for users.

  • Over 100 downloads of the application on the Play Store; real players using real devices.

  • Over 560 impressions on the App Store, with more than 100 users installing the iOS application. Implementation of notifications using OneSignal for segmentation, campaigns, tracking, user retention, and driving app growth.

  • Available in both English and Spanish.

  • Allows for gameplay within groups of family, friends, or coworkers. It serves as an app for casual gaming that adapts to seasonal events—such as Halloween, Christmas, Thanksgiving, and New Year’s—making Hidloo suitable for a wide range of gatherings, celebrations, marketing initiatives, and social gaming.

What we learned

The hard part of a social game is not the game. It is the return trip: having something worth seeing when the other person finishes, and making that something a reason to hide again. Everything that survived the cut serves the return trip.

Security rules do not cascade, and habits formed on a simpler shape are the ones that bite. One-photo challenges taught us that blocked: true was enough. Rooms were a different shape and the habit was silently wrong.

A document that says "done" is more dangerous than one that says nothing, because it is what stops anybody from going to look.

A correct idea can be a bad product. The panels were right on paper and awful on a screen. We built them, looked at them on a real phone, and threw them away — which is cheaper than being right for eight weeks.

And a gate that cannot be opened is not strict, it is broken. We found that by doing the arithmetic on our own economy, from the outside, as a stranger would.

Service Configuration in Firebase. Integration of the backend with the Flutter application.

App Store Release. First time a mobile app has been developed and put into production.

Codemagic. iOS build using the main branch from the existing repository.

YouTube Content Creation. Publishing episodes that showcase the development progress of Hidloo, including milestones, bugs to fix, added features and functionality, integration with RevenueCat SDK and codemagic, implementation of OneSignal, store registration for Android and iOS, and much more as detailed in the episodes.

What's next for Hidloo

  • Sending a room in-app. The server would already accept it — the invite document only carries a code — but the room dialog only shares a link today.

  • The miss map over six faces, so a room's creator sees where people looked inside their own cube.

  • /c/{code} for rooms. The link page still assumes one photograph and serves generic copy for a room.

  • Seasonal figure packs. The catalogue and the entitlement were built for exactly this: one more pack is a dashboard change and a content drop, not an app update.

  • Add an option to let players see who is currently online and playing.

Built With

Share this project:

Updates

Submission history