Toda Máquina Concorda com o Mesmo Frame
Todo post anterior de co-op (uma simulação, muitos cursores e o servidor parou de se repetir) dependia de um servidor Node dedicado que rodava a simulação inteira e mandava o resultado pros clientes magros no navegador. Funcionava, mas era a forma errada pro rumo que o jogo está tomando. Essa semana eu arranquei a fundação e joguei outra: uma simulação determinística de passo fixo onde a mesma seed, fase e inputs produzem uma partida idêntica bit a bit em qualquer máquina. Depois reconstruí o co-op em cima disso como um relay entre pares em vez de uma simulação no servidor. Essa é a semana menos visual do projeto inteiro e a que mais me deixou feliz.
O problema de "roda a cada frame"
Até agora a simulação avançava por quanto tempo de relógio tivesse passado desde o frame anterior. Um monitor de 144Hz avançava o mundo em fatias minúsculas; um notebook de 30Hz avançava em fatias gordas. Isso é ok pra um jogador solo que nunca compara nada com ninguém. É fatal no instante em que duas máquinas precisam concordar sobre onde um inimigo está, porque a matemática de ponto flutuante depende da sequência exata de operações, e "avança 6,94ms" versus "avança 16,66ms" é uma sequência diferente. Os dois mundos se afastam em segundos.
A solução é o truque que todo jogo determinístico sério usa, do Trackmania aos jogos de luta: separar o relógio da simulação do relógio da tela.
A simulação faz tick; a tela interpola
A simulação agora avança em ticks fixos exatos de 1/20 de segundo, o FIXED_DT. Ela nunca vê um delta variável. A cada frame o loop de renderização acumula o tempo real decorrido num acumulador e o esvazia em ticks inteiros: se juntaram 33ms, isso são dois ticks de FIXED_DT com um resto sobrando. Um inteiro GameState.tick é o relógio mestre agora, incrementado uma vez por passo, a única fonte de verdade pra "que frame é esse."
O acelerador cai de graça. Os botões de 2x e 3x não mudam o tick; eles só enchem o acumulador mais rápido, então mais ticks são esvaziados por segundo. A sequência de ticks é idêntica seja em 1x ou 3x, que é exatamente a propriedade que você quer: adiantar não pode mudar o resultado.
O pega é que uma simulação de 20Hz fica travada se você desenha ela crua. Então a camada de apresentação interpola. Antes de cada tick eu guardo a posição de cada entidade, e o renderizador mistura a posição anterior em direção à atual por um alpha de sub-tick (accumulator / FIXED_DT). O mundo simula a 20Hz e desenha a 144Hz, liso feito manteiga, e o balanço cosmético (o gingado dos inimigos, o giro dos projéteis, os brilhos) ainda lê o relógio da tela direto, então nunca encosta na simulação. Um hookzinho observe(onRemove) invalida os slots de entidade reciclados pra um id reusado nunca interpolar a partir de um cadáver.
A parte que morde de verdade: transcendentais
Aqui está o assassino sutil. O + - * / e o Math.sqrt do JavaScript são arredondados corretamente pelo IEEE-754, ou seja, todo engine em toda plataforma devolve o mesmo padrão de bits. Mas sin, cos, atan2, exp, log, pow e o operador ** não são padronizados até o último bit. V8, SpiderMonkey e o engine JS de um celular podem cada um devolver um Math.sin(x) levemente diferente, e numa simulação determinística "levemente diferente" vira "totalmente dessincronizado" ao longo de alguns milhares de ticks.
Então todo transcendental que a simulação alcança agora passa por um módulo minúsculo, o deterministicMath.ts: dhypot, dsin, dcos, datan2, dexp, dln, dpow, todos construídos só a partir das operações seguras, todos feitos com TDD. Checagens de distância ao quadrado que usavam ** 2 foram reescritas como multiplicação simples. E como eu com certeza vou esquecer essa regra daqui a seis meses, tem um teste guardião que varre todo arquivo da simulação e quebra o build se um transcendental nativo ou um ** voltar escondido. Arquivos de apresentação e input estão isentos, já que nunca alimentam a simulação.
Pra provar que a coisa toda de fato se sustenta, o stateHash.ts gera uma impressão digital do mundo inteiro num único número, dbg.hash() imprime ela no console do navegador, e npm run determinism roda a mesma seed duas vezes sem interface e afirma que as impressões digitais batem tick a tick. Quando esse comando fica verde, a simulação é genuinamente determinística, não só parece determinística.
Co-op, reconstruído como relay
Com um núcleo determinístico, o servidor dedicado deixou de valer o custo. Pra que rodar a simulação num processo Node quando todo navegador consegue rodar a mesma simulação a partir da mesma seed e ficar sincronizado sozinho? Então o co-op foi reescrito como um relay determinístico com autoridade no host.
O navegador de quem criou a sala é a autoridade e roda a simulação de verdade. Todo outro jogador roda a própria cópia a partir da mesma seed e tick. O servidor agora é burro: ele repassa intents (carimbando cada um com o slot de quem enviou), repassa cursores e repassa as correções periódicas da autoridade. Ele não simula mais nada. Se o criador sair, a autoridade migra pra outro jogador via um helper puro de eleição, e a simulação dele simplesmente continua.
As entidades são reconciliadas por um id de rede determinístico, não pelo id de entidade do bitecs (o bitecs recicla esses entre tipos de entidade, então são inúteis como chave compartilhada). Um contador nextNid é carimbado em cada spawn e o mesmo spawn em todo par recebe o mesmo nid. Os snapshots carregam tick, seed, nextNid e indexam tudo por nid.
O sorteio de cartas saiu da simulação compartilhada de vez, porque é por jogador: cada jogador sorteia as próprias cartas do próprio RNG a partir do próprio pool de desbloqueios. Só a escolha resolvida é transmitida, então todo par aplica o mesmo efeito compartilhado sem nunca ver a sua tela de cartas.
Não dá tranco; espere a sua vez
A primeira versão do relay fazia os convidados aplicarem cada correção da autoridade no instante em que ela chegava, umas quatro vezes por segundo. Isso dava um efeito elástico horrível: inimigos que você estava observando teleportavam de volta pra onde estavam 250ms atrás, a cada 250ms. Feio.
A solução é um jitter buffer indexado por tick. Todo par continua rodando a própria simulação determinística. A autoridade manda correções carimbadas com o tick que elas descrevem. Um convidado não aplica uma correção na chegada; ele guarda ela e aplica exatamente quando o próprio relógio chega naquele tick, rodando de propósito com um atraso fixo (10 ticks, 500ms, adaptável até 20 se a conexão estiver ruim). Como um estado num tick reconcilia contra um estado no mesmo tick, a correção é minúscula, quase sempre invisível, em vez de um tranco pro passado.
O coopSync.ts puro cuida do controle de relógio: planFrame devolve uma lista ordenada de ações de passo / aplicar / snapshot que o loop principal executa. Se uma correção chega descrevendo um tick já no passado (overrun), ele congela por alguns ticks e descarta o dado velho. Se um convidado fica muito pra trás, ele dá um tranco pra frente até o full snapshot mais novo do buffer. O atraso se adapta: cresce quando a conexão gagueja e relaxa quando as coisas estão calmas.
Pra segurar a banda, as correções vêm em dois sabores. Um snapshot full sai a cada 20 ticks (uma vez por segundo) e sempre que aconteceu algo estrutural. Nos outros ticks a autoridade manda um delta: só as entidades que mudaram, mais listas de ids de rede removidos, tudo em valores absolutos. Um intent estrutural (uma torre colocada, uma carta escolhida) liga uma flag pendingFullSync pra que o próximo tick já dispare um full imediato, reconciliando todo convidado ao estado exato pós-evento. O servidor guarda só os fulls, então quem entra no meio da partida pode ser jogado direto na simulação ao vivo.
Onde isso chegou
O resultado é um jogo que roda um relógio com que todo mundo concorda. Solo e co-op compartilham o loop de passo fixo idêntico, a interpolação idêntica, a derivação de efeitos idêntica. O servidor foi de "roda o jogo" pra "encaminha correspondência." E a coisa toda é comprovadamente determinística, que é a propriedade sem glamour que destrava tudo que eu quero a seguir: replays, espectar e, lá na frente, um placar em que dá pra confiar de verdade. De volta pro quadro.