Netcode e Multiplayer: Otimização de Latência e Sincronização em Jogos Online

Guia completo de netcode para jogos multiplayer: arquiteturas, sincronização, compensação de lag e técnicas avançadas de otimização
Netcode e Multiplayer: Otimização de Latência e Sincronização em Jogos Online
Netcode é a parte do seu jogo que ninguém vê e todo mundo sente. Quando está bom, ninguém comenta. Quando está ruim, o jogador acha que o jogo "é travado", culpa a internet dele e desinstala. Levei muito tempo pra entender que a maioria dos problemas de "lag" não é a rede em si: é como o jogo lida com a rede.
Esse texto é o que eu queria ter lido quando comecei a mexer com multiplayer. Sem fórmula mágica, sem "implemente isso e resolva tudo". A real é que netcode é uma série de trade-offs, e o seu trabalho é escolher quais erros você prefere cometer.
A primeira decisão: quem manda na simulação
Antes de qualquer técnica de otimização, você precisa decidir quem é a fonte da verdade no seu jogo. Isso muda tudo. As três opções na prática:
Cliente-servidor autoritativo. Um servidor central roda a simulação e tem a palavra final. Os clientes mandam input, o servidor responde com estado. É o padrão de quase todo jogo competitivo (FPS, MMO, battle royale) por um motivo simples: o cliente não decide nada, então é muito mais difícil cheatear. O custo é dinheiro (você paga pelos servidores) e latência (todo input dá uma volta completa até o servidor e volta, o RTT inteiro).
Peer-to-peer (P2P). Os jogadores se conectam direto, sem servidor no meio. Latência menor entre dois peers e custo zero de infra. Em troca você herda três dores de cabeça: cheating é trivial (ninguém é autoridade), NAT traversal é chato de fazer funcionar, e quando o host cai você precisa de host migration. Funciona bem pra cooperativo, RTS de poucos jogadores e jogos de luta.
Híbrido. Servidor pra matchmaking, lobby e antifraude; P2P ou um servidor leve pra sessão em si. É o que muitos jogos de lobby usam. Mais flexível, mais complexo de manter (são dois sistemas).
Sobre P2P, vale um aviso que poucos falam: o número de conexões cresce rápido. Com N jogadores conectados todos contra todos, você tem N×(N-1)/2 conexões. Com 4 jogadores são 6 conexões; com 8 já são 28. Por isso P2P full-mesh não escala pra muita gente, e é parte do motivo de jogos grandes serem cliente-servidor.
Meu conselho pra quem está começando: comece com cliente-servidor autoritativo. É mais trabalho no início, mas você evita reescrever o jogo inteiro quando descobrir que metade dos jogadores está usando aimbot.
Predição e reconciliação: como esconder a latência
Aqui está o problema central. Se o servidor é autoritativo, todo input do jogador precisa ir até o servidor e voltar antes de surtir efeito na tela. Com 80ms de ping, isso são 80ms entre apertar a tecla e o personagem andar. Joga assim e você vai querer chorar.
A solução clássica tem dois lados:
Predição do lado do cliente. O cliente não espera o servidor. Ele aplica o input localmente na hora (anda, atira, pula) assumindo que o servidor vai concordar. O jogador sente resposta instantânea.
Reconciliação. O cliente guarda cada input numerado que mandou. Quando o servidor responde com o estado oficial, ele diz "processei até o input número X". O cliente joga fora os inputs que o servidor já confirmou, corrige a posição pra bater com a do servidor, e reaplica em cima os inputs que o servidor ainda não viu. Na maior parte do tempo a predição acertou e nada muda na tela. Quando erra (você bateu numa parede que não previu), o servidor corrige.
Esse é o coração do netcode de FPS, e foi descrito pela Valve no clássico artigo de Source Multiplayer Networking. A lógica de reconciliação fica mais ou menos assim:
// Cliente: guarda inputs pendentes e reconcilia com o servidor
private Queue<InputCommand> pendingInputs = new Queue<InputCommand>();
private int inputSequence = 0;
void FixedUpdate()
{
if (!IsLocalPlayer) return;
// 1. Captura e numera o input
InputCommand input = CaptureInput();
input.sequence = inputSequence++;
// 2. Aplica localmente AGORA (predição) e guarda pra reconciliar
ApplyInput(ref localState, input);
pendingInputs.Enqueue(input);
// 3. Manda pro servidor
SendInputServerRpc(input);
}
// Chamado quando o servidor responde com o estado oficial
void OnServerState(PlayerState serverState, int lastProcessedSeq)
{
// Descarta os inputs que o servidor já aplicou
while (pendingInputs.Count > 0 && pendingInputs.Peek().sequence <= lastProcessedSeq)
pendingInputs.Dequeue();
// Começa do estado autoritativo do servidor
localState = serverState;
// Reaplica os inputs que o servidor ainda não viu
foreach (var input in pendingInputs)
ApplyInput(ref localState, input);
}
O detalhe que faz isso funcionar é o ApplyInput ser a mesma função no cliente e no servidor, e ser determinística. Se a física de movimento do cliente diverge da do servidor, a reconciliação vira um soluço visual constante. Mantenha o movimento do jogador o mais simples e determinístico possível.
Um ponto importante sobre a correção: não dê teleporte. Se você simplesmente jogar o jogador pra posição do servidor, vai ver "rubber banding" feio. Quando a divergência for pequena, suavize a correção ao longo de alguns frames (um Lerp em vez de um salto). Quando for grande, aí sim corrija de uma vez, porque suavizar muito deixa o personagem fora do lugar por tempo demais.
Sincronizando os outros jogadores: interpolação
Predição resolve o SEU personagem. Mas e os outros jogadores na tela? Você recebe a posição deles a cada tick do servidor (digamos, 20 ou 30 vezes por segundo), não a cada frame. Se você só plotar a última posição recebida, eles vão andar aos trancos.
A técnica é interpolar com um pequeno buffer de atraso. Você guarda os últimos estados recebidos e renderiza os outros jogadores propositalmente "no passado", uns 100ms atrás. Assim você sempre tem dois estados (um antes, um depois do tempo de render) e desliza suavemente entre eles. O custo é que você vê os outros jogadores 100ms atrasados, o que a compensação de lag (mais abaixo) resolve na hora de mirar.
// Renderiza no passado, interpolando entre os dois estados conhecidos
class EntityInterpolator {
constructor(bufferMs = 100) {
this.buffer = [] // [{ t, position }] em ordem cronológica
this.bufferMs = bufferMs
}
addSnapshot(timestamp, position) {
this.buffer.push({ t: timestamp, position })
}
positionAt(now) {
const renderTime = now - this.bufferMs
// Acha o par de estados que cerca o renderTime
for (let i = 0; i < this.buffer.length - 1; i++) {
const a = this.buffer[i]
const b = this.buffer[i + 1]
if (a.t <= renderTime && renderTime <= b.t) {
const f = (renderTime - a.t) / (b.t - a.t)
return {
x: a.position.x + (b.position.x - a.position.x) * f,
y: a.position.y + (b.position.y - a.position.y) * f,
z: a.position.z + (b.position.z - a.position.z) * f,
}
}
}
// Sem estado futuro disponível: fica na última posição conhecida
return this.buffer.length ? this.buffer[this.buffer.length - 1].position : null
}
}
Extrapolação (chutar onde o jogador estaria baseado na velocidade) existe e às vezes ajuda quando um pacote atrasa, mas use com cuidado: ela inventa movimento que pode não ter acontecido, e quando o estado real chega você precisa corrigir. Na prática, um buffer de interpolação um pouco maior costuma incomodar menos do que extrapolação agressiva.
Compensação de lag: acertando o tiro
Esse é o ponto onde todo mundo briga online. Você atira na cabeça do inimigo, a mira estava em cima, e o jogo diz que errou. Quase sempre é porque, do lado do servidor, o inimigo já tinha se mexido.
A compensação de lag resolve isso com "rewind". O servidor guarda um histórico das posições e hitboxes de cada jogador nos últimos instantes. Quando recebe um tiro, ele calcula em que momento o atirador realmente viu a cena (descontando a latência dele) e rebobina os outros jogadores pra aquele instante antes de testar a colisão. Ou seja: o servidor valida o tiro no mundo como o atirador via, não no mundo "agora".
# Servidor: rebobina os alvos pro instante em que o atirador viu a cena
class LagCompensation:
def __init__(self, history_seconds=1.0):
self.history_seconds = history_seconds
self.history = {} # player_id -> [(timestamp, position, hitbox)]
def record(self, player_id, timestamp, position, hitbox):
snaps = self.history.setdefault(player_id, [])
snaps.append((timestamp, position, hitbox))
# Descarta o que é mais velho que a janela de histórico
cutoff = timestamp - self.history_seconds
self.history[player_id] = [s for s in snaps if s[0] > cutoff]
def position_at(self, player_id, target_time):
snaps = self.history.get(player_id, [])
for (t1, p1, _), (t2, p2, _) in zip(snaps, snaps[1:]):
if t1 <= target_time <= t2:
f = (target_time - t1) / (t2 - t1)
return lerp(p1, p2, f)
return snaps[-1][1] if snaps else None
def check_shot(self, shooter_id, shooter_latency, origin, direction):
rewind_time = now() - shooter_latency
for player_id in self.history:
if player_id == shooter_id:
continue
pos = self.position_at(player_id, rewind_time)
if pos and ray_hits_box(origin, direction, pos):
return player_id
return None
O trade-off da compensação de lag é honesto e vale conhecer antes de implementar: ela favorece o atirador. Em ping alto, isso gera o famoso "morri atrás da parede". Você correu pra cobertura, mas na máquina do inimigo (atrasada) você ainda estava exposto, e o servidor validou o tiro dele. Não tem como ter os dois: ou você favorece o atirador (e às vezes morre atrás da parede), ou favorece quem foge (e às vezes acerta tiro perfeito que não conta). Quase todo FPS escolhe favorecer o atirador, porque acertar o tiro que você viu acertar é mais satisfatório do que ser punido por uma defasagem invisível.
Rollback: o netcode dos jogos de luta
Jogos de luta e ação rápida têm uma exigência brutal: cada frame importa, e interpolar de 100ms é inaceitável. A resposta da comunidade é o rollback netcode (o famoso GGPO).
A ideia: em vez de esperar o input do oponente, o cliente prevê (geralmente repetindo o último input dele) e simula em frente, sem travar. Quando o input real chega e é diferente do previsto, o jogo rebobina pro frame daquele input, aplica o input correto e re-simula rapidamente até o presente. Como tudo acontece em poucos frames, na maioria das vezes você nem percebe.
// Esqueleto de rollback: recebeu input do passado -> rebobina, corrige, re-simula
const int MAX_ROLLBACK = 8;
GameState[] stateHistory = new GameState[MAX_ROLLBACK];
void OnRemoteInput(int frame, PlayerInput input)
{
if (frame >= currentFrame)
{
StoreInput(frame, input); // input pro presente/futuro: só guarda
return;
}
int distance = currentFrame - frame;
if (distance > MAX_ROLLBACK)
return; // atrasou demais: não dá pra recuperar, aceita o desvio
// 1. Restaura o estado salvo no frame do input
RestoreState(stateHistory[frame % MAX_ROLLBACK]);
// 2. Corrige o input previsto pelo input real
StoreInput(frame, input);
// 3. Re-simula do frame corrigido até o presente, salvando cada estado
for (int f = frame; f <= currentFrame; f++)
{
SimulateFrame(GetInputsForFrame(f));
stateHistory[f % MAX_ROLLBACK] = CaptureState();
}
}
Dois requisitos não negociáveis pra rollback funcionar: a simulação precisa ser totalmente determinística (mesmos inputs sempre geram o mesmo estado), e salvar/restaurar estado precisa ser barato, porque você faz isso várias vezes por segundo. Se o seu game state é pesado pra serializar, rollback fica caro. Vale planejar isso desde o começo, não depois.
Lockstep determinístico: o caminho dos RTS
RTS com centenas de unidades têm um problema diferente: sincronizar a posição de 300 unidades a cada tick estouraria a banda. A solução é não mandar estado nenhum. Você manda só os comandos (os inputs), e cada máquina roda a mesma simulação determinística em cima dos mesmos comandos. Todos chegam ao mesmo resultado sem nunca trocar posições.
Isso é lockstep determinístico. O jogo avança em turnos: cada cliente envia seus comandos pro turno N, e ninguém executa o turno N até ter os comandos de todos. Por isso RTS toleram latência mais alta (o turno tem um atraso embutido) mas são sensíveis a qualquer divergência.
E aqui vem o pulo do gato que muito tutorial erra: lockstep exige matemática determinística, e ponto flutuante (float/double) não é confiável entre máquinas diferentes. O mesmo cálculo pode dar resultados ligeiramente diferentes dependendo de CPU, compilador e flags de otimização. Uma diferença minúscula no frame 1 vira duas partidas completamente distintas no frame 5000. Isso se chama desync.
A solução real é usar aritmética de ponto fixo (inteiros representando frações, por exemplo multiplicando tudo por 1000 e trabalhando com inteiros) ou uma biblioteca de matemática fixa, em toda a lógica que afeta a simulação. Não adianta calcular em ponto fixo e converter pra float no meio do caminho: você perde o determinismo de novo. O ponto fixo precisa atravessar a simulação inteira.
# Lockstep: só comandos viajam; todos executam o turno na MESMA ordem
class Lockstep:
def __init__(self, player_count):
self.player_count = player_count
self.inputs = {} # turn -> {player_id: commands}
self.confirmed_turn = -1
def submit(self, player_id, turn, commands):
self.inputs.setdefault(turn, {})[player_id] = commands
# Só executa quando TODOS enviaram o turno
if len(self.inputs[turn]) == self.player_count:
self.execute(turn)
def execute(self, turn):
if turn != self.confirmed_turn + 1:
return # turno fora de ordem: espera
# Ordem determinística é obrigatória: ordene por player_id sempre
for player_id in sorted(self.inputs[turn]):
for cmd in self.inputs[turn][player_id]:
self.apply(cmd) # apply usa SÓ aritmética de ponto fixo
self.confirmed_turn = turn
def checksum_matches(self, local, remote):
# Cada cliente envia um checksum do estado; divergiu = desync
return local == remote
Na hora de debugar, calcule um checksum do estado de jogo a cada turno e compare entre os clientes. No momento em que os checksums divergem, você sabe exatamente o turno onde o determinismo quebrou. Sem isso, caçar desync é um inferno.
Banda: gaste bytes onde importa
Banda é finita e cada byte que você manda é multiplicado pelo número de jogadores e pela taxa de tick. Três técnicas que dão o maior retorno:
Delta compression. Não mande o estado inteiro toda vez. Mande só o que mudou desde o último estado que o cliente confirmou. Se o jogador não se moveu, não mande a posição. Um header de bits indica quais campos vêm no pacote. Em jogos com muitas entidades estáticas ou paradas, isso corta uma fatia enorme do tráfego.
Quantização. Sua posição não precisa de um float de 32 bits de precisão. Se você sabe os limites do mundo, mapeie a coordenada pra um inteiro de 16 bits dentro daquele intervalo. Cabe metade dos bytes e o jogador não percebe diferença de meio centímetro.
// Quantiza um float dentro de [min, max] em N bits
function quantize(value, min, max, bits) {
const maxInt = (1 << bits) - 1
const normalized = Math.min(1, Math.max(0, (value - min) / (max - min)))
return Math.round(normalized * maxInt)
}
function dequantize(q, min, max, bits) {
const maxInt = (1 << bits) - 1
return min + (q / maxInt) * (max - min)
}
Pra rotação tem um truque consagrado: o "smallest three". Um quaternion tem 4 componentes, mas como ele é normalizado você pode omitir o maior componente e reconstruí-lo a partir dos outros três (mais 2 bits dizendo qual foi omitido). De 16 bytes pra 4. É padrão em jogos que mandam muita rotação.
Sistema de prioridade. Nem toda entidade merece a mesma atenção. O jogador do seu lado importa mais que um item do outro lado do mapa. Calcule uma prioridade por entidade (distância, se está no campo de visão, se é jogador ou projétil, há quanto tempo não atualiza) e mande updates frequentes só pro que é relevante. Coisas distantes e irrelevantes podem atualizar uma vez por segundo, ou nem isso, sem ninguém notar.
// Prioridade simples por entidade para decidir frequência de update
float Priority(Entity e, Player viewer)
{
float p = 0f;
// Mais perto = mais importante
float dist = Vector3.Distance(e.position, viewer.position);
p += 10f / (1f + dist * 0.01f);
// Tipo de entidade pesa no gameplay
if (e.isPlayer) p += 20f;
else if (e.isProjectile) p += 15f;
// No campo de visão importa mais
if (viewer.CanSee(e)) p += 5f;
// Quanto mais tempo sem atualizar, mais urgente fica
p += e.TimeSinceLastUpdate() * 2f;
return p;
}
A regra mental: gaste banda em proporção ao quanto o jogador vai notar a falta dela.
Meça antes de otimizar
Não dá pra arrumar o que você não mede. Instrumente o netcode desde cedo e acompanhe pelo menos quatro números:
- RTT (latência): quanto tempo um pacote leva pra ir e voltar.
- Jitter: a variação do RTT. Jitter alto é pior que latência alta e estável, porque seus buffers de interpolação não conseguem se planejar.
- Packet loss: percentual de pacotes perdidos. Acima de uns poucos por cento, o jogo começa a sentir.
- Banda: quantos bytes por segundo você está realmente mandando por jogador.
// Métricas básicas de rede para você não otimizar no escuro
class NetMetrics {
constructor() {
this.rtt = []
this.jitter = []
}
recordRtt(sendTime, receiveTime) {
const rtt = receiveTime - sendTime
if (this.rtt.length > 0) {
const prev = this.rtt[this.rtt.length - 1]
this.jitter.push(Math.abs(rtt - prev))
}
this.rtt.push(rtt)
return rtt
}
packetLoss(sent, received) {
return sent === 0 ? 0 : ((sent - received) / sent) * 100
}
avg(arr) {
return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0
}
}
E o mais importante de tudo: teste em condições ruins de propósito. Seu jogo vai parecer perfeito na sua LAN e horrível na internet real. Use uma ferramenta de simulação de rede (o Clumsy no Windows é gratuito e faz o trabalho) pra injetar latência, jitter e packet loss enquanto desenvolve. É a diferença entre descobrir o problema agora ou na review do jogador.
Ferramentas e bibliotecas
Pra Unity, as opções principais hoje:
- Netcode for GameObjects: solução oficial da Unity, boa pra cliente-servidor e bem documentada.
- Mirror: open-source, maduro, comunidade grande.
- Photon: multiplayer como serviço, tira o peso de rodar servidor às custas de vendor lock-in.
No Godot, vale lembrar que ele tem networking de alto nível embutido (RPCs e MultiplayerSynchronizer), o que cobre bem cliente-servidor pra projetos menores sem dependência externa.
Pra debugar:
- Wireshark: pra ver os pacotes de verdade quando algo está estranho no fio.
- Clumsy: simular latência e perda de pacote no Windows.
- Ferramentas de profiling de rede da própria engine, que mostram quanto cada coisa está custando de banda.
O resumo honesto
Netcode perfeito não existe. O que existe é netcode bom o suficiente pro tipo de jogo que você está fazendo, e cada técnica aqui é uma resposta a um trade-off diferente:
- Quem é autoritativo? Cliente-servidor pra competitivo, P2P/lockstep pra sessões pequenas.
- Como esconder latência do meu jogador? Predição e reconciliação.
- Como mostrar os outros jogadores suaves? Interpolação com buffer.
- Como acertar o tiro? Compensação de lag, sabendo que ela favorece o atirador.
- Frames críticos (luta)? Rollback, com simulação determinística.
- Muitas unidades (RTS)? Lockstep determinístico, com ponto fixo (nada de float).
- Banda apertada? Delta, quantização e prioridade.
Comece simples: cliente-servidor autoritativo com predição e interpolação básicas. Coloque pra rodar com latência simulada antes de achar que está pronto. Itere com feedback de jogador real. O resto você adiciona quando o jogo pedir, não antes.
Para ir mais fundo
Dois materiais que valem a leitura: o artigo da Valve sobre Source Multiplayer Networking (a fonte clássica de predição, reconciliação e compensação de lag) e a documentação do GGPO sobre rollback. Eles explicam o "porquê" por trás de cada técnica daqui, e entender o porquê é o que te deixa resolver os problemas que nenhum tutorial previu.


