1

Pitch: Xiao Xiao? fiz um jogo de luta multiplayer em TypeScript que roda no navegador, com servidor autoritativo e simulação determinística

NANQUIM: luta de kung fu em tinta nanquim, grátis no navegador

Fala, pessoal! Quero mostrar o projeto em que venho trabalhando nos últimos meses: o NANQUIM, um jogo de luta 2D on-line, grátis, que roda direto no navegador. Todo o visual é pintado no estilo da tinta nanquim chinesa: lutadores, cenários, golpes e efeitos.

Além de convidar vocês para jogar (e trazer os amigos para uma porradaria), quero contar como ele foi construído por dentro, porque multiplayer de jogo de luta no navegador tem uns problemas bem interessantes.

Jogar: https://play.nanquim.gg
Site: https://nanquim.gg

Os seis estilos de luta em ação

O jogo

São seis estilos de luta, cada um com especiais próprios, um super de três níveis e técnicas que você libera jogando:

EstiloJeitão
Mestreequilibrado, com rajada de chi
Brutamontesagarrão, armadura e jogar o outro para fora da arena
Ninjakunai, fumaça e teletransporte
Mongegolpes de palma, cura e aparar
Bêbadoataques fora do ritmo e a cabaça
Lanceiroalcance longo e controle de espaço

Modos:

  • Duelo ranqueado com temporadas. Todo lutador é igual: quem decide é a habilidade.
  • Salas com amigos: você escolhe modo, mapa, regras e bots, e outras pessoas podem assistir ao vivo.
  • Modos festa para até 4 pessoas: briga com meteoros, Rei da Colina, Infecção, esconde-esconde e outros.
  • Vista do Grou: uma arena vista de cima, por pontes e telhados.
  • Campanha com 45 fases em 5 mundos, chefes, cenas de história em tinta e um tutorial interativo.

Briga de quatro com meteoros

É grátis e sem pay-to-win. Se um dia tiver algo à venda, vai ser só cosmético.

Como foi feito

Stack

  • TypeScript de ponta a ponta, num monorepo com cliente, servidor e um pacote compartilhado.
  • Node 24 rodando TypeScript direto, sem etapa de build no servidor.
  • Cliente sem framework de UI: DOM puro para as telas e Canvas 2D com pós-processamento em WebGL para o efeito de tinta. O Vite cuida do build.
  • Dados: PostgreSQL como banco principal e Valkey para o estado compartilhado em tempo real (presença, filas, diretório da frota, limites de taxa distribuídos, ranking ao vivo e pub/sub entre instâncias). Para desenvolvimento e ambientes de nó único, a mesma camada roda em SQLite (node:sqlite, modo WAL) com estado em memória.
  • Infra: AWS (EC2 Graviton/ARM) para os servidores de jogo e Cloudflare na borda.
  • Em números: cerca de 150 mil linhas de TypeScript e 212 arquivos de teste.

Arquitetura feita para escalar na horizontal

A arquitetura separa duas coisas que crescem de jeitos diferentes:

  • Plano de partida (o que pesa): servidores de jogo sem estado e sem banco, um ou mais por região. Cada um só simula partidas, roda anti-cheat e bots. Para atender mais gente, é só subir mais máquinas (futuramente usaremos AWS GameLift): elas se registram sozinhas no diretório da frota (heartbeat assinado com TTL), e o matchmaking passa a mandar partidas para elas. Se uma cair, sai do diretório em segundos e as próximas partidas vão para outra.
  • Plano de controle (o que é leve): contas, lobby, filas, ranking e amigos. Roda em múltiplas instâncias ativas, sem ponto único de falha: o estado durável fica no PostgreSQL e o estado quente no Valkey, então qualquer instância atende qualquer jogador. A comunicação com os servidores de partida é toda assinada (HMAC com chave derivada por servidor) e passa por Cloudflare Access. Um servidor regional não confia em nada que não venha assinado.

Todo estado compartilhado passa por um contrato com duas implementações: distribuída (PostgreSQL + Valkey) e nó único (SQLite + memória). O jogo só conhece o contrato, a implementação é escolhida por configuração, e os testes rodam as duas para garantir o mesmo comportamento.

CamadaComo escala
Partidashorizontal por região: mais máquinas na frota, com autoscaling (Auto Scaling Group ou GameLift com instâncias Spot) que sobe no pico e desce de madrugada
Plano de controlehorizontal: mais instâncias atrás do balanceamento da Cloudflare
Estado quenteValkey (ElastiCache), com réplica
Estado durávelPostgreSQL gerenciado, Multi-AZ, com réplicas de leitura
BordaCloudflare: cache, WAF, limites de taxa e DDoS antes de chegar na AWS

Um detalhe que faz diferença: a partida não passa pelo banco. Tudo que acontece 60 vezes por segundo vive em memória no servidor de partida. O banco só recebe uma gravação quando a partida acaba, então o custo de dados cresce com o número de partidas, não com o número de ticks.

