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.
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
02 · Decisões técnicas
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.
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.
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
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.
Editor ao vivo (DartPad) — em breve
04 · Resultados · antes / depois
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.
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».