Uma Simulação, Vários Cursores: Enfiando Co-op Online num ECS
No post passado eu te falei que o td-survivors tava pronto, dez posts e um "fim de papo", "vejo você quando parecer um jogo". Aí eu adicionei co-op online. Esse é um post sobre co-op. Eu sou cheio de camadas, e também menti.
Vai a coisa que me pegou de surpresa, e o motivo desse post existir: co-op foi fácil. Não "fácil" naquele jeito que dev fala que algo foi fácil logo antes de explicar as seis semanas de sofrimento. Fácil de verdade. Um fim de semana. E o motivo de ter sido fácil é exatamente o mesmo motivo do post 10 existir: o jogo inteiro é uma pilha de funções puras mexendo em arrays de números, sem classes e sem this. Acontece que se você faz um jogo assim, o netcode já tá meio que escrito, você só ainda não tinha reparado.
A cilada que todo mundo cai: lockstep
O jeito óbvio de fazer multiplayer pra um tower defense que parece determinístico é lockstep: as duas máquinas rodam a mesma simulação, você só manda os inputs um pro outro, e como os dois rodam o mesmo código com os mesmos inputs, os dois chegam no mesmo resultado. Limpo. Elegante. Mentira deslavada pra esse jogo.
A sim roda em cima de dt, um delta de tempo em ponto flutuante que varia, e faz milhares de multiplicações de float por frame. Ponto flutuante em JavaScript não tem garantia nenhuma de ser bit a bit igual entre máquinas, browsers, ou até o humor da CPU naquele dia. Então na sua máquina um inimigo tá em pathDist = 4.0000001 e na minha tá em 3.9999998, e isso já basta. Um frame depois a sua torre atira e a minha não, e agora as nossas duas simulações foram embora de mãos dadas pra universos diferentes, pra nunca mais se reconciliar. Dessync de lockstep é lenda de terror de gamedev, e eu não ia escrever um aparato de auditoria de determinismo pra um jogo cuja arte ainda é quadradinho placeholder.
Então: sem lockstep. Em vez disso, uma máquina tá certa e o resto tá assistindo.
Um servidor autoritativo, vários clientes magros
O modelo é simplório. Um processo Node headless roda a simulação de verdade, a única fonte da verdade. Os dois browsers são clientes magros: eles mandam o que o jogador quer fazer, e desenham o que o servidor disser que o mundo tá parecendo. O seu browser não simula nada. O seu browser é um jeito caríssimo de ficar olhando os arrays de outra pessoa.
E é aqui que a disciplina do ECS paga sozinha. Lembra da simulação inteira do post passado, spawn, mover, atirar, morrer, catar XP? É uma função:
export const stepSim = (w: GameWorld, dt: number): void => {
const g = w.game
g.time += dt
spawnSystem(w)
movementSystem(w, dt)
// ...status, shooting, projectiles, death, xpOrbs, leak, levelUp
}
A regra que deixou o single player testável, a sim importa zero LittleJS, nada de render, nada de input, nada de áudio, foi escrita meses antes de co-op sequer ser um plano. Mas ela faz o stepSim rodar numa boa dentro do Node, onde não existe canvas, nem caixa de som, nem mouse. O servidor só chama ela num timer:
room.tick = setInterval(() => {
for (const intent of batch) applyIntent(w, intent)
if (w.game.phase === 'playing') stepSim(w, (1 / TICK_HZ) * w.game.speedMult)
broadcast(room, { t: 'snapshot', s: encodeSnapshot(w, room.stageId) })
}, TICK_MS)
Trinta vezes por segundo: aplica os inputs enfileirados de todo mundo, avança a única simulação verdadeira em um tick, e manda pra todo mundo uma foto do resultado. É o loop inteiro do servidor. O jogo single player e o servidor multiplayer rodam o mesmo código de simulação, byte por byte, porque nunca existiu uma segunda cópia pra manter em sincronia. Existe um stepSim, e ele não tá nem aí se quem chamou foi o game loop de um browser ou um setInterval do Node num datacenter.
Inputs são intents, e cursor é grudento
Cliente não encosta no mundo. Ele manda intents, um vocabulariozinho serializável de "coisas que um jogador pode querer":
export type Intent =
| { kind: 'cursor'; slot: number; x: number; y: number }
| { kind: 'placeTower'; slot: number; towerType: TowerTypeId; x: number; y: number }
| { kind: 'pickCard'; slot: number; index: number }
| { kind: 'nextWave'; slot: number }
| { kind: 'cycleSpeed'; slot: number }
// ...move, re-aim, fila de torre, reroll, banish, pause
Todo intent carrega um slot: qual jogador é você. A parte bonita é que applyIntent é o mesmo caminho de código no single player e no co-op. No single player, o clique do seu mouse vira um intent placeTower aplicado no seu mundo local na hora. No co-op, o intent idêntico é despachado por um WebSocket, e o servidor aplica no mundo de verdade. Mesma função, mesmo resultado, escrita uma vez só. O servidor só faz uma coisinha paranoica: ele sobrescreve o slot de todo intent que chega com o slot real de quem mandou, pra você não conseguir mandar um placeTower fingindo ser o seu amigo e construir torre com o inventário dele. (Eu faria isso com um amigo na hora. É exatamente por isso que o servidor não confia em mim.)
O grudento é o cursor. Trinta vezes por segundo, o seu browser conta pro servidor exatamente onde tá o seu mouse, tipo um amigo de correspondência ansioso que nunca ouviu falar de espaço pessoal. É assim que o seu parceiro vê o seu cursorzinho deslizando pelo mapa. É também como o jogo sabe qual cursor catou qual orbe de XP, porque XP no co-op é competitivo: os orbes vão pra quem tiver o cursor mais perto, e os níveis são por jogador. Co-op de td-survivors é um jogo cooperativo até o exato momento em que cai um orbe roxo de 100 de XP, quando vira uma corrida e uma traição.
Snapshots: o ECS deixa a serialização entediante
Aqui é a parte onde "sem OOP, só arrays" para de ser preferência estética e vira código de trapaça.
O servidor tem que descrever o mundo inteiro pros clientes, trinta vezes por segundo. Numa engine orientada a objetos isso é genuinamente um saco: você tem um grafo de objetos inimigo apontando pra objetos torre apontando pra objetos projétil, tudo embolado com referência de this e método, e você tem que caminhar nesse grafo, achatar, tirar o comportamento, e remontar do outro lado sem mandar sem querer uma função ou um loop infinito.
Num ECS, o mundo já é chapado. Já é Enemy.hp = [ ...números ], Position.x = [ ...números ]. Um snapshot é basicamente "leia os arrays". O encodeSnapshot percorre as queries de entidade, pega os campos que os clientes de fato precisam, e joga tudo num JSON. O applySnapshot no cliente escreve esses números de volta nos arrays do mundo-sombra dele. Não tem objeto pra reconstruir porque nunca teve objeto. O inimigo sempre foi só o número 7 e um combinado de quais arrays ler, e esse combinado serializa perfeitamente, porque ele não é nada.
A única sutileza de verdade é que o bitECS recicla id de entidade, então o eid 7 pode ser um inimigo no servidor e um projétil no mundo-sombra do cliente. Então cada entidade ganha um id de rede estável, separado por tipo, e um NidMap mantém "inimigo 7 do servidor" apontando pro "meu inimigo local 41" ao longo dos ticks. Essa é a única contabilidade de verdade da feature inteira, e são umas quarenta linhas.
De onde vêm as explosões?
Se o servidor só manda número, quem dispara as partículas?
Essa foi a única decisão de design que deu trabalho de pensar, e é a coisa de que eu tenho mais orgulho. A simulação não emite apresentação. Nenhuma. Não tem um spawnParticle em lugar nenhum do stepSim, nada de som, nada de tremida de tela. A sim é uma máquina de estado pura que só sabe mudar número. Isso é inegociável, porque o servidor não tem tela pra tremer nem caixa de som pra estourar, e eu me recuso a manter duas versões da sim.
Então todo efeito do jogo é derivado no cliente olhando os números mudarem. Um arquivo, deriveFx.ts, faz o diff do mundo todo frame e deduz o drama:
- O
hpde um inimigo caiu desde o frame passado? Solta um número de dano, toca um som de acerto. - Um inimigo sumiu perto do fim do caminho? Isso é um vazamento: treme a tela.
- Um inimigo sumiu em qualquer outro lugar? Isso é uma morte: explosão de partícula.
- O
lastFirede uma torre avançou? Ela acabou de atirar: flash de cano e um estouro. - Um projétil sumiu? Explosão ou faísca, dependendo da torre.
- Um orbe de XP sumiu coladinho num cursor? Timbre de coleta.
A mágica é que esse é o exato mesmo derivador no single player e no co-op. No single player ele olha o seu mundo local tick a tick. No co-op ele olha o mundo-sombra sendo remendado pelos snapshots. Ele não consegue ver a diferença e nem precisa, porque "um número mudou, logo aconteceu alguma coisa" é verdade dos dois jeitos. A tremida de tela, que antes era um valor dentro da sim, foi despejada e mudou pro cliente, o lugar dela, porque servidor headless tremendo uma tela que não existe é a preparação pra uma piada filosófica que eu escolhi não fazer.
Entra, sai, ninguém manda
A última coisa boa: não tem limite de jogador nem cerimônia de lobby. Você cria uma sala, ganha um código e um link ?room=CODE, e o host começa quando quiser, até sozinho. Qualquer um pode entrar a qualquer momento, inclusive no meio da run: o servidor só acrescenta um slot, cresce o mundo em andamento em um jogador, e manda pro recém-chegado uma mensagem started pra jogar ele direto dentro da simulação viva. Crescer o mundo é, de novo, entediante: acrescenta uma entrada no array de players. Os arrays não tão nem aí.
Sair é igual de tranquilo. O seu slot vira lápide, connected = false, então todo mundo para de desenhar o seu cursor, mas as suas torres ficam, porque torre é entidade compartilhada do mundo e nunca foi sua pessoalmente. A run continua pro resto. A sala só morre quando a última pessoa sai e apaga a luz. Não tem migração de host porque depois do START o host não tem poder nenhum mesmo; "host" é só quem clicou em criar, um título honorário sem regalia.
Então funcionou tudo de primeira?
Na maior parte, ofensivamente, sim. A parte assustadora, a parte com fama de Netcode com N maiúsculo, foi um anticlímax, porque a arquitetura já tinha feito o trabalho pesado meses antes com outro nome. Os bugs que eu peguei foram gloriosamente banais e nada de netcode: o convidado não conseguia ver as cartas de level-up por uma build ou duas (uma treta de encanamento de UI, as cartas tavam lá, o convidado só não tava vendo elas), e eu tive que lembrar de congelar a sim no servidor quando a run acaba, pra os dois jogadores verem a mesma tela de game-over em vez do mundo do host continuar jogando de boa. Nenhum dessync. Nenhum "funciona na minha máquina e diverge na sua". Nenhuma auditoria de determinismo. Porque tem uma máquina, e ela tá certa, e o resto tá educadamente olhando os arrays dela.
É esse o truque inteiro, e é o mesmo truque do jogo inteiro: mantém o estado em arrays chapados, mantém a lógica em funções puras, e deixa um loop ser a verdade. Faz isso e multiplayer para de ser reescrita e vira endereço de entrega.
Beleza. Agora a série acabou. Provavelmente. Vejo você quando parecer um jogo, talvez com dois cursores em cima.