Observabilidade

  • Todos os servidores expõem métricas no formato Prometheus: tick da simulação, ticks atrasados, CPU, memória, conexões, partidas, filas e latência por região.
  • Logs estruturados em JSON, com eventos de segurança separados.
  • Alertas de orçamento na AWS e backup diário verificado do banco, com snapshot do disco.

Simulação determinística em ponto fixo

O coração do jogo é uma simulação a 60 ticks por segundo que roda igual no cliente e no servidor, com o mesmo código, vindo do pacote compartilhado.

Para isso funcionar, ela precisa ser determinística: com as mesmas entradas, o resultado tem que ser idêntico bit a bit em qualquer máquina. Com float isso não é garantido entre CPUs e motores JavaScript diferentes, então toda a física usa ponto fixo 16.16, com inteiros, onde 1.0 vale 65536. Nada de Math.sin nem Math.random dentro da simulação: o seno é calculado só com inteiros e o aleatório vem de um gerador com semente.

O bônus de ser determinístico: dá para gravar só as entradas de uma partida e reproduzir a luta inteira depois. Isso serve para replay, para teste e para conferir denúncia de trapaça.

Servidor autoritativo com predição no cliente

Toda partida roda no servidor, campanha incluída. O cliente só manda as teclas apertadas; quem decide o que aconteceu é o servidor.

Para não parecer lento, o cliente prevê o resultado das próprias entradas na hora. Quando chega o estado confirmado do servidor, ele volta no tempo e refaz os ticks que mudaram (reconciliação). Em jogo de luta isso é crítico, porque um golpe sai em poucos quadros.

Anti-trapaça

Como o servidor é a fonte da verdade, não adianta mexer na memória do jogo para ter vida infinita. Além disso:

  • Protocolo diferente a cada versão: a forma das mensagens muda a cada build, derivada de uma chave secreta. Um bot feito para uma versão para de funcionar na seguinte.
  • Validação de toda entrada no servidor, com limite de taxa.
  • Detecção de comportamento de bot, como tempo de reação humano demais para ser humano, com revisão por gente de verdade antes de qualquer banimento permanente.
  • Honeypots como crucial.

Infra barata e sem porta aberta

A Cloudflare faz a parte de borda, e a AWS só roda o que precisa de processo ligado o tempo todo:

  • O cliente é servido por um Cloudflare Worker, que também faz um portão de sessão: o código do jogo só é baixado por quem está logado.
  • WAF e limite de requisições por IP na borda, antes de qualquer coisa chegar no servidor, mais a proteção contra DDoS que já vem com a Cloudflare.
  • Turnstile invisível nas chamadas sensíveis (login, cadastro), no lugar de captcha chato.
  • Cloudflare Access na frente de tudo que é interno: painel de moderação e a comunicação entre os servidores das regiões.
  • R2 guarda os replays das partidas, sem custo de saída de dados.
  • Os servidores de jogo são EC2 ARM pequenos (t4g.small), sem SSH e sem nenhuma porta de entrada aberta. Todo o tráfego chega por Cloudflare Tunnel.
  • Deploy sem derrubar partida (blue/green): a versão nova sobe ao lado da antiga, na mesma porta, e a antiga só sai quando as partidas dela terminam.
  • Várias regiões (Brasil, EUA, Europa e Ásia): o lobby e as contas ficam em São Paulo e a partida roda no servidor de menor ping para nós.

Golpe aparado

O resto que dá trabalho

  • 31 idiomas, com o texto das cenas de história traduzido.
  • Contas com Google, Discord, Apple ou código por e-mail.
  • Chat e voz com filtro de conteúdo em vários idiomas, denúncia, bloqueio e silêncio.
  • Service Worker com cache verificado por hash, para o jogo abrir rápido sem servir arquivo adulterado.
  • Funciona no PC e no celular (na horizontal), com teclado ou controle.

O que eu aprendi

  1. Determinismo tem que ser decisão do primeiro dia. Adaptar depois é muito mais difícil, porque qualquer float, Date.now() ou iteração sobre Map com ordem diferente estoura a sincronia.
  2. Testes de replay valem ouro. Gravar as entradas e conferir que a mesma partida chega no mesmo estado pegou bug que nenhum teste unitário pegaria.
  3. Menos dependência, menos dor. Sem framework, o cliente carrega rápido e não tem surpresa de atualização.
  4. Segurança desde o começo. Servidor autoritativo e protocolo que muda por versão custam pouco no início e muito para encaixar depois.

Bora jogar?

O jogo acabou de sair e está sendo atualizado direto. O que mais me ajuda agora:

  • Jogar e chamar os amigos para uma sala: https://play.nanquim.gg
  • Contar o que achou aqui nos comentários: como sentiu o combate e o on-line, qual estilo está forte ou fraco demais e o que ficou confuso nos primeiros 5 minutos.
  • Se você for de jogo, rede ou game dev e tiver dúvida de como alguma parte foi feita, pergunta aí que eu explico com prazer.

Valeu! 🥋

Carregando publicação patrocinada...