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.
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
02 · Decisiones técnicas
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.
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.
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
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.
Editor en vivo (DartPad) — próximamente
04 · Resultados · antes / después
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.
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».