Inspiration

Everyone has an intuition for distance, and everyone is wrong about it. Paris to Berlin feels about the same as Paris to Rome. It isn't. That gap between the map in your head and the real one is the whole game.

The daily-puzzle format proved that a small, finished, once-a-day ritual beats an endless feed. Far Off takes that ritual and points it at the planet: two cities, one number, twenty seconds.

What it does

Two photos. One distance to guess.

Far Off shows you two cities from around the world, in photos taken at eye level — no satellite imagery, no isolated landmark, the city as you see it when you arrive. You type how far apart they are. Then a counter races from your answer to the true distance, and a plane draws the route on the map.

  • Five games a day, refilled every night, identical for every player on Earth.
  • Scoring that rewards accuracy — up to 1,000 points per round, plus a bonus for three good answers in a row.
  • A weekly world leaderboard: the top 100 and your exact rank. Monday, everyone restarts at zero.
  • A share card with your two photos, your answer and the truth — or a spoiler-free grid to challenge friends.
  • A streak: seven days in a row earns a bonus game. Computed server-side from results, so reinstalling never costs a player their streak.
  • 300 cities, 44,850 possible pairs, four languages (EN, FR, DE, ES), no account, no ads.

Far Off Unlimited (RevenueCat) unlocks bonus games, played back to back — and they count on the leaderboard.

How we built it

  • App — Expo / React Native (New Architecture), iOS first. Native, not a webview, because the counter animation, the haptics and the notifications are the product.
  • Backend — Supabase: Postgres with RLS, anonymous sessions, a leaderboard RPC, and edge functions daily / guess / extra / streak. The true distance never reaches the client: the daily payload ships without it, and the submission endpoint returns truth and score together.
  • Content pipeline — Wikidata + Wikimedia Commons ingestion: 300 hand-verified places, photos re-cropped to 4:5, output as versioned JSON with a manifest, served from a CDN. Content ships without shipping an app build.
  • A shared, pure core — packages/core holds the domain (scoring, daily selection, streaks, the price anchor) with zero React / React Native / Expo imports, locked by a test that walks the import graph. The marketing site (Astro) consumes the exact same module, so the site and the game can never disagree about a rule.
  • Monetization — RevenueCat (react-native-purchases) behind a small Purchases interface, so the whole paywall → purchase → bonus game → leaderboard path is playable in Expo Go against a fake implementation, and swaps to the real SDK with one line in a dev build.
  • Analytics — PostHog (EU hosting). The player is identified by their Supabase anonymous session — the same id used as the RevenueCat appUserID, which is what lets the server check a right.

How RevenueCat is wired in

RevenueCat is not a paywall bolted on top; it is the source of truth for a right the server enforces.

  • One entitlement, unlimited. The app configures RevenueCat with the Supabase anonymous user id, so client, server and analytics all speak about the same person.
  • Before granting a bonus game, the edge function resolves the entitlement itself: a cached activeUntil decides while it is in the future, otherwise it asks RevenueCat and remembers the answer. One external call per subscription period per player, and no webhook to keep in sync. A client that fakes a purchase gets a 403, not a leaderboard entry.
  • The three-state result (premium / free / unknown) is deliberate: when RevenueCat is unreachable we don't decide on its behalf.
  • Prices are never recomputed. The paywall renders priceString from the store, and the "−51%" anchor is derived from the real amount and currencyCode — it rounds savings down, stays silent under 5%, and refuses to compare two currencies.
  • Trial copy follows the store, not the code: introPrice decides whether the button reads "Subscribe" or "Try N days free", so adding an intro offer in App Store Connect changes the app without a release.

The monetization strategy (HAMM)

The free game is the trial. Five games a day, free forever. The paywall appears only after the fifth round — never before, never mid-game. A player has already had the whole experience before being asked for anything.

So: no free trial, on purpose. Stacking a 7-day trial on top of a permanently free game offers the same thing twice, and bills day 7 to people who decided nothing.

A two-rung ladder built to make the annual obvious: €39.90/year against €6.90/month. The monthly rung isn't there to be sold — it's the reference that makes the annual read as cheap (€3.33/month, −51%). Both live at the same level of the subscription group, so switching is a crossgrade, not an upgrade. The direct comparable, GeoGuessr, runs the same mechanic 17% more expensive.

One promise, in bold: "Play unlimited." An earlier version led with three benefits and none of them landed; the leaderboard and the free quota moved into the body copy.

Two copy variants, one mechanic: euphoria when the day went well (≥65% of max), more otherwise. Same price, same promise — the variant is an event, not a different experience.

Every failure is instrumented: offerings_failed, offer_unavailable, purchase_failed, paywall_dismissed{had_offer}. A paywall that sells nothing because the store is mute has to be visible in the numbers, not inferred.

Design and art direction

Photography does the work. Every one of the 300 cities is a real photo at street level, re-cropped to a single 4:5 frame so two cities can be stacked without either one winning. Dark UI, one accent, Fredoka for the numbers — because the number is the hero.

The moment to watch is the reveal: the counter doesn't cut to the answer, it races to it, haptics ticking, while the plane draws the great-circle route across the map. Being wrong is the best part of the game, so being wrong had to feel good.

Challenges we ran into

  • Never trusting the client. Score, truth, streak and entitlement are all server-side. It changed the shape of every screen — the app asks, it doesn't compute.
  • Anchoring a price honestly. Two prices in different billing units read as one number to the eye. The anchor had to be expressed in the other offer's unit, computed from real store values, with the actually-billed amount still the biggest thing on the card, as Apple requires.
  • Not shipping a paywall that can't sell. A TestFlight build without a RevenueCat key shows neither "want to play more?" nor a dead paywall — App Review asks what a screen does, and "nothing" isn't an answer.
  • Sharing logic without sharing UI between a React Native app and an Astro site, and proving it with a test on the import graph rather than a convention nobody enforces.

What we learned

Server-side entitlement resolution with a cache beats webhooks for a small team: no queue, no replay, no drift — one call per subscription period, and the state you show is the state you can defend.

And the older lesson, paid for on a previous app: paid acquisition on a €6.90/month game doesn't pay back. The free ritual, the share card and SEO have to do the acquisition, which is why they were built before the paywall was.

What's next for Far Off

Android, more cities from the same pipeline, a friends leaderboard, and RevenueCat Experiments on the paywall variants that are already instrumented and waiting.

Built With

Share this project:

Updates

Submission history