Ashenmarch turns the real world into a medieval territory war. Every walk, run, ride, or swim you take is a march — your GPS route encloses real-world ground and claims it for your House. Hold it, wall it, and watch rivals try to take it back; neglect it and it decays.
Live on the App Store and Google Play, in 44 countries and four languages — two native clients over a shared Go/PostGIS backend, with a real subscription taking real money.
Inspiration
Fitness apps track your run and hand you a number. We wanted the run to mean something the moment it ends — to leave a mark on a shared world. The medieval feudal frame did the rest: land you can win and lose, Houses and rivalries, a Herald that narrates your conquests, coats of arms you design.
The result is a game where the motivation to go outside is territorial, not numeric — you run because someone is besieging your holdings, or because there's unclaimed ground three blocks over.
What it does
- March to claim. Start a march and go walk, run, ride, or paddle/row on water in the real world. Your GPS route encloses area; enclosed ground becomes territory owned by your House, drawn on a live war-map.
- Build & defend. Out-and-back routes raise walls; connected holdings form Districts. Territory decays over time, so an empire is something you maintain, not just win once.
- Invade. Run through a rival's holding to drive down its defenses; enclose it and it flips to you — you take only what you actually enclose.
- Every fitness level counts. Walking, running, Cavalry (cycling) and Navy (water) are each first-class, with their own baselines and identity. Fortification is presence-based, so a beginner walking slowly inside a court member's region contributes real defensive value — there is no pace at which you stop mattering.
- A feudal world. Houses, Courts and vassals, contested Kingdom Regions, city leaderboards, and a Herald that turns your runs into feudal dispatches and re-engagement notifications.
- Signature share cards. Every campaign renders as a heraldic parchment Conquest Share Card — your crest, your conquered ground, your chronicle — made to be shared.
- An earned economy. Territory yields Gold tribute banked in your House Vault, spent on cosmetics. Earned only, never bought.
The grander the holding, the more often it demands you
Decay is not a single curve — it is a tolerance per fortification tier, and it runs backwards from what you'd expect:
| Tier | Days before it begins to decay |
|---|---|
| Citadel | 5 |
| Stronghold | 7 |
| Keep | 10 |
| Village | 14 |
| Outpost | 21 |
A Citadel is the most powerful thing you can build and the most demanding to keep. Walls decay on their own clock (14 days, 7 if reinforced), and a House that can't pay its upkeep has its decay accelerated rather than its land confiscated — you are never locked out, only pressed.
How we built it
- iOS — native Swift / SwiftUI: live-GPS capture with background activity sessions, Live Activities / Dynamic Island for the active march, MapLibre for the in-app map, and a bespoke parchment Canvas renderer for the heraldic war-map and share cards.
- Android — native Kotlin / Jetpack Compose: a foreground-service march with the same background-survival guarantees, Compose Canvas parity for the war-map and share cards, and Glance widgets.
- Backend — Go with PostgreSQL / PostGIS for the real-time territory geometry (enclosure, invasion splits, walls, decay), a runtime game-config layer so balance changes ship without a release, and a feudal narrative engine.
- Web companion — SvelteKit / TypeScript portal that mirrors the game for imported runs.
- Monetization — RevenueCat powers the premium Membership (monthly + annual, 30-day free trial) through one subscription group and the
premiumentitlement. One integration spans StoreKit and Google Play Billing, and entitlement is validated server-side — RevenueCat webhooks land on the Go backend, which is the only thing that decides what a player owns. The client never gets a vote. - Localization — English, Spanish, French and Portuguese in-app, generated from a single canonical source of truth in git, with regionally-targeted store listings (es-419, fr-CA, pt-BR).
- Platform — Fly.io (app + Managed Postgres), Firebase Cloud Messaging / APNs push, Sentry + MetricKit for crash and exit-reason telemetry, PostHog analytics, GraphHopper route planning, Strava import.
Challenges we ran into
Keeping a march alive for 45 minutes. This was the number-one launch blocker for months, on both platforms. A live GPS session the OS is actively trying to reclaim is the product — if tracking dies at minute 38, the run didn't happen. It took background activity sessions, foreground services, crash-orphan recovery on next launch, and app-exit-reason telemetry so we could tell an OOM kill from a warm return instead of guessing.
One Spanish word cost a release. The longest Spanish tier name wrapped a pill at 375pt. Invisible in code review, invisible in English, and it shipped — version 1.0.2 exists to fix it. The lesson generalised: every screen now gets a render pass at the narrowest width and the largest font scale, in every language.
A double-charge that only appears on a timeout. Our spend flows minted the idempotency key inside the tap handler, so a request that timed out and retried generated a new key and charged twice. It hit three separate surfaces before we recognised it as one class of bug and moved the key to session scope.
Silent failures beat loud ones every time. A hardcoded country-suffix check decided what language to name a city's provinces in — so every Spanish-speaking market outside Mexico quietly got English place names. Nobody reports that, because an English place name still looks like a place name. Most of our worst bugs looked exactly like success.
Accomplishments that we're proud of
- Two native clients, both stores, one window. iOS and Android are genuinely native — not a shared runtime — and reached production days apart.
- Real-time territory geometry. PostGIS handles route enclosure, invasion splits, wall raising and time decay against live player data, fast enough to render a war-map the moment a march ends.
- Four languages at launch, not retrofitted — every new player-facing string ships translatable by definition of done.
- A real subscription, server-validated. Trial to paid, monthly and annual, across two stores, with entitlement decided by the backend rather than trusted from the device.
What we learned
Instrumentation you don't verify is decoration. We found a blocking CI gate that had been checking one of its four inputs for months — the other three silently passed on empty sets. A blind gate and a clean gate produce an identical log. Coverage is now explicit: a check that cannot see its input must say so and fail, never skip quietly.
Count the places that must agree. The same defect — "what languages exist, declared in two places" — appeared independently on iOS, Android, the backend, and the place-name generator. Four bugs, one habit. The fix was never a better process; it was one manifest and one assertion.
"It's on main" is not "it's live." Several shipped features were inert in production because a one-time job or config flag was never applied. We learned to verify the effect in the database, not the code in the branch.
What's next
- Apple Watch and Wear OS — a march you start from your wrist, with the phone in your pocket or left at home.
- German and Italian, shipping together. Languages don't interact, so one layout pass finds both sets of bugs.
- Local place names as world content. The generator already builds feudal toponymy from real map data — English and Spanish today. Dutch and Nordic vocabularies are next, because a player in Utrecht or Aarhus should be conquering ground that sounds like their own country, not someone else's.
Built With
- activitykit
- apns
- core-location
- firebase-cloud-messaging
- fly.io
- go
- graphhopper
- ios
- maplibre
- metrickit
- postgis
- postgresql
- posthog
- revenuecat
- sentry
- storekit
- sveltekit
- swift
- swiftui
- typescript


Log in or sign up for Devpost to join the conversation.