Product // Architecture // Gamification

Dromo

A running app that turns every outing into a round: drops scattered across the city, XP per kilometer and a weekly league. The map isn't fixed — it grows and shrinks with the community of runners.

Role
Development & architecture
Year
2026
Status
In development
Stack
Flutter · Dart · Python · Azure Functions · PostGIS · Mapbox
A GPS trace of an urban loop with hexagonal drops captured along the route

Dromo is a running app with a game layer on top. While your activity records over GPS, the map fills with drops — collectibles seeded at real points around the city — that capture themselves as you pass close by. Every drop adds XP, every kilometer adds XP, and the week's XP decides your place in the league.

The interesting part isn't the collectible. It's that the map is dynamic: there's no hand-placed list of fixed points. A density system decides every week how many drops each neighborhood can sustain based on how many people actually run there, then creates, rotates and retires them.

4Deployed services
7 daysMap cycle
5 XPPer kilometer run
35Runners per league
Three Dromo screens: nearby drops on the home tab, a drop captured mid-run, and the weekly league podium
Home, mid-run capture and weekly league. Screenshots taken against real backend data.

The loop

You open the app and the first thing is a question: what do I do now?. Below it, the drops near you and the button to start running.

During the run the route draws live. When you enter a drop's radius it captures on its own — no button, no need to look at the phone — and the card with the XP earned appears. When you finish, the activity is saved with its route, the XP goes into that week's league, and the captured drops join your collection, grouped by rarity.

Rarity isn't a separate label: it is derived from the drop's XP. 45 or more is epic, 25 or more is rare, everything else common. A single number decides the color, the label and the weight in the route ranking.

The architecture

Four pieces, each with a job it doesn't share with the others:

  1. rundrop/ — The app. Flutter with Material 3, flutter_bloc for per-feature state, get_it for injection and go_router with StatefulShellRoute so each tab keeps its own navigation stack. Maps via mapbox_maps_flutter, location via geolocator, session via Firebase.
  2. dromo-api/ — The API. Azure Functions v4 in Python over PostgreSQL with PostGIS. No framework: decorators, psycopg2 and plain SQL. Users, activities, captures and leagues.
  3. dromo-positioning/ — The map's density system. A timer-triggered Function that runs Mondays at 01:00 Colombia time and decides where each drop lives.
  4. dromo-route-recommender/ — The route recommender. Takes the runner's profile and returns five loops with real polylines.

The map that breathes

This is the piece I rewrote the most times, and the one that defines the product.

The first model was a relocator: a table of drops and a process that changed their coordinates. It had two problems, and the second one was serious.

The first: rewriting the row destroyed the history. The second: since capture uniqueness is keyed on drop_map_id, moving a drop permanently locked out anyone who had already captured it — a new point appeared on their map that their account considered already claimed.

The current model never issues an UPDATE. Every placement is a new row in drop_map. A drop doesn't move: it retires and another one is born.

On top of that, the weekly cycle:

  1. Sweep what expired — placements whose window has closed get unpublished.
  2. Group runners into zones by geographic proximity, with a 5 km radius. Only people active in the last 30 days count; otherwise a ghost zone holds drops where nobody runs anymore.
  3. Compute each zone's target density: 1.5 drops per runner, with a floor of 3 and a ceiling of 12. The map grows where people show up and contracts where they stop.
  4. Execute the plan — create where drops are missing, rotate the expiring ones so this week doesn't repeat last week's route, and retire the excess.
  5. Safety net — if a region ends up with no live drop at all, seed a baseline of 10 around its center. The map never shows up empty.

The urban/rural split is deliberate too. A ring sampled uniformly by area pushes drops outward — 84% landed more than 2 km from the zone's center, meaning the edge of the city — and people train inside it. Today it's 7 out of 10 in the urban band (0.4 to 2.5 km) and the rest out to 6 km, with a minimum separation of 0.7 km between live drops.

The GPS filter

XP comes from kilometers, so distance has to be honest. GPS on its own isn't: it drifts while you stand still, jumps a hundred meters when you leave a tunnel, and emits garbage during the first seconds.

Positions pass through a filter before they count: accuracy rejection, a warm-up window, impossible-jump rejection with re-anchoring, Kalman smoothing, and drift suppression while you aren't moving.

What matters is the single-source rule: the UI draws what the service accepted, and never filters again. Two filters in two layers is the recipe for the map and the counter disagreeing with nobody able to say which one is lying.

Tracking runs in a separate isolate with flutter_background_service — a foreground service on Android — and the state is persisted to disk, so the activity survives a locked screen and the system killing the app mid-run.

The league

ISO week: it starts Monday 00:00 Colombia time, not the server's. Azure Functions runs in UTC, and a league that closes five hours before the countdown the app displays is a broken promise.

Each league holds 35 seats: up to 25 real runners and 10 NPCs. When the week closes the top 7 are promoted, the bottom 5 demoted, and everyone else stays. Users are reshuffled at random into the next one so they don't always compete against the same people.

NPCs have random weekly XP that is static: it doesn't grow during the week. Since the real runner does earn XP, they experience it as climbing the table. It's a launch crutch — a two-person leaderboard isn't a leaderboard — and it's bounded: only real users get promoted or demoted. I wrote about why that decision bothers me in the article on gamification.

A separate service answers "I want to run 10 km, where?":

  1. It estimates the reachable area with Mapbox's Isochrone, using the typical pace of the runner's division (from 8.0 min/km at beginner to 3.5 at elite) plus a 15% margin.
  2. It finds the active drops inside that area with PostGIS.
  3. It builds five variants — base loop, maximum drops, new territory, reverse loop, and drops plus exploration — each a closed circuit with three waypoints.
  4. It requests the real polylines from Directions, in parallel.
  5. It ranks them out of 100 points: proximity to the kilometer target (40), drops weighted by rarity (30), difficulty fit (20) and novelty of the area (10).

That last factor is the one that actually matters: 10 points out of 100 that exist solely so Sunday doesn't suggest Tuesday's loop again.

What I'd do differently

  • Not run the cycle on the Function's startup. With run_on_startup=True, every restart or scale-out fired a full cycle: 30 cycles where 19 belonged, moving drops under the feet of people who were running. A cron that rewrites the shared world shouldn't be triggered by an infrastructure event.
  • Model placements as append-only from day one. The locked-capture bug wasn't an edge case: it was the direct consequence of storing an event as if it were a state.
  • Stop duplicating the response helper. _response is copied verbatim into every API module. It's a cheap decision you pay for every time the error shape changes.
  • Finish the migration before opening new tabs. The feature-based structure and the legacy one coexist, and some tabs still point at HomePage as a stub. Half a migration costs more than none.

Where it stands

In development, running against the real backend: the API, the positioner and the recommender are deployed on Azure, and the app runs on Android and iOS. The screenshots above come from a test account over real data.

The repository is github.com/santiago01est/DROMO.

Next project