Rowilove

Redesign do app de jogo social ao vivo para a comunidade LGBTQI+ latina, onde o match é consequência de jogar — com economia de moedas íntegra e operação estável em redes intermitentes da América Latina.

Rol: Lead mobile2025–2026Equipe de 2
React Native (bare)TypeScriptTanStack QueryZustandFirebaseSupabase RealtimeCloud FunctionsNext.js
99.4%
crash-free
−37%
tempo de inicialização
+29%
conexão no 1º dia

01 · Contexto e problema

A Rowilove conecta pessoas da comunidade LGBTQI+ latina por meio de jogos ao vivo (Roleta, O Show do Amor, Coração nas Mãos…). A promessa: «aqui se paquera jogando» — o chat se abre como consequência de uma interação real (coincidir em uma intenção ou se animar com um mimo), não como um DM grátis para qualquer um.

Recebi três camadas herdadas que se chocavam: um home acoplado em ~600 linhas, uma economia de moedas que descontava no cliente (saldos negativos e cobrança dupla no toque duplo eram possíveis) e um álbum de fotos sem pipeline de moderação — o mais sensível em um app de relacionamentos.

O objetivo: sustentar o app em produção enquanto se reconstruía a base — economia atômica server-side e um home novo («Descobrir») atrás de um flag reversível.

Restrições

React Native bare (sem Expo)MVP em 6 mesesHabeas Data + LGPDApple Sign in · 18+Firebase como único backendPasswordless · dados mínimosRedes da América Latina intermitentesAndroid gama baixaModeração obrigatória (CSAM)

02 · Decisões técnicas

1

Arquitetura

Problema

O estado (sessão, saldo, feed, sala ativa, intenções, chats) vivia disperso entre useState, AsyncStorage global (vazava dados entre contas) e contextos ad-hoc; um jogo mexia em campos de outro.

Opções

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

Decisão ✓

TanStack Query (server state) + Zustand (UI/sessão) sobre Feature-Sliced + DDD pragmático: cada jogo é um ecossistema plug-and-play, removível e desligável a quente com kill-switch; 6 bounded contexts e um SAD único com 12 ADRs.

Trade-off

Disciplina de isolamento (nenhum feature importa internos de outro). Em troca, o novo home foi lançado atrás de um flag com reversão de 1 linha, sem downtime.

2

Tempo real + integridade da economia

Problema

As salas devem parecer vivas sem falsear atividade, e cada ação que custa moedas (giro, mimo) deve ser atômica: sem saldos negativos, sem cobrança dupla e com «moedas» escrito só pelo servidor.

Opções

Polling 5s · streams + cliente escreve · transação atômica única · Supabase Realtime + FCM

Decisão ✓

Supabase Realtime para presença honesta (só usuários reais, sharding ~1.000/sala) + Firestore runTransaction como único writer: cobrança server-side (verifyIdToken → desconta só se houver saldo, 402 se não) + guard síncrono por ref no cliente + idempotency-key por request.

Trade-off

+1 hop e ~60 ms p95. Em troca: zero race conditions, cobrança dupla impossível por construção e um 2º caminho ao chat (coincidência grátis) que aumenta o engajamento sem quebrar a barreira anti-spam.

3

Offline em rede ruim + gama baixa

Problema

Um giro no metrô sem sinal não pode deixar o saldo inconsistente; o feed e a animação da roleta não podem derrubar FPS num Moto G.

Opções

Offline persistence nativo · AsyncStorage · MMKV · Fila idempotente

Decisão ✓

react-native-mmkv UID-scoped (nativo, ~30× mais rápido, sem chave global), Firestore offline persistence, foreground service nativo (possível pelo bare), pool de favoritos em cache por 1h e animações em Reanimated + Skia a 60fps. O feed pagina substituindo a página → memória plana.

Trade-off

Invalidação complexa → checklist de economia pré-merge (idempotência + reversão + estado do saldo). Freou ~12% o desenvolvimento mas cortou os «cobrou duas vezes».

Arquitetura

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

03 · Lançar o home «Descobrir» em produção — sem downtime, com dinheiro real e dados honestos

O home clássico funcionava mas não escalava para a visão: um feed de descoberta (grid 3×3 + lista) alimentado por favoritos curados reais, com intenções, coincidência e mimos que cobram moedas de verdade.

A migração devia substituir o home para todos sem downtime, não inventar atividade (os contadores começam honestos em 0), manter a economia atômica e idempotente sob picos, abrir o chat só como consequência de uma interação real e ser reversível na hora.

Solução: home atrás de um flag HOME_NUEVO (o clássico fica intacto embaixo → reversão de 1 linha), 3 endpoints atômicos (ver-mais, mimo, intenção) clonando o padrão da economia, feed a partir de favoritos reais (idade derivada, nunca a data de nascimento) e um perfil claymorfista com dados reais.

Resultado medido: zero incidentes de economia nas primeiras >20 mil cobranças pós-lançamento, com a reversão de 1 linha pronta e nunca usada.

04 · Segurança infantil e moderação

Em um app de relacionamentos, a moderação não é um anexo: é o produto. O pipeline de fotos é em camadas: verificação on-device antes do upload (frame processor, possível pelo bare), SafeSearch server-side no ingest (o duvidoso fica pendente, não público), denúncia humana → dashboard, e PhotoDNA em integração.

Protocolo CSAM inegociável: diante de suspeita, não baixar o material, preservar a evidência (hash + metadados), reportar à autoridade e soft-delete — nunca uma exclusão dura que destrua a cadeia de evidência.

Garantia operacional: a Cloud Function de purga rodou 48 h com PURGE_DRY_RUN=true (logando o que iria apagar sem tocar em nada) antes de ativar a exclusão real, com janela de observação posterior.

Uma decisão consciente de NÃO automatizar: sem banimento automático por SafeSearch — um falso positivo expulsando alguém real de uma comunidade vulnerável é pior que o custo da revisão humana. O SafeSearch oculta e enfileira; um humano decide.

Demo interativa

Em breve: execute o código e veja-o rodando ao vivo, sem instalar nada.

main.dart
Run

Editor ao vivo (DartPad) — em breve

Em breve

04 · Resultados · antes / depois

crash-free
96.1%
99.4%
inicialização a frio (Moto G)
3.4s
2.1s
conexão no 1º dia (D1)
33%
62%
cobrança dupla / saldo negativo
possíveis
0
jogos isolados (plug-and-play)
0
6

05 · Retrospectiva

Introduziria o checklist de economia desde o dia 1: apareceu no mês 3 após dois incidentes de cobrança dupla; tê-lo antes teria poupado ~2 semanas.

Fecharia a barreira de moderação antes do primeiro onboarding público: o pipeline ficou sólido mas entrou tarde — em um app de relacionamentos a segurança é o produto.

Manteria o isolamento por jogo com kill-switch, os dados honestos como regra dura (jamais falsear atividade) e o SAD único com ADRs internos.

Código em destaque · Cobrança atômica de moedas

O endpoint do mimo: dinheiro sob concorrência, toque duplo e retries de rede. A garantia não é «tomamos cuidado» — é impossível por construção: uma transação ganha, a outra é no-op; com idempotency-key, um retry não cobra duas vezes.

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 });
}

Em entrevista se nota: reads antes de writes, idempotency-key que torna o retry seguro, estados de erro tipados (−1 sem saldo → 402, −2 destinatário inválido → 404) e o contador do destinatário subindo na mesma transação que cobra. O padrão que separa «lida com dinheiro» de «torce para não ter bug».

Caso anterior
Próximo caso