Pitch: ChillCoffee: Recriei o Café Mania no navegador. Como um simulador de cafeteria no browser separa regra de jogo e render
Desenvolvi um game com o Cursor, e a parte que vale contar não cabe em um parágrafo sobre “usar IA”.
Com esse boom de AI games, eu resolvi recriar um clássico da minha infância: o Café Mania. Quem teve Orkut provavelmente sentou alguém numa cadeira, fez um cappuccino e ficou sem espaço no salão. Eu queria essa sensação de novo. Não um vídeo em que a tela parece o jogo por trinta segundos.
O projeto se chama ChillCoffee. É um simulador de cafeteria que roda no navegador, no celular e no computador. Não tem Unity nem Godot. A cena 3D é React Three Fiber. As regras são TypeScript puro. O progresso fica no IndexedDB do próprio aparelho. A música é sintetizada na hora, sem arquivo de áudio.
O prompt foi a menor parte. O trabalho foi decidir o que é regra, o que é conteúdo e o que é desenho, e impedir que essas três coisas se misturem.
O que está jogável hoje
O salão começa numa grade de 12 por 10. Já tem cafeteira, balcão, duas mesas e quatro cadeiras. O jogador entra com 750 moedas, 5 gemas e reputação 10.
O cliente entra pela porta, procura cadeira, senta, pede, espera, consome e vai embora. Se não acha lugar, ou se o pedido estoura a paciência, ele sai e a reputação cai. O jogador prepara a receita num equipamento compatível, gasta ingrediente na hora e serve quando o produto fica pronto. A venda paga moeda, gorjeta e XP. Subir de nível entrega moeda, gema e o que o catálogo libera naquele nível.
Dá para construir: comprar, girar, mover e vender móvel. Venda devolve 40% do preço. Equipamento com preparo em andamento não pode ser vendido.
O cardápio jogável tem 67 receitas, escritas até o capítulo 4, com meta de 80. O catálogo de móveis e equipamentos posicionáveis passa de 70 itens. São 10 perfis de cliente, com paciência e velocidade diferentes. Tudo isso é JSON. A simulação não conhece “cappuccino”. Ela conhece id, tempo, ingrediente e qual máquina aceita aquela receita.
O que ainda não está no loop, e eu não vou fingir que está: contratar funcionário, missão, aumentar o salão, progresso offline, o jogador mudar o preço da receita, prestígio. Isso está desenhado. Não está ligado na partida. O documento de progressão prevê 100 níveis e cerca de 300 horas até o fim. A curva de XP já roda. A campanha inteira, não.
Três pastas, três responsabilidades
A tentação, principalmente com um agente escrevendo código, é colocar a regra dentro do componente que desenha a xícara. Aí o próximo pedido de “deixa o balão do pedido mais bonito” reescreve a economia.
Eu separei assim:
content/ é dado. Receita, móvel, ingrediente, preço, parâmetro de economia, calendário sazonal. JSON.
src/modules/ é regra. Grade, pathfinding, cliente, produção, estoque, satisfação, carteira, XP, formato de save. Função pura. Sem React, sem Three, sem window.
src/game/scene/ e src/ui/ são vista. Leem o estado e desenham. Não decidem se a venda foi válida.
src/stores/gameStore.ts é a cola. Zustand. Uma ação do jogador chama a regra, grava o save novo e pede persistência.
src/infrastructure/persistence/ é IndexedDB, via Dexie. Backup, recuperação e o banco antigo com outro nome.
O catálogo importa o JSON e entrega mapa por id. O módulo de save recebe esse catálogo por parâmetro. Ele valida “esse móvel existe?” sem importar o arquivo de receita. Dá para testar migração com um catálogo falso.
Isso mudou o jeito de usar o Cursor. O agente é bom em preencher um módulo quando o tipo e o teste já existem. Ele é ruim em decidir o que o jogo é. Eu deixei a decisão nos documentos de economia e progressão, e deixei a geração mais solta no JSON, porque adicionar uma receita não deveria exigir uma linha nova na simulação.
Dois relógios
A produção não usa o frame da cena. Um setInterval de 250 ms chama tick(dt). Se a aba dormiu e acordou com um buraco grande, o dt é cortado em 5 segundos. Um tick não fabrica uma hora de café.
const GAME_TICK_MS = 250; const MAX_TICK_SECONDS = 5;
O comentário no código diz a intenção: o preparo continua mesmo quando o canvas está lento. Cada job guarda os segundos que faltam. Uma máquina com velocidade 1,2 consome 1,2 segundo de trabalho por segundo real. Quando o tempo acaba e ainda cabe produto no estoque, o item vai para a prateleira.
O cliente anda em outro relógio, o useFrame do Three. O delta do frame move o boneco e consome a paciência. Com a aba visível, os dois relógios acompanham o relógio de parede. Com o canvas pausado e o intervalo ainda disparando, o café pode ficar pronto enquanto o cliente não envelhece. Eu preferi isso a prender a economia nos 60 fps. Ainda é uma assimetria, e eu sei onde ela está.
Cliente não entra no save. Pedido entregue e ainda não pago fica num Map em memória. Fechou a aba no meio do salão cheio, as pessoas somem. Máquina, estoque, móvel e o job pela metade continuam. Eu tratei o cliente como efeito da sessão, e a cafeteria como o estado que precisa sobreviver.
O cliente é uma máquina de estados
Cada pessoa passa por estados explícitos: entrando, procurando cadeira, esperando vaga, andando até o assento, pedindo, esperando o pedido, consumindo, saindo, saindo insatisfeita. Não é “um sprite que anda até um ponto”. É uma simulação com evento.
Os eventos que interessam à economia são poucos: order_placed, paid, lost. O motivo da perda também é enum: sem cadeira, sem cardápio, estouro de paciência, cadeira removida. A loja escuta isso e mexe em reputação, contador e dinheiro. A cena escuta para redesenhar.
O caminho até a cadeira é A* de 4 direções. A célula de destino é sempre tratada como andável. A cadeira é obstáculo para quem passa, e é o objetivo de quem vai sentar. Sem esse detalhe, o cliente não consegue “entrar” no próprio assento. A grade inicial reserva a porta e a célula de trás. Senão o jogador fecha a entrada com uma planta e o salão morre em silêncio.
Tem teto de 12 clientes ao mesmo tempo. O intervalo de chegada não é fixo.
A demanda segue a cozinha
No começo eu deixaria um spawn de 6 a 12 segundos e pronto. Uma cafeteria com uma cafeteira lenta enche de gente brava e o jogador acha que o jogo está quebrado. Uma cafeteria grande fica vazia e o jogador acha que expandir não serviu para nada.
A regra que ficou: o intervalo médio é o inverso da produção, ajustado pela reputação.
A pressão vai de 0,85 a 1,35 conforme a reputação vai de 0 a 100. O intervalo fica entre 4 e 40 segundos, com uma abertura de 30% para não parecer metrônomo. Sem nenhuma máquina produzindo o cardápio atual, o intervalo vai para o máximo. Reputação alta empurra a fila um pouco acima da capacidade. Esse “um pouco acima” é o que faz o jogador querer mais cadeira e mais máquina. A expansão de salão ainda não existe no jogo. A pressão que justificaria essa expansão já existe na fórmula.
O cardápio que o cliente pode pedir também é filtrado na hora: receita desbloqueada no nível, equipamento daquela receita colocado no salão, e ou o produto já está pronto ou ainda dá para fazer com o estoque. Acabou o leite, o cliente para de pedir o que leva leite. Ele não pede para depois a UI dizer “impossível”.
Quem recebe o café pronto
Se há dois pedidos de espresso e só um pronto, o pronto vai para quem está mais perto de desistir, não para quem pediu primeiro. A ordenação é tempo esperado / paciência. O segundo espresso em preparo marca o outro pedido como “preparando”. O que não tem produto nem job fica “pendente”, e o toque no balão começa o preparo.
Isso é uma função de alocação, testada sozinha. A UI só mostra o status. Sem essa separação, o primeiro balão da lista rouba o café de quem está a um segundo de levantar.
Satisfação não é um número mágico
Quando o pedido chega, a nota de 0 a 100 é uma média ponderada:
30% qualidade da máquina
25% atendimento
20% tempo de espera
15% conforto da cadeira
10% beleza somada do salão
O tempo tem uma folga. Se o cliente esperou até um quarto da paciência, a nota de tempo é 100. Depois disso cai linear até zero. Cadeira boa e decoração sobem a nota, com teto. Não dá para cobrir um pedido atrasado só com vaso de planta, e um vaso também não é inútil.
A gorjeta é preço × taxa da qualidade × satisfação / 100. Qualidade regular não dá gorjeta. Boa dá 5%, excelente 12%, perfeita 20%.
A reputação não copia a última nota. Cada cliente atendido puxa a reputação 1% da distância até aquela satisfação. Cliente perdido tira 0,4. Uma cafeteria com reputação 70 não desaba porque uma pessoa esperou demais. Uma sequência de perdas desaba. Reputação alta, por sua vez, aumenta a pressão da fila. O jogador sente o salão ficando mais exigente sem uma tela de “agora você está no modo difícil”.
XP que não explode no nível 2 e não acaba no 30
A XP para sair do nível n é:
100 + 35(n − 1) + 5(n − 1)²
Do 1 para o 2 são 100 XP. Do 99 para o 100 são 51.550. A soma até o nível 100 é 1.772.430 XP. A curva é quadrática de propósito. Para o tempo por nível não disparar, a XP por hora precisa crescer junto: mais gente ao mesmo tempo, receita com XP base maior, mais movimento no salão. O documento de progressão fixa o ritmo alvo, de cerca de 30 minutos até o nível 5 até cerca de 300 horas até o 100, contando só jogo ativo.
A recompensa de nível também é fórmula, não tabela digitada: 50 + 25(n − 1) moedas, e gemas com bônus em múltiplo de 5 e de 10. O nível 10 paga 275 moedas e 8 gemas. Se a fórmula mudar, o teste quebra. Eu não quero um “+100 moedas” esquecido no meio de um componente.
Gemas hoje entram no nível e aparecem no HUD. Elas ainda não compram aceleração nem cosmético dentro da partida. A regra de design é que gema não compra XP nem expansão. Nada que seja obrigatório para terminar a campanha pode depender de gema. Isso já está escrito para eu não “só dessa vez” vender progresso.
Conteúdo fora do código
Eu escrevi dois documentos antes de tratar número como verdade espalhada pelo TypeScript.
O de progressão diz que todo nível de 1 a 100 entrega alguma coisa, que sistema chega antes do conteúdo, e que cada categoria de receita estreia junto com a máquina que a faz. Chá com chaleira. Café gelado com máquina de gelo. O de economia diz de onde vem o dinheiro e para onde ele vai: venda na casa de 70% da renda, ingrediente como o maior ralo, margem positiva em toda receita no preço sugerido.
As receitas carregam o custo como soma dos ingredientes. O preço sugerido segue a margem do capítulo, de 2,6 no começo até perto de 2,3 no meio, com uma recuperação leve nas receitas de assinatura. O XP base do espresso é 5, coerente com o começo. Receita de capítulo avançado sobe.
Um script gera relatório a partir do JSON e recusa nível vazio. A tabela do documento é saída desse script. Eu não edito a tabela na mão. Quando o agente altera um desbloqueio, o relatório é o lugar em que a mentira aparece.
O jogo jogável ainda não percorre esses 100 níveis de conteúdo. O documento existe para o protótipo não inventar número a cada tela. É mais fácil implementar metade de um sistema com fórmula do que implementar um sistema cujos números moram em quinze arquivos.
Save que assume corrupção
O formato tem versão. A versão 1 não tinha estoque nem produção. A migração para a versão 2 coloca os dois, com um estoque inicial congelado dentro da função de migração. Se amanhã o café inicial mudar de 40 grãos para 20, a partida antiga não pode ser reescrita. Migração antiga é histórica.
Na exportação, o JSON vai dentro de um envelope com nome do formato, versão, id da partida e um checksum FNV-1a. O checksum pega arquivo truncado e edição acidental. Não é anticheat. Um save local de um jogo sem servidor não tem como ser à prova de edição, e eu não vou fingir que um hash de 32 bits é isso. O validador ainda confere tipo, nível, móvel existente, receita existente e se o layout cabe na grade. Importar lixo não pode virar partida.
O repositório lê o slot principal. Se o dado não valida, tenta o backup mais recente que valide. Se achar, a partida volta com um aviso. Se não achar, a tela é fatal: começar de novo, em vez de seguir com um objeto pela metade. Backup roda no máximo a cada 5 minutos, guarda 5 cópias, e é forçado em level up, importação, reset e recuperação. Save e backup entram na mesma transação do IndexedDB. Um erro no meio não pode deixar o principal novo e o backup velho, ou o contrário.
O autosave é de 30 segundos, mais visibilitychange e pagehide. Tempo de jogo só conta com a aba visível.
Duas abas abertas no mesmo save são um bug clássico de IndexedDB: as duas leem, as duas escrevem, a que salvar por último apaga a outra. Eu uso a Web Locks API. Uma aba segura o lock chillcoffee-session e não o solta. A segunda vê que o lock está ocupado e mostra “aberto em outra aba”, com um botão para tomar a sessão. Quando a outra aba perde o lock, ela para de escrever.
O banco já se chamou cafe-empire. Na primeira abertura, se o banco novo está vazio e o antigo existe, os saves são copiados e o antigo é apagado. Quem jogou o protótipo com o nome anterior não perde a cafeteria por causa de um rename.
A cena não depende de loja de asset
Os móveis são montados com caixa, cilindro, esfera e cone. O JSON diz a forma (table, chair, machine, kettle…) e a cor. O componente traduz isso em peças. Colocar uma cadeira nova no catálogo não exige um arquivo glTF. O preview de construção usa um material fantasma: verde se a célula é válida, vermelho se bate em outra coisa ou sai da grade. A validação de verdade está no módulo da grade. A cor só repete a resposta.
O céu segue o relógio do computador, não um ciclo interno de jogo. Meio-dia é dia. De madrugada é noite. O amanhecer interpola entre 5h30 e 7h, o anoitecer entre 17h30 e 19h30, com smoothstep para a luz não dar um degrau. Sol, névoa, hemisfério e exposição acompanham esse 0–1. As luzes de dentro da cafeteria acendem com a noite. O cálculo da fração de noite é função pura, com teste. O useFrame só aplica na luz, e ignora mudança menor que 0,004 para não ficar escrevendo cor todo frame à toa.
Decoração de data é outro JSON. Halloween vale de 15 de outubro a 2 de novembro. A janela pode cruzar o ano novo: se o início for maior que o fim, a regra trata como “dezembro até janeiro”. Dá para forçar um tema, ou nenhum, por localStorage, para eu ver a cidade enfeitada em julho sem mudar o relógio do sistema.
A cidade em volta é cenário. O jogo acontece na grade. Eu não simulo pedestre com pathfinding na rua. O custo não se paga, e o salão é o sistema.
A música não é um mp3
São 16 faixas. Quatro de aventura, oito calmas, quatro de Halloween. Cada uma é uma lista de notas: tempo em batida, nome da nota, duração, velocidade, e uma de três vozes. Piano, caixa de música, pad.
O Web Audio transforma isso em oscilador. A nota vira MIDI, MIDI vira frequência com a fórmula dos 440 Hz. Um reverb de convolução usa um impulso gerado: ruído que decai, uns 2,8 segundos, filtrado para não soar como chuva branca. Entre uma faixa e outra tem um silêncio de 12 a 22 segundos. Cafeteria respira. Não é playlist de streaming colada.
Nada disso é sample de outro jogo. Num projeto que parte de um clássico que as pessoas reconhecem, áudio e modelo copiados são o jeito mais rápido de transformar uma homenagem em problema. O nome do jogo também não é Café Mania. A referência fica no texto. O arquivo fica original.
O teste que segura o agente
São 122 testes, em Vitest, sem browser. Grade, caminho, produção, estoque, satisfação, carteira, XP, formato de save, demanda, simulação de cliente, catálogo, luz do dia, calendário, música, PWA.
O teste que eu mais olho não é unitário. Ele sobe a loja de verdade, com IndexedDB falso, zera a partida e joga sozinho.
Um jogador atento, por 10 minutos de tempo simulado, a cada segundo: serve o que está pronto, prepara o que está pendente, compra grão se tiver menos de 12 e açúcar se tiver menos de 4. O teste exige pelo menos 15 clientes atendidos, no máximo 2 perdidos, reputação e moedas maiores que no início, e pelo menos uma compra de ingrediente.
O jogador ocioso, por 5 minutos, não atende ninguém. Perde cliente, perde reputação, e o saldo de moedas fica igual. Atender tem que ser o que gera dinheiro. Ficar parado tem que doer, e não pode gerar moeda do nada.
Esse teste é a especificação do loop. Quando uma alteração de IA deixa o salão “mais vivo” e a cafeteria no prejuízo, ele falha antes de eu perceber jogando. Eu ainda leio o diff. O teste não substitui isso. Ele impede o tipo de regressão que o olho deixa passar porque a cena continuou bonita.
O que o Cursor fez bem e o que eu não terceirizei
Funcionou pedir um módulo com a assinatura já fechada. “Dado este estado de produção e esta estação, comece um job ou devolva o motivo”. Funcionou pedir teste antes, ou junto, da regra. Funcionou deixar receita e móvel em JSON: o modelo adiciona conteúdo sem precisar entender o A*.
Não funcionou pedir “faz um Café Mania”. O resultado desse pedido é uma tela que lembra o jogo e um loop que não fecha conta. Também não funcionou deixar o agente decidir persistência. Duas abas, migração e backup são o tipo de código que parece certo até a primeira partida corrompida.
A regra que eu usei: o agente escreve dentro de um módulo. A fronteira entre módulos eu desenho. Número de economia nasce no JSON e no documento, e um teste ou um relatório confere. Se a alteração mexe em save, eu leio a migração inteira. Save é o único lugar em que um refactor elegante apaga a tarde de alguém.
O stack em si é comum de propósito. React 19, Vite, TypeScript, Zustand, Three.js, Dexie, Vitest. Dá para publicar como site estático. Tem service worker com rede primeiro e cache se a rede falhar, e o sw.js vai com Cache-Control: no-cache para uma versão nova não ficar presa. O convite de instalar como app só aparece no celular, e some se o jogo já estiver em tela cheia. WebGL 2 é obrigatório. Sem isso, a página explica em vez de abrir um canvas preto.
O que eu faria diferente
Eu escreveria o teste do loop de 10 minutos no primeiro dia, não depois da cena estar agradável. A cena convence cedo demais. A planilha convence tarde.
Eu também ligaria menos sistema no documento antes de o loop do capítulo 1 estar chato de tão sólido. O GEDD me impediu de inventar preço na UI. Ele também criou uma lista de funcionários, turnos e prestígio que o código ainda não sustenta. Documento à frente do jogo é bom. Documento muito à frente vira um segundo jogo, feito de markdown.
O próximo pedaço que vale implementar é o que o loop já pede: mais lugar para sentar quando a reputação sobe, e uma pessoa além do jogador operando uma máquina. Não é um sistema de RH. É o momento em que uma cafeteria de uma pessoa só deixa de dar conta da fila que a própria reputação criou.
Se você jogou Café Mania no Orkut, o ChillCoffee é a minha tentativa de reconstruir essa tarde com sistema de verdade por baixo. A IA acelerou a digitação. A arquitetura é o que faz a partida de amanhã continuar de onde a de hoje parou.