App (React Native + Expo)
│ login anônimo · RPCs · Realtime · Edge Function
▼
Supabase
├── Postgres ── tabelas + RLS + regras do jogo em funções RPC (fonte oficial do estado)
├── Realtime ── avisa os aparelhos quando a sala muda
├── Auth ────── identidade anônima e estável por aparelho (reconexão sem duplicar jogador)
└── Edge Function submit-guess
└── MusicSearchService ── MusixmatchProvider (chave só no servidor)
└─ (opcional) IA para desempate entre candidatos reais
O aplicativo publicado não depende do Claude Code nem de plataformas como Base44/Lovable: é um app Expo comum + um projeto Supabase.
O aparelho só exibe. Todas as decisões oficiais acontecem no Postgres, dentro de funções
security definer que validam o jogador (auth.uid()), a sala, a fase e o prazo:
| Decisão | Onde |
|---|---|
| Quem enviou primeiro | submit_guess (trava a linha da sala com select … for update) |
| Quem acertou / ponto | resolve_guess (só a Edge Function, com service_role) |
| Palavra sorteada, sem repetição | app_private.draw_word + índice único em used_words |
| Quem é host | app_private.ensure_host (menor join_order entre os ativos) |
| Maioria para finalizar | app_private.check_finish_majority (> 50% dos ativos) |
| Resultado da votação | app_private.resolve_decision |
| Fim da partida | app_private.finish_room |
Os clientes não têm permissão de insert/update/delete em nenhuma tabela (RLS sem políticas de
escrita + revoke). Leitura só para membros da sala (app_private.is_room_member). O banco de palavras
não é legível pelo app.
rooms.status)stateDiagram-v2
[*] --> waiting
waiting --> starting: host inicia (sozinho ou com outros)
starting --> playing: 3 s · sorteia a palavra
playing --> verifying: primeiro palpite
verifying --> correct: música confirmada (+1 ponto)
verifying --> incorrect: música incorreta
verifying --> playing: API indisponível / tempo esgotado (não conta)
correct --> countdown: 3 s
countdown --> playing: 3 s · nova palavra
correct --> finished: rodadas completas
incorrect --> decision: 2 s
playing --> decision: "Pular palavra"
decision --> playing: maioria "Novo palpite" (mesma palavra)
decision --> playing: "Nova palavra", empate ou sem votos
decision --> finished: palavra descartada era a última rodada
playing --> finished: maioria pediu para finalizar / host encerrou
finished --> waiting: host "Jogar novamente"
Cada fase temporizada grava phase_ends_at. Quando o prazo vence, qualquer aparelho chama
advance_room; o servidor confere o relógio dele e avança. A chamada é idempotente (vários
aparelhos podem chamar juntos) e também acontece dentro do heartbeat, então a partida não trava se
alguém fechar o app. As contagens na tela usam a diferença de relógio medida em cada leitura do estado.
| Fase | Duração |
|---|---|
starting (“Preparem-se!”) |
3 s |
verifying |
até a resposta da API (mínimo 1,5 s na tela; expira em 25 s) |
correct (“CONFIRMADO!”) |
3 s |
countdown (“Próxima palavra em 3, 2, 1”) |
3 s |
incorrect (“MÚSICA INCORRETA”) |
2 s |
decision (Novo palpite × Nova palavra) |
3 s |
rounds (sequence = ordem de exibição).round_number fica nulo até o primeiro palpite verificado (correto ou incorreto). Nesse momento,
app_private.count_attempt atribui o próximo número e incrementa rooms.rounds_played — uma única vez
por palavra (unique (room_id, round_number) e a checagem has_attempt).status = 'error' e o jogo volta a aceitar palpites.X = round_number da palavra atual ou rounds_played + 1.rounds_played ≥ configured_rounds.submit-guess com o token da sessão.auth.getUser) e chama submit_guess(user, sala, texto).submit_guess trava a sala. Se o estado não for playing, o palpite é descartado (busy). Se for,
grava o palpite, muda para verifying e libera a trava — quem chegar depois já vê verifying.EdgeRuntime.waitUntil), verifica a música e grava o
resultado com resolve_guess. Todos os aparelhos acompanham pelo Realtime.Além da trava, o banco garante no máximo um palpite em verificação por sala e um palpite correto por palavra (índices únicos parciais).
round_votes): um voto por jogador e por janela (decision_number), pode mudar até o
prazo; votos depois do prazo são recusados. Maioria em “Novo palpite” mantém a palavra; “Nova palavra”,
empate ou nenhum voto sorteiam outra. “Pular palavra” abre a mesma votação, já com o voto de quem pediu.finish_requests): não existe “Não”. Cada clique soma um pedido; quando os pedidos de
jogadores ativos passam de 50% dos ativos, a partida termina para todos. A maioria é recalculada
quando alguém sai ou cai.rounds_played ≥ configured_rounds − 1, o host vê o aviso “O jogo está indo
para a última rodada. Deseja adicionar mais?”. NÃO grava final_prompt_answered_for; SIM soma rodadas
(sem limite baixo artificial; limite técnico de 9.999).players tem unique (room_id, user_id),
então reabrir o app ou recarregar a página recupera o mesmo jogador com os mesmos pontos.heartbeat a cada 5 s. Sem sinal por 30 s, o jogador fica inativo (left_reason =
'timeout'), mas continua no placar. Ao voltar, o próprio heartbeat o reativa.heartbeat indica uma versão (state_version) diferente.join_order
(ordem de entrada guardada no banco). Quem sai por conta própria e volta entra no fim da fila;
quem só perdeu a conexão mantém a posição. O host pode iniciar, expulsar, adicionar rodadas,
encerrar e reiniciar a sala.supabase/functions/_shared/music/:
MusicSearchService.verify({ guess, targetWord })
1. Busca candidatos na fonte: por título, por letra (palpites com 3+ palavras) e geral.
2. Avalia em ordem cada candidato (sem repetir música):
- correspondência do palpite com o título (inclui "título + artista" e partes do título),
com a letra (trecho) ou com "título + trecho";
- presença da palavra sorteada no título ou na letra (aceita plurais simples: corações).
3. Usa a PRIMEIRA correspondência válida (não o primeiro resultado bruto).
4. Só possibilidades parciais? Se a IA estiver habilitada, ela escolhe entre os candidatos reais
(índice ou "nenhum"). Sem IA, o palpite é incorreto.
→ { found, title, artist, matchedExcerpt, matchedWord, confidence, … }
text.ts): ignora acentos, caixa, pontuação e espaços; tolera 1–2 letras erradas em
palavras longas. O texto original nunca é alterado.MusicProvider (busca + letra) e escolha-o em factory.ts.q_lyrics do próprio Musixmatch (que considera a letra toda).| Especificação | Implementação |
|---|---|
rooms.host_id |
rooms.host_player_id |
rooms.current_round |
rooms.rounds_played (rodadas contabilizadas) + current_word_id |
rooms.status |
rooms.status (máquina de estados acima) + phase_ends_at |
players.* |
igual, com user_id, last_seen_at e left_reason |
rounds.* |
igual; round_number só é preenchido quando a palavra conta como rodada |
guesses.* |
igual, com failure_reason, matched_word, confidence, provider |
round_votes.* |
igual, com decision_number (uma votação por janela) |
finish_requests.*, used_words.* |
iguais |
| — | words: banco de palavras editável pelo painel do Supabase |
Sem servidor: src/game/local/reducer.ts é uma máquina de estados pura (setup → playing →
finished) com as mesmas regras de contagem de rodadas (“Tentativa errada” registra uma tentativa;
pular sem tentativa não conta). Para marcar um acerto, toca-se no nome de quem acertou e em “Próxima
palavra”: o ponto e a rodada contam e a nova palavra entra direto, sem tela de comemoração. O estado é
salvo no aparelho para continuar depois; ao sair do pódio (ou abandonar a partida pelo início), tudo
zera, inclusive os nomes dos jogadores.
Quando a versão web é aberta como artefato no Claude (window.claude existe), o app usa outro
backend com a mesma interface (src/services/online/types.ts), escolhido em
src/services/onlineApi.ts:
App (web, dentro do Claude)
├── db ──── rooms/<CÓDIGO> (estado da sala) · rooms/<CÓDIGO>/votes/<jogador> (voto de cada um)
├── room ── presença na sala "upum-<código>" (quem está conectado)
├── user ── id estável de cada pessoa (reconexão sem duplicar jogador)
└── sample ─ verificação da música pelo Claude, na conta de quem palpitou
src/game/online/rules.ts é a mesma máquina de estados das funções SQL, em TypeScript puro
e com testes (rules.test.ts). Ao mudar uma regra online, mude os dois lados.acquire): o aparelho trava, lê, aplica a regra e grava. Dois palpites simultâneos viram fila e só o
primeiro é aceito, como no select … for update do Postgres.SharedClock), então aparelhos com relógios diferentes veem os mesmos prazos.src/services/online/claudeJudge.ts. Só vale se o jogo souber de qual música é o palpite
(título e artista), para ninguém inventar música. São duas perguntas ao Claude (modelo mais capaz):
error: o palpite
não conta e o jogo volta a aceitar palpites. Limite: sem internet no artefato, o Claude responde de memória e
pode não reconhecer músicas pouco conhecidas; uma base de letras exige o backend com servidor.