Pular para o conteúdo

O Que Construir um Jogo de Programação em Tempo Real Me Ensinou Sobre Tempo, Estado e Confiança

14 de out. de 2026 · por Dionisio · 10 min de leitura

Dois jogadores conectados a um servidor Go autoritativo, com matchmaking, Elo e bots rotulados

Tempo Real · WebSocket · Engenharia de Jogos

Quando o relógio faz parte do contrato, "na minha máquina funciona" deixa de bastar

Construí um jogo de programação 1v1 em tempo real. Parecia um projetinho divertido. Virou a minha lição mais dura sobre estado, tempo e confiança — porque, num jogo, dois jogadores nunca podem discordar sobre o que acabou de acontecer.

Estado Autoritativo Desvio do Contador Matchmaking Elo por Guerra

A maioria dos projetos “em tempo real” que eu vejo são chats ou dashboards. São em tempo real no sentido de que os dados acabam aparecendo. Esse é um problema tolerante: se uma mensagem chega dois segundos atrasada, ninguém perde nada.

Um jogo competitivo não é tolerante. Dois jogadores estão olhando para duas telas, e se essas telas discordam sobre o placar, o relógio ou de quem é a vez, o jogo está quebrado — não importa o quão bonita seja a interface.

O Code Royale é um jogo de batalha de programação 1v1. Os dois jogadores recebem o mesmo desafio ao mesmo tempo, respondem o mais rápido e o mais certo que conseguem, e quem enche a barra de energia primeiro vence a rodada. As rodadas fluem até alguém atingir a pontuação-alvo, e o Elo atualiza por tópico de programação. O estudo de caso completo está aqui. Este artigo é sobre as quatro decisões de engenharia que o jogo me forçou a tomar, e o que elas generalizam.

Decisão 1 — O servidor é dono de tudo; o cliente, de nada

Seção intitulada “Decisão 1 — O servidor é dono de tudo; o cliente, de nada”

O primeiro instinto quando você escreve um jogo multiplayer é deixar o cliente esperto. O cliente tem o desafio, sabe a resposta, então deixa ele conferir e avisar o servidor. Mais rápido, certo?

Errado, e perigosamente errado. No momento em que o cliente decide o placar, você tem dois problemas:

  1. Trapaça — qualquer um abre o devtools e envia “acertou” sem responder nada.
  2. Divergência — dois clientes calculam estados sutilmente diferentes e se afastam sem forma de reconciliar.

Então a arquitetura é o oposto de um CRUD. O servidor é a única fonte da verdade. O cliente não calcula o placar; ele renderiza o último estado que o servidor mandou e envia intenções:

cliente -> servidor: { type: "submit_answer", challengeId, choice }
servidor -> cliente: { type: "answer_result", correct, scores, energy }
cliente -> servidor: { type: "forfeit_battle" }

É exatamente o modelo de um servidor de jogo, e é a única decisão que separa um brinquedo de algo que dá para colocar no ar. A partir do momento em que o servidor é dono do estado, todo o resto — reconexão, anti-trapaça, espectador, bots — vira variação do mesmo tema, em vez de caso especial novo a cada vez.

A parte sutil não é o socket. É o intervalo entre enviar uma intenção e receber a resposta. Você mostra “acertou!” na hora, ou espera o servidor?

Para respostas de múltipla escolha, o Code Royale espera. O botão é pessimista. Um “acertou!” otimista que depois é revertido é pior do que meio segundo de espera, porque o jogador aprende a desconfiar do próprio feedback do jogo. A confiança é o produto; a latência dá para esconder, uma mentira não.

Decisão 2 — Um contador regressivo é mais difícil do que parece

Seção intitulada “Decisão 2 — Um contador regressivo é mais difícil do que parece”

Um relógio correndo parece a coisa mais simples da tela. É onde a maioria dos apps em tempo real quebra em silêncio.

Três modos de falha apareceram nos testes, todos visíveis para o jogador:

Arredondamento divergente. Se o cliente arredonda para baixo e o servidor para cima (ou vice-versa), o número exibido oscila entre N e N−1 várias vezes por segundo. O jogador vê um número que não se decide. A correção é chata e obrigatória: os dois lados usam o mesmo arredondamento, na mesma direção.

Tremor causado por ping. Uma implementação ingênua recalcula “tempo restante” do zero a cada mensagem do servidor. Cada ping chega alguns milissegundos fora de sincronia, então os segundos gaguejam. A correção é parar de derivar o relógio do servidor: o cliente é dono de um prazo absoluto e deriva cada segundo exibido dele, localmente.

Estrangulamento em segundo plano. Este é o que surpreende. Quando a aba vai para segundo plano, os navegadores estrangulam os timers para cerca de uma vez por minuto. Seu contador congela enquanto o jogador está fora — e o jogador volta para um relógio simplesmente errado.

O modelo que sobrevive aos três:

  • o cliente guarda um prazo absoluto, calculado uma vez no início da rodada
  • o segundo exibido é derivado dele a cada quadro, sem recálculo a partir dos pings
  • os pings do servidor só corrigem o desvio acumulado, e só quando a discordância passa de um limite
  • no visibilitychange, o cliente ressincroniza o relógio imediatamente

