Rowilove

Rediseño de la app de juego social en vivo para la comunidad LGBTQI+ latina, donde el match es consecuencia de jugar — con economía de monedas íntegra y operación estable en redes intermitentes de LATAM.

Rol: Lead móvil2025–2026Equipo de 2
React Native (bare)TypeScriptTanStack QueryZustandFirebaseSupabase RealtimeCloud FunctionsNext.js
99.4%
crash-free
−37%
tiempo de arranque
+29%
conexión el 1er día

01 · Contexto y problema

Rowilove conecta a personas de la comunidad LGBTQI+ latina a través de juegos en vivo (Ruleta, El Show del Amor, Corazón en las Manos…). La promesa: «aquí se liga jugando» — el chat se abre como consecuencia de una interacción real (coincidir en una intención o animarse con un mimo), no como un DM gratis a cualquiera.

Recibí tres capas heredadas que chocaban: un home acoplado en ~600 líneas, una economía de monedas que descontaba en cliente (saldos negativos y doble-cobro al doble-tap eran posibles) y un álbum de fotos sin pipeline de moderación — lo más sensible en una app de citas.

El objetivo: sostener la app en producción mientras se reconstruía la base — economía atómica server-side y un home nuevo («Descubrir») detrás de un flag reversible.

Restricciones

React Native bare (sin Expo)MVP en 6 mesesHabeas Data + LGPDApple Sign in · 18+Firebase como único backendPasswordless · datos mínimosRedes LATAM intermitentesAndroid gama bajaModeración obligatoria (CSAM)

02 · Decisiones técnicas

1

Arquitectura

Problema

El estado (sesión, saldo, feed, sala activa, intenciones, chats) vivía disperso entre useState, AsyncStorage global (filtraba datos entre cuentas) y contextos ad-hoc; un juego tocaba campos de otro.

Opciones

Context + useReducer · Redux Toolkit · MobX · TanStack Query + Zustand

Decisión ✓

TanStack Query (server state) + Zustand (UI/sesión) sobre Feature-Sliced + DDD pragmático: cada juego es un ecosistema plug-and-play, removible y apagable en caliente con kill-switch; 6 bounded contexts y un SAD único con 12 ADRs.

Trade-off

Disciplina de aislamiento (ningún feature importa internos de otro). A cambio, el nuevo home se lanzó detrás de un flag con reversa de 1 línea, sin downtime.

2

Tiempo real + integridad de la economía

Problema

Las salas deben sentirse vivas sin falsear actividad, y cada acción que cuesta monedas (giro, mimo) debe ser atómica: sin saldos negativos, sin doble-cobro y con «monedas» escrito solo por el server.

Opciones

Polling 5s · streams + cliente escribe · transacción atómica única · Supabase Realtime + FCM

Decisión ✓

Supabase Realtime para presencia honesta (solo usuarios reales, sharding ~1.000/sala) + Firestore runTransaction como único writer: cobro server-side (verifyIdToken → descuenta solo si hay saldo, 402 si no) + guard sincrónico por ref en cliente + idempotency-key por request.

Trade-off

+1 hop y ~60 ms p95. A cambio: cero race conditions, doble-cobro imposible por construcción y un 2º camino al chat (coincidencia gratis) que sube el engagement sin romper la barrera anti-spam.

3

Offline en mala red + gama baja

Problema

Un giro en el metro sin señal no puede dejar el saldo inconsistente; el feed y la animación de la ruleta no pueden tirar FPS en un Moto G.

Opciones

Offline persistence nativo · AsyncStorage · MMKV · Cola idempotente

Decisión ✓

react-native-mmkv UID-scoped (nativo, ~30× más rápido, sin clave global), Firestore offline persistence, foreground service nativo (posible por bare), pool de favoritos cacheado 1h y animaciones en Reanimated + Skia a 60fps. El feed pagina reemplazando la página → memoria plana.

Trade-off

Invalidación compleja → checklist de economía pre-merge (idempotencia + reversa + estado del saldo). Frenó ~12 % el desarrollo pero cortó los «se cobró dos veces».

Arquitectura

UI · Features (RN bare)
App · TanStack Query + Zustand
Domain · TS puro (VOs)
Infra · Firestore + Supabase + CF

03 · Lanzar la home «Descubrir» en producción — sin downtime, con dinero real y datos honestos

El home clásico funcionaba pero no escalaba a la visión: un feed de descubrimiento (grid 3×3 + lista) alimentado por favoritos curados reales, con intenciones, coincidencia y mimos que cobran monedas de verdad.

La migración debía reemplazar el home para todos sin downtime, no inventar actividad (los contadores arrancan honestos en 0), mantener la economía atómica e idempotente bajo picos, abrir el chat solo como consecuencia de una interacción real y ser reversible al instante.

Solución: home detrás de un flag HOME_NUEVO (el clásico queda intacto debajo → reversa de 1 línea), 3 endpoints atómicos (ver-más, mimo, intención) clonando el patrón de la economía, feed desde favoritos reales (edad derivada, nunca la fecha de nacimiento) y un perfil claymorfista con datos reales.

