Dromo
Una app de running que convierte cada salida en una partida: drops repartidos por la ciudad, XP por kilómetro y una liga semanal. El mapa no es fijo — crece y se contrae con la comunidad de corredores.
- Rol
- Desarrollo y arquitectura
- Año
- 2026
- Estado
- En desarrollo
- Stack
- Flutter · Dart · Python · Azure Functions · PostGIS · Mapbox
Dromo es una app de running con una capa de juego encima. Mientras grabás tu actividad por GPS, el mapa se llena de drops —objetos coleccionables sembrados en puntos reales de la ciudad— que se capturan solos al pasar cerca. Cada drop suma XP, cada kilómetro suma XP, y el XP de la semana decide tu puesto en la liga.
La parte interesante no es el coleccionable. Es que el mapa es dinámico: no hay una lista de puntos fija puesta a mano. Un sistema de densidad decide cada semana cuántos drops sostiene cada barrio según cuánta gente corre ahí, los crea, los rota y los retira.

El bucle
Abrís la app y lo primero es una pregunta: ¿qué hago ahora?. Debajo, los drops que tenés cerca y el botón de empezar.
Durante la carrera el recorrido se dibuja en vivo. Al entrar en el radio de un drop se captura solo —no hay botón, no hay que mirar el teléfono— y aparece la tarjeta con el XP ganado. Al terminar, la actividad se guarda con su ruta, el XP se suma a la liga de la semana y los drops capturados pasan a la colección, agrupados por rareza.
La rareza no es una etiqueta aparte: se deriva del XP del drop. 45 o más es épico, 25 o más es raro, el resto común. Un solo número decide el color, la etiqueta y el peso en el ranking de rutas.
La arquitectura
Cuatro piezas, cada una con un trabajo que no comparte con las demás:
rundrop/— La app. Flutter con Material 3,flutter_blocpara el estado por feature,get_itpara inyección ygo_routerconStatefulShellRoutepara que cada pestaña conserve su pila de navegación. Mapas conmapbox_maps_flutter, ubicación congeolocator, sesión con Firebase.dromo-api/— La API. Azure Functions v4 en Python sobre PostgreSQL con PostGIS. Sin framework: decoradores,psycopg2y SQL plano. Usuarios, actividades, capturas y ligas.dromo-positioning/— El sistema de densidad del mapa. Una Function con timer que corre los lunes a la 01:00 hora de Colombia y decide dónde vive cada drop.dromo-route-recommender/— El recomendador de rutas. Recibe el perfil del corredor y devuelve cinco circuitos con polilínea real.
El mapa que respira
Esta es la pieza que más veces reescribí, y la que define el producto.
El primer modelo era un reubicador: una tabla de drops y un proceso que les cambiaba las coordenadas. Tenía dos problemas, y el segundo era grave.
El primero: al reescribir la fila se perdía el histórico. El segundo: como la unicidad de captura es por drop_map_id, mover un drop dejaba bloqueado para siempre a quien ya lo había capturado — le aparecía un punto nuevo en el mapa que su cuenta consideraba ya cobrado.
El modelo actual no hace UPDATE nunca. Cada colocación es una fila nueva en drop_map. Un drop no se mueve: se retira y nace otro.
Encima de eso, el ciclo semanal:
- Barre lo vencido — las colocaciones cuya ventana terminó se despublican.
- Agrupa a los corredores en zonas por cercanía geográfica, con radio de 5 km. Solo cuentan quienes tuvieron actividad en los últimos 30 días; si no, una zona fantasma retiene drops donde ya no corre nadie.
- Calcula la densidad objetivo de cada zona: 1,5 drops por corredor, con piso de 3 y techo de 12. El mapa crece donde aparece gente y se contrae donde deja de haberla.
- Ejecuta el plan — crea donde faltan, rota las que vencen para que la semana no repita el recorrido de la anterior, y retira el exceso.
- Red de seguridad — si una región queda sin ninguna drop viva, siembra una base de 10 alrededor de su centro. El mapa nunca aparece vacío.
El reparto urbano/rural también se decide a propósito. Un anillo uniforme por área empuja las drops hacia afuera —el 84% caía a más de 2 km del centro de la zona, o sea en el borde de la ciudad— y la gente entrena adentro. Hoy son 7 de cada 10 en banda urbana (0,4 a 2,5 km) y el resto hasta 6 km, con separación mínima de 0,7 km entre drops vivas.
El filtro de GPS
El XP sale de los kilómetros, así que la distancia tiene que ser honesta. El GPS, solo, no lo es: deriva estando quieto, salta cien metros al salir de un túnel y manda lecturas basura durante los primeros segundos.
Las posiciones pasan por un filtro antes de sumar: descarte por precisión, ventana de calentamiento, rechazo de saltos imposibles con re-anclaje, suavizado Kalman y supresión de deriva cuando no te estás moviendo.
Lo importante es la regla de una sola fuente: la UI dibuja lo que el servicio aceptó, y no vuelve a filtrar. Dos filtros en dos capas es la receta para que el mapa y el contador discrepen y nadie sepa cuál de los dos miente.
El tracking corre en un isolate aparte con flutter_background_service —servicio en primer plano en Android— y el estado se persiste en disco, así que la actividad sobrevive a la pantalla apagada y a que el sistema mate la app a mitad de carrera.
La liga
Semana ISO: arranca el lunes 00:00 en hora de Colombia, no en la del servidor. Azure Functions corre en UTC, y una liga que cierra cinco horas antes que la cuenta regresiva que muestra la app es una promesa rota.
Cada liga agrupa 35 plazas: hasta 25 corredores reales y 10 NPCs. Al cerrar la semana ascienden los 7 primeros, descienden los 5 últimos, y el resto permanece. Los usuarios se reparten al azar en la siguiente para no competir siempre contra los mismos.
Los NPCs tienen XP semanal aleatorio y estático: no suben durante la semana. Como el corredor real sí gana XP, lo vive como ir escalando posiciones. Es una muleta de lanzamiento —un ranking de dos personas no es un ranking— y está medida: solo los usuarios reales ascienden o descienden. Escribí sobre por qué esa decisión me incomoda en el artículo sobre gamificación.
Rutas recomendadas
Un servicio aparte responde a "quiero correr 10 km, ¿por dónde?":
- Estima el área alcanzable con la Isochrone de Mapbox, usando el ritmo típico de la división del corredor (de 8,0 min/km en principiante a 3,5 en élite) más un 15% de margen.
- Busca los drops activos dentro de esa área con PostGIS.
- Arma cinco variantes —circuito base, máximo de drops, zona nueva, circuito inverso, y drops más exploración— cada una como circuito cerrado con tres waypoints.
- Pide las polilíneas reales a Directions, en paralelo.
- Rankea sobre 100 puntos: cercanía al objetivo de kilómetros (40), drops ponderados por rareza (30), encaje de dificultad (20) y novedad de la zona (10).
Ese último factor es el que importa de verdad: 10 puntos sobre 100 que existen solo para que el domingo no te proponga el mismo circuito del martes.
Qué haría distinto
- No arrancar el ciclo en el
startupde la Function. Conrun_on_startup=True, cada reinicio o escalado disparaba un ciclo completo: 30 ciclos donde debían ir 19, moviendo drops bajo los pies de gente que estaba corriendo. Un cron que escribe el mundo compartido no debe dispararse por un evento de infraestructura. - Modelar las colocaciones como append-only desde el primer día. El bug de la captura bloqueada no era un caso raro: era la consecuencia directa de guardar un evento como si fuera un estado.
- Dejar de duplicar el helper de respuesta.
_responseestá copiado literal en cada módulo de la API. Es una decisión barata que se paga cada vez que cambia la forma del error. - Terminar la migración antes de abrir pestañas nuevas. Conviven la estructura por features y la heredada, y hay pestañas apuntando a
HomePagecomo stub. Media migración cuesta más que ninguna.
Dónde está
En desarrollo, corriendo contra el backend real: la API, el posicionador y el recomendador están desplegados en Azure, y la app anda en Android y iOS. Las capturas de arriba salen de una cuenta de prueba sobre datos de verdad.
El repositorio es github.com/santiago01est/DROMO.