O resultado é um contador correto em reconexões, abas em segundo plano e desvio de relógio — porque ele só acompanha um número, e deixa o servidor ajustar esse número em vez de substituí-lo.

Matchmaking parece problema de algoritmo. É problema de produto.

A abordagem ingênua é “ache alguém do meu nível”. Se não tem ninguém, o usuário espera. Aí ele sai. Aí a fila fica mais vazia, então o próximo espera mais, e sai também. É assim que um produto em tempo real morre — não com um erro, mas com silêncio.

O matchmaking do Code Royale é uma fila por guerra com janela de Elo que se alarga:

t = 0s : bucket exato de Elo
t = 5s : ±1 bucket
t = 10s : ±2 buckets
t = 20s : ilimitado
t = 30s : gera um bot no Elo do jogador

Duas coisas importam aqui. Primeiro, a janela se alarga ao longo do tempo — uma partida um pouco injusta é melhor do que partida nenhuma. Segundo, o fallback de bot faz parte da funcionalidade, não é tratamento de erro. O motor de batalha trata um bot exatamente como um humano: mesmo caminho de código, mesmas mensagens, mesmo timer. A única diferença é o que está do outro lado do socket.

Essa escolha de design compensa várias vezes. O bot mantém o jogo jogável às 3 da manhã com zero usuários online, dá ao novato uma primeira partida em vez de um lobby vazio, e custa quase nada porque reaproveita todo o caminho do humano.

A generalização: decida de antemão quando parar de procurar e começar a jogar. Degradar com elegância não é um fallback que você prega depois; é um comportamento projetado, com um limite.

A primeira versão tinha um Elo único por jogador. Estava errado, e o motivo vale ser dito.

Uma nota única mistura habilidades sem relação. Um jogador forte em Go não recebe crédito por isso quando entra numa batalha de SQL, e uma derrota num tópico que ele nunca estudou puxa para baixo a nota “de verdade”. O número para de significar algo, então a escada para de motivar qualquer um.

A correção é uma linha player_war_elo por (jogador, guerra). A mesma conta carrega uma nota diferente em cada tópico de programação, calculada de forma independente a partir das batalhas daquele tópico. Quais batalhas contam é determinado por participação numa tabela de junção, não por adivinhação a partir de prefixo de ID — uma decisão pequena de schema que mantém os dados honestos.

A consequência de produto é grande: um tópico novo é uma nova escada para subir, não um reset de tudo o que você conquistou. Adicionar um domínio vira convite, em vez de punição.

A lição menos glamourosa, e a mais importante para qualquer coisa movida a conteúdo.

O banco de desafios é dado. Dado pode estar errado. Três exemplos reais deste projeto:

  • uma questão de múltipla escolha cujas duas melhores alternativas são ambas defensáveis
  • um prompt de análise de código cuja saída esperada depende de um espaço no começo que o HTML colapsa — então duas respostas diferentes renderizam idênticas e a questão fica impossível de responder
  • um desafio ligado ao tópico errado, então ele aparece onde não deveria

Nenhum desses derruba a aplicação. Sem exceção, sem log de erro. Eles só tornam o jogo injusto em silêncio — e injustiça é a única coisa que um produto competitivo não sobrevive.

Então o banco é gerenciado como artefato de software, não como planilha:

  • tipos de desafio bem definidos, com formato validado
  • participação por guerra numa tabela de junção explícita
  • uma passada de auditoria que confere a correção contra o gabarito real
  • ferramenta de correção em massa para ajustes mecânicos, como recalcular todas as saídas esperadas de uma vez

A regra à qual eu sempre volto: uma “resposta certa” errada é pior do que um crash. Um crash é barulhento e é consertado. Um gabarito errado é silencioso e corrói a confiança partida a partida.

O Code Royale é um jogo, mas essas decisões não são específicas de jogo. Elas aparecem sempre que um produto precisa concordar consigo mesmo em tempo real:

  • Onde está a fonte única da verdade do placar, e do tempo? Se dois sistemas podem calcular o mesmo valor, uma hora eles vão discordar. Escolha um dono.
  • O que acontece entre uma intenção e sua confirmação? Esse intervalo é decisão de produto, não detalhe de UI. Otimista parece rápido; pessimista parece honesto. Escolha de propósito.
  • Quando a fila está vazia, o produto degrada ou morre? Silêncio é indisponibilidade. Projete o fallback como funcionalidade com limite, não como tratamento de erro.
  • O conteúdo está realmente correto, ou apenas presente? Produtos movidos a conteúdo falham na correção, não na renderização.

Nada disso exige um motor de jogo. Exige decidir, de antemão, quais falhas são inaceitáveis — e então projetar para que elas não possam acontecer em silêncio.

Se você quiser se aprofundar no código e no estudo de caso:

Se for para levar uma frase: num produto em tempo real, o relógio faz parte do contrato. Projete para ele no momento em que projeta seu estado, ou ele vai criar as próprias regras sem você.