Resultado medido: cero incidentes de economía en los primeros >20k cobros post-lanzamiento, con la reversa de 1 línea lista y nunca usada.

04 · Seguridad infantil y moderación

En una app de citas, la moderación no es un anexo: es el producto. El pipeline de fotos es en capas: verificación on-device antes del upload (frame processor, posible por bare), SafeSearch server-side en el ingest (lo dudoso queda pendiente, no público), reporte humano → dashboard, y PhotoDNA en integración.

Protocolo CSAM no negociable: ante sospecha, no descargar el material, preservar la evidencia (hash + metadatos), reportar a la autoridad y soft-delete — nunca un borrado duro que destruya la cadena de evidencia.

Garantía operacional: la Cloud Function de purga corrió 48 h con PURGE_DRY_RUN=true (logueando qué iba a borrar sin tocar nada) antes de activar el borrado real, con ventana de observación posterior.

Una decisión consciente de NO automatizar: sin auto-baneo por SafeSearch — un falso positivo expulsando a alguien real de una comunidad vulnerable es peor que el costo de la revisión humana. SafeSearch oculta y encola; un humano decide.

Demo interactiva

Próximamente: ejecuta el código y míralo correr en vivo, sin instalar nada.

main.dart
Run

Editor en vivo (DartPad) — próximamente

Próximamente

04 · Resultados · antes / después

crash-free
96.1%
99.4%
arranque en frío (Moto G)
3.4s
2.1s
conexión el 1er día (D1)
33%
62%
doble-cobro / saldo negativo
posibles
0
juegos aislados (plug-and-play)
0
6

05 · Retrospectiva

Introduciría el checklist de economía desde el día 1: apareció en el mes 3 tras dos incidentes de doble-cobro; tenerlo antes habría ahorrado ~2 semanas.

Cerraría la barrera de moderación antes del primer onboarding público: el pipeline quedó sólido pero entró tarde — en una app de citas la seguridad es el producto.

Conservaría el aislamiento por juego con kill-switch, los datos honestos como regla dura (jamás falsear actividad) y el SAD único con ADRs internos.

Código destacado · Cobro atómico de monedas

El endpoint del mimo: dinero bajo concurrencia, doble-tap y retries de red. La garantía no es «tenemos cuidado» — es imposible por construcción: una transacción gana, la otra es no-op; con idempotency-key, un retry no cobra dos veces.

app/api/descubrir/mimo/route.ts
import { getAdminAuth, getAdminDb } from "@/lib/firebase-admin";
import { FieldValue } from "firebase-admin/firestore";

const PRECIO = 100; // monedas por mimo

export async function POST(req: Request) {
  const { idToken, targetUid, qty, idemKey } = await req.json();
  const n = Math.floor(qty);
  if (!idToken || !targetUid || !Number.isFinite(n) || n < 1 || n > 99) {
    return Response.json({ error: "bad" }, { status: 400 });
  }

  const { uid } = await getAdminAuth().verifyIdToken(idToken);
  if (uid === targetUid) return Response.json({ error: "uno_mismo" }, { status: 400 });

  const db = getAdminDb();
  const yo = db.collection("usuarios").doc(uid);
  const el = db.collection("usuarios").doc(targetUid);
  const idem = db.collection("cobros").doc(`${uid}_${idemKey}`); // anti-retry
  const cost = n * PRECIO;

  // Atómico: lee emisor + receptor + idempotency-key, valida y escribe todo o nada.
  const saldo = await db.runTransaction(async (tx) => {
    const [yoSnap, elSnap, idemSnap] = await Promise.all([
      tx.get(yo), tx.get(el), tx.get(idem),
    ]);
    if (idemSnap.exists) return idemSnap.data()!.saldoResultante as number; // retry: no recobra
    if (!elSnap.exists || !elSnap.data()?.seudonimo) return -2;            // receptor inválido
    const m = (yoSnap.data()?.monedas as number) ?? 0;
    if (m < cost) return -1;                                               // sin saldo → 402
    const nuevo = m - cost;
    tx.update(yo, { monedas: nuevo });
    tx.update(el, { mimos: FieldValue.increment(n) });                     // contador REAL
    tx.set(idem, { saldoResultante: nuevo, ts: FieldValue.serverTimestamp() });
    return nuevo;
  });

  if (saldo === -2) return Response.json({ error: "sin_persona" }, { status: 404 });
  if (saldo < 0) return Response.json({ error: "sin_monedas" }, { status: 402 });
  // el mimo abre el chat (helper compartido) + evento de métricas — fuera de la tx
  return Response.json({ ok: true, monedas: saldo, mimos: n });
}

En entrevista se nota: reads antes de writes, idempotency-key que hace el retry seguro, estados de error tipados (−1 sin saldo → 402, −2 receptor inválido → 404) y el contador del receptor subiendo en la misma transacción que cobra. El patrón que separa «maneja dinero» de «espera no tener bug».

Caso anterior
Siguiente caso