Voltar para o Blog
Quest Log

Multiplayer no Godot: Tutorial Completo de Networking Para Iniciantes

Diagrama de arquitetura cliente-servidor no Godot Engine

Aprenda a criar jogos multiplayer no Godot Engine. Tutorial passo a passo sobre networking, sincronização, High-Level Multiplayer API e implementação prática.

Multiplayer no Godot: Tutorial Completo de Networking Para Iniciantes

Vou ser direto com você: multiplayer é a parte do game dev onde a maioria dos projetos morre. Não porque a ideia é ruim, mas porque networking esconde uma porção de armadilhas que single-player nunca te mostra. Latência, sincronização, dois clientes vendo coisas diferentes na mesma tela, cheater enviando dano negativo. Eu já apanhei de todas elas.

A boa notícia é que o Godot resolve uma fatia grande disso pra você. A High-Level Multiplayer API te dá RPC, sincronização de propriedades e spawn replicado sem você precisar abrir socket na mão nem escrever protocolo binário. Você ainda vai precisar entender o que está acontecendo por baixo, mas o trabalho braçal sai do caminho.

Este tutorial vai do conceito até código que roda. A gente passa por arquitetura cliente-servidor, RPCs, autoridade de node, MultiplayerSynchronizer, interpolação e o básico de anti-cheat. O código é GDScript do Godot 4.x. No fim você não vai dominar multiplayer (ninguém domina isso lendo um post), mas vai ter dois clientes se enxergando na tela. E é desse primeiro pacote que o resto nasce.

Fundamentos de Networking em Jogos

Antes de código, três conceitos que você precisa carregar na cabeça o tempo todo.

Cliente-Servidor vs Peer-to-Peer

No modelo cliente-servidor, um peer manda no jogo. Pode ser um servidor dedicado ou um dos jogadores fazendo papel de host. Os clientes mandam input, o servidor processa e devolve o estado oficial. A vantagem é que o servidor é a fonte da verdade: se ele não acreditar no que o cliente mandou, o trapaceiro não consegue nada. A desvantagem é que dá mais trabalho.

No peer-to-peer, todo mundo conversa com todo mundo, sem dono. É mais simples de montar e serve pra jogo casual entre amigos, mas qualquer um pode mentir sobre o próprio estado. Sem alguém validando, P2P é um convite pra cheat.

O Godot roda os dois. Mas se você está fazendo algo que vai pra internet aberta, vai de cliente-servidor. É o padrão por um motivo.

Latência e Tick Rate

Latência (ping) é o tempo que um pacote leva pra ir e voltar. Abaixo de 50ms é ótimo, até uns 100ms a maioria dos jogos lida bem, e acima de 150ms você começa a sentir o atraso na pele. A latência nunca vai a zero, então o jogo precisa ser desenhado pra conviver com ela, não pra fingir que ela não existe.

Tick rate é a frequência com que o servidor atualiza o mundo. Pra jogo casual, 20 a 30 ticks por segundo resolve. Jogo competitivo de tiro empurra pra 64 ou mais. Quanto maior o tick rate, mais responsivo o jogo fica, e mais banda ele consome. É uma troca, não um número mágico.

A regra prática de banda: mande só o que mudou e só pra quem precisa saber. Multiplayer mal otimizado não trava por causa de gráfico, trava por causa de pacote demais.

Sincronização de Estado

O problema central do multiplayer é este: cada máquina tem a própria cópia do mundo, e elas tendem a divergir. Existem três jeitos de manter todas alinhadas, e o Godot te dá ferramenta pra cada um.

Você pode mandar o estado completo de tempos em tempos (simples, mas gasta banda), mandar só o input de cada jogador e deixar cada máquina simular o resultado (econômico, mas exige que a simulação seja determinística, o que é difícil), ou mandar snapshots do estado e deixar o cliente interpolar entre eles pra suavizar o movimento. Na prática você vai misturar essas abordagens. O MultiplayerSynchronizer do Godot cuida da sincronização de propriedades, e os RPCs cuidam de eventos pontuais.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Setup Básico: Criando Servidor e Cliente

Vamos começar com o mínimo que conecta dois peers.

Estrutura de Projeto

Multiplayer_Game/
├── scenes/
│   ├── Player.tscn
│   ├── World.tscn
│   └── UI.tscn
├── scripts/
│   ├── player.gd
│   ├── network.gd
│   └── world.gd
└── main.tscn

Network Manager (Singleton)

Crie scripts/network.gd e registre como Autoload em Project Settings > Autoload, com o nome Network. Assim qualquer script chama Network.create_server() de onde quiser.

extends Node

const PORT = 7777
const MAX_PLAYERS = 4

var peer = ENetMultiplayerPeer.new()

func create_server() -> void:
    var error = peer.create_server(PORT, MAX_PLAYERS)
    if error != OK:
        push_error("Falha ao criar servidor: %d" % error)
        return
    multiplayer.multiplayer_peer = peer
    print("Servidor criado na porta ", PORT)

    multiplayer.peer_connected.connect(_on_player_connected)
    multiplayer.peer_disconnected.connect(_on_player_disconnected)

func join_server(address: String) -> void:
    var error = peer.create_client(address, PORT)
    if error != OK:
        push_error("Falha ao iniciar cliente: %d" % error)
        return
    multiplayer.multiplayer_peer = peer
    print("Conectando a ", address)

    multiplayer.connected_to_server.connect(_on_connected_to_server)
    multiplayer.connection_failed.connect(_on_connection_failed)

func _on_player_connected(id: int) -> void:
    print("Player conectou: ", id)

func _on_player_disconnected(id: int) -> void:
    print("Player desconectou: ", id)

func _on_connected_to_server() -> void:
    print("Conectado ao servidor com sucesso!")

func _on_connection_failed() -> void:
    print("Falha ao conectar ao servidor")

Repare numa coisa que muito tutorial pula: create_server e create_client retornam um código de erro. Ignorar esse retorno é o jeito mais rápido de passar meia hora debugando uma porta ocupada sem perceber. Cheque sempre.

UI Para Criar ou Juntar

Uma tela com dois botões e um campo de IP já basta pra testar.

extends Control

@onready var host_button = $VBoxContainer/HostButton
@onready var join_button = $VBoxContainer/JoinButton
@onready var address_input = $VBoxContainer/AddressInput

func _ready() -> void:
    host_button.pressed.connect(_on_host_pressed)
    join_button.pressed.connect(_on_join_pressed)

func _on_host_pressed() -> void:
    Network.create_server()
    get_tree().change_scene_to_file("res://scenes/World.tscn")

func _on_join_pressed() -> void:
    var address = address_input.text if address_input.text != "" else "127.0.0.1"
    Network.join_server(address)
    get_tree().change_scene_to_file("res://scenes/World.tscn")

Pra testar na mesma máquina, o Godot tem um atalho ótimo: em Debug > Run Multiple Instances, escolha duas. Roda o jogo, numa janela clica em Host, na outra digita 127.0.0.1 e clica em Join. Se as duas seguem pro World sem erro no console, seu networking básico está de pé.

Sincronizando Players

Conectar é o fácil. Agora vem o ponto onde a maioria empaca: fazer cada jogador controlar só o próprio boneco e ver os outros se mexerem.

Player Scene

Crie Player.tscn com esta estrutura:

Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── Camera2D

Autoridade de Multiplayer

A ideia de autoridade é o coração do multiplayer no Godot. Cada node tem um "dono", e só o dono processa input pra aquele node. Os outros peers apenas recebem o resultado. Sem isso, todo mundo tentaria controlar todo mundo.

extends CharacterBody2D

const SPEED = 300.0

@export var player_id: int = 1

func _enter_tree() -> void:
    # Define a autoridade antes do node entrar em cena.
    # Só o peer com este id vai processar input deste player.
    set_multiplayer_authority(player_id)

func _physics_process(delta: float) -> void:
    if not is_multiplayer_authority():
        return  # Não sou o dono: não processo input aqui.

    var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    velocity = direction * SPEED
    move_and_slide()

    # Versão "na mão": mando minha posição pros outros peers.
    rpc("sync_position", position)

@rpc("authority", "unreliable")
func sync_position(pos: Vector2) -> void:
    if not is_multiplayer_authority():
        position = pos

Esse sync_position na mão funciona e é didático, mas é a versão crua. Daqui a pouco a gente troca ele pelo MultiplayerSynchronizer, que faz o mesmo trabalho sem você escrever RPC. Deixei a versão manual aqui de propósito pra você ver o que o synchronizer está fazendo por baixo.

Spawnando Players no Servidor

No World.gd, o servidor cria um player a cada conexão.

extends Node2D

const PLAYER_SCENE = preload("res://scenes/Player.tscn")

@onready var players_node = $Players

func _ready() -> void:
    if multiplayer.is_server():
        multiplayer.peer_connected.connect(_on_player_connected)
        multiplayer.peer_disconnected.connect(_on_player_disconnected)
        spawn_player(1)  # O próprio host também é um player.

func _on_player_connected(id: int) -> void:
    spawn_player(id)

func _on_player_disconnected(id: int) -> void:
    if players_node.has_node(str(id)):
        players_node.get_node(str(id)).queue_free()

func spawn_player(id: int) -> void:
    var player = PLAYER_SCENE.instantiate()
    player.player_id = id
    player.name = str(id)
    players_node.add_child(player, true)  # force_readable_name = true

Aí você roda e bate na parede: o servidor vê os dois players, mas o cliente só vê o dele. Isso porque criar um node no servidor não o cria magicamente no cliente. Você precisa replicar o spawn.

Spawn Replicado com MultiplayerSpawner

O MultiplayerSpawner resolve isso. Ele observa um node e, sempre que um filho aparece nele no servidor, replica esse filho em todos os clientes automaticamente.

No World.tscn, adicione um MultiplayerSpawner. No Inspector, configure o Spawn Path apontando pro node Players, e na Auto Spawn List adicione Player.tscn. Pronto: a partir daqui, todo filho que entrar no node Players no servidor nasce em todos os clientes.

O World.gd praticamente não muda. A diferença é que agora você confia no spawner pra propagar.

extends Node2D

const PLAYER_SCENE = preload("res://scenes/Player.tscn")

@onready var players_node = $Players

func _ready() -> void:
    if multiplayer.is_server():
        multiplayer.peer_connected.connect(_on_player_connected)
        multiplayer.peer_disconnected.connect(_on_player_disconnected)
        spawn_player(1)

func _on_player_connected(id: int) -> void:
    spawn_player(id)

func _on_player_disconnected(id: int) -> void:
    if players_node.has_node(str(id)):
        players_node.get_node(str(id)).queue_free()

func spawn_player(id: int) -> void:
    var player = PLAYER_SCENE.instantiate()
    player.player_id = id
    player.name = str(id)
    # O MultiplayerSpawner observa players_node e replica este filho
    # em todos os clientes sozinho.
    players_node.add_child(player, true)

Agora todo player aparece pra todo mundo. Um detalhe que economiza dor de cabeça: deixe o spawn sob responsabilidade exclusiva do servidor. Se o cliente também tentar instanciar player, você vai acabar com bonecos duplicados.

RPCs: Remote Procedure Calls

RPC é você chamar uma função que executa em outro peer. É o mecanismo pra eventos pontuais: um tiro, uma mensagem, um jogador morrendo. Pra movimento contínuo, prefira o synchronizer; pra evento discreto, RPC é a ferramenta certa.

Os Modificadores de RPC

A anotação @rpc aceita modificadores que mudam quem pode chamar e como o pacote viaja:

  • "any_peer": qualquer peer pode disparar essa função. Conveniente e perigoso. Use só quando o servidor for validar o que chega.
  • "authority": só o dono do node pode disparar. É o default e o mais seguro.
  • "call_local": além de rodar nos outros peers, roda também em quem chamou.
  • "reliable": o Godot garante a entrega e a ordem. Use pra coisa que não pode se perder.
  • "unreliable": pode perder pacote, mas é mais leve. Use pra dado que chega de novo logo, tipo posição.

A regra de ouro: posição e dado que se repete vão de unreliable. Evento crítico (morte, dano, item coletado) vai de reliable.

Exemplo: Sistema de Chat

Chat é um caso clássico onde o servidor precisa ser o intermediário. O cliente manda a mensagem pro servidor, o servidor valida e só então retransmite pra todo mundo. Cliente nunca fala direto com cliente.

extends Control

@onready var chat_log = $VBoxContainer/ChatLog
@onready var input_field = $VBoxContainer/HBoxContainer/InputField
@onready var send_button = $VBoxContainer/HBoxContainer/SendButton

func _ready() -> void:
    send_button.pressed.connect(_on_send_pressed)

func _on_send_pressed() -> void:
    var message = input_field.text
    if message.strip_edges() == "":
        return
    # Mando só pro servidor (peer id 1).
    rpc_id(1, "receive_message", message)
    input_field.clear()

@rpc("any_peer", "reliable")
func receive_message(message: String) -> void:
    # Só o servidor processa. O sender vem do próprio Godot,
    # não de um parâmetro que o cliente poderia falsificar.
    if not multiplayer.is_server():
        return
    var sender_id = multiplayer.get_remote_sender_id()
    var clean = validate_message(message)
    rpc("display_message", sender_id, clean)  # Broadcast pra todos.

@rpc("authority", "call_local", "reliable")
func display_message(sender_id: int, message: String) -> void:
    chat_log.text += "\n[%d]: %s" % [sender_id, message]

func validate_message(message: String) -> String:
    # Trava o tamanho. Aqui também entraria filtro de palavrão, anti-spam, etc.
    return message.substr(0, 200)

Tem um detalhe de segurança importante aí. O id de quem mandou a mensagem não vem como parâmetro: ele vem de multiplayer.get_remote_sender_id(), que o próprio Godot preenche. Se você confiar num sender_id enviado pelo cliente, qualquer um pode se passar por outro jogador. Nunca confie em identidade que o cliente afirma sobre si mesmo.

MultiplayerSynchronizer: Sincronização Automática

Lembra do sync_position na mão lá em cima? O MultiplayerSynchronizer faz isso por você, pra qualquer propriedade que você listar.

Setup

Em Player.tscn, adicione um MultiplayerSynchronizer como filho do Player:

Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── Camera2D
└── MultiplayerSynchronizer

No Inspector do synchronizer, aponte o Root Path pro Player (o .) e crie um novo SceneReplicationConfig. Dentro dele, adicione as propriedades que devem viajar pela rede: position, velocity e, se o boneco gira, rotation. Você ainda controla a frequência pelo Replication Interval: 0.1 manda dez atualizações por segundo, que costuma ser suficiente pra movimento.

O Script Fica Limpo

Com o synchronizer cuidando da rede, o script do player volta a parecer single-player. Some todo o RPC manual.

extends CharacterBody2D

const SPEED = 300.0

@export var player_id: int = 1

func _enter_tree() -> void:
    set_multiplayer_authority(player_id)

func _physics_process(delta: float) -> void:
    if not is_multiplayer_authority():
        return

    var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    velocity = direction * SPEED
    move_and_slide()
    # position e velocity são replicados pelo MultiplayerSynchronizer.
    # Zero código de rede aqui.

A vantagem é óbvia: o boneco se move como sempre, e a rede some do seu caminho. Você decide o que viaja marcando propriedades num arquivo de config, não escrevendo RPC.

Interpolação Para Movimento Suave

Replicar dez vezes por segundo deixa o movimento dos outros jogadores aos pulinhos. A tela atualiza a 60fps, mas a posição só chega 10 vezes. O olho percebe o degrau.

A solução é interpolar: em vez de teletransportar o boneco pra cada posição recebida, você desliza suavemente em direção a ela. Quem é dono do node continua se movendo normal; quem é cópia remota persegue o alvo aos poucos.

extends CharacterBody2D

const SPEED = 300.0
const INTERP_SPEED = 12.0

@export var player_id: int = 1

# Esta propriedade é a que o MultiplayerSynchronizer replica.
# Marque target_position no SceneReplicationConfig no lugar de position.
@export var target_position: Vector2

func _enter_tree() -> void:
    set_multiplayer_authority(player_id)

func _physics_process(delta: float) -> void:
    if is_multiplayer_authority():
        var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
        velocity = direction * SPEED
        move_and_slide()
        target_position = position  # O dono publica a posição autoritativa.
    else:
        # Cópia remota: desliza em direção ao último alvo recebido.
        position = position.lerp(target_position, INTERP_SPEED * delta)

O truque aqui é não replicar position diretamente. Você replica target_position (a posição "oficial" que o dono publica) e deixa cada cópia remota fazer o lerp em direção a ela todo frame. O resultado é movimento contínuo mesmo recebendo poucas atualizações. Ajuste INTERP_SPEED no olho: muito baixo deixa o boneco "flutuando" atrás, muito alto traz de volta o pulinho.

Lidando com Latência: Prediction e Reconciliation

Pra jogo casual, o que vimos até aqui basta. Mas em jogo competitivo, esperar o servidor confirmar cada passo deixa o controle pastoso: você aperta a direita e o boneco anda um instante depois. A técnica pra resolver isso se chama client-side prediction com server reconciliation.

Vou ser honesto: essa é uma das partes mais difíceis de multiplayer, e enrolar com pseudocódigo aqui só ia te enganar. Então vou explicar a ideia direito e mostrar só o esqueleto correto.

A lógica é a seguinte. O cliente prevê o próprio movimento na hora, sem esperar o servidor, e ao mesmo tempo guarda cada input que aplicou, com um número de sequência. O servidor processa esses inputs na ordem e, de tempos em tempos, devolve a posição oficial junto com o número do último input que ele processou. O cliente então compara: se a posição oficial bate com onde ele previu, ótimo, nada a fazer. Se divergiu (porque o servidor recusou um movimento ou houve colisão que o cliente não viu), o cliente volta pra posição oficial e reaplica todos os inputs que ainda não foram confirmados, recuperando a posição atual de forma consistente.

O esqueleto, sem fingir que resolve tudo:

extends CharacterBody2D

const SPEED = 300.0

var input_sequence := 0
var pending_inputs: Array[Dictionary] = []  # Inputs ainda não confirmados.

func _physics_process(delta: float) -> void:
    if not is_multiplayer_authority():
        return

    var direction = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    input_sequence += 1
    var input := {"seq": input_sequence, "dir": direction}

    # 1. Prediz local: aplica o input imediatamente.
    _apply_input(direction, delta)

    # 2. Guarda pra reconciliar depois.
    pending_inputs.append(input)

    # 3. Manda pro servidor processar.
    rpc_id(1, "server_process_input", input, delta)

func _apply_input(direction: Vector2, delta: float) -> void:
    velocity = direction * SPEED
    move_and_slide()

@rpc("any_peer", "reliable")
func server_process_input(input: Dictionary, delta: float) -> void:
    if not multiplayer.is_server():
        return
    # O servidor simula com os mesmos números e guarda o estado oficial.
    _apply_input(input["dir"], delta)
    # Devolve a posição autoritativa e o último seq processado.
    var sender := multiplayer.get_remote_sender_id()
    rpc_id(sender, "reconcile", input["seq"], position)

@rpc("authority", "reliable")
func reconcile(acked_seq: int, server_position: Vector2) -> void:
    # Descarta o que o servidor já confirmou.
    pending_inputs = pending_inputs.filter(func(i): return i["seq"] > acked_seq)

    # Volta pro estado oficial e reaplica o que ainda está pendente.
    position = server_position
    for pending in pending_inputs:
        _apply_input(pending["dir"], get_physics_process_delta_time())

Antes de copiar isso pro seu jogo, saiba o que ele ainda não cobre: o delta ideal deveria ser fixo (timestep travado) pra cliente e servidor simularem igual, e mandar um RPC por frame de input é caro, então na prática você agrupa inputs. Prediction de verdade é um projeto à parte. Meu conselho: só vá por esse caminho se o seu jogo realmente exige resposta instantânea (tiro, luta, plataforma rápida). Pra a maioria dos jogos, interpolação simples entrega uma experiência boa com uma fração da complexidade. Não pague esse preço sem precisar.

::blog-cta{title="Domine Todas as Áreas de Game Development" description="Multiplayer networking é apenas uma das muitas skills necessárias. Combinado com arte, design, áudio e marketing, você pode criar experiências completas e bem-sucedidas. Aprenda todas as disciplinas ou a colaborar efetivamente." buttonText="Candidate-se Agora" icon="fas fa-network-wired" variant="highlight"}::

Segurança: Anti-Cheat Básico

A frase que você precisa tatuar: o cliente sempre mente. Trate todo input que chega da rede como hostil até o servidor validar. A maioria dos cheats de multiplayer existe porque o desenvolvedor confiou no cliente em algum ponto crítico.

Server Authority na Prática

Veja a diferença entre o jeito ingênuo e o jeito certo de aplicar dano.

# ERRADO: o cliente decide quanto dano levou.
# Um cheater manda amount = 0 e fica imortal.
@rpc("any_peer")
func take_damage(amount: int) -> void:
    health -= amount

# CERTO: o cliente só PEDE pra atacar. O servidor decide tudo.
@rpc("any_peer", "reliable")
func request_attack(target_id: int) -> void:
    if not multiplayer.is_server():
        return
    var attacker_id = multiplayer.get_remote_sender_id()
    if not _is_valid_attack(attacker_id, target_id):
        return  # Fora de alcance, em cooldown, etc. Ignora.
    var damage = _calculate_damage(attacker_id)
    _apply_damage(target_id, damage)
    rpc("sync_health", target_id, health)

@rpc("authority", "reliable")
func sync_health(target_id: int, new_health: int) -> void:
    health = new_health

A diferença conceitual: no jeito errado, o cliente afirma o resultado. No jeito certo, o cliente faz um pedido e o servidor é quem decide se o pedido é válido e qual o resultado. Toda lógica que importa (vida, dinheiro, posição, item) mora no servidor.

Onde Validar

Pra movimento, deixe o cliente prever, mas o servidor checa se a velocidade é plausível e recusa teletransporte impossível. Pra combate, o servidor confere alcance, linha de visão e cooldown antes de aplicar dano. Pra inventário e recursos, o servidor guarda o estado oficial e o cliente só desenha o que recebe. O padrão é sempre o mesmo: o cliente sugere, o servidor confirma.

Debugging Multiplayer

Bug de multiplayer é traiçoeiro porque depende de timing e de qual peer está rodando o quê. Algumas ferramentas ajudam muito.

A primeira é rodar várias instâncias de uma vez (Debug > Run Multiple Instances) e usar o debugger remoto do Godot, que conecta em todas. A segunda, mais simples e que eu uso o tempo todo, é um print que diz de quem é a fala.

func debug_log(message: String) -> void:
    var role = "SERVER" if multiplayer.is_server() else "CLIENT"
    var id = multiplayer.get_unique_id()
    print("[%s %d] %s" % [role, id, message])

Sem esse prefixo de papel e id, você vai olhar pra um log misturado de servidor e cliente sem saber quem disse o quê. Com ele, fica óbvio onde a lógica divergiu.

Testando com Latência Real

Localhost mente. No 127.0.0.1 o ping é praticamente zero, então tudo parece perfeito, e aí você publica e descobre que o jogo desmonta com 80ms de latência. O Godot ajuda a simular isso: o ENetMultiplayerPeer tem set_packet_loss e configurações de latência que você pode ligar em desenvolvimento pra ver como o jogo se comporta sob estresse. Teste com latência antes de chamar de pronto. Sempre.

Otimização de Bandwidth

Multiplayer come banda, e banda é o recurso que mais limita quantos jogadores cabem por servidor. Três técnicas que dão o maior retorno.

Mande só o que mudou. Se a posição não mexeu, não precisa reenviar. O MultiplayerSynchronizer já tem boa parte dessa lógica, mas se você sincroniza na mão, cheque antes.

var last_sent_position: Vector2

func _physics_process(delta: float) -> void:
    if position.distance_to(last_sent_position) > 5.0:
        rpc("sync_position", position)
        last_sent_position = position

Escolha o canal certo. Posição vai de unreliable, porque se um pacote se perder o próximo já corrige. Evento que não pode sumir vai de reliable. Mandar tudo como reliable por preguiça enche a fila de retransmissão e aumenta a latência efetiva.

@rpc("authority", "unreliable")
func sync_position(pos: Vector2) -> void:
    position = pos

@rpc("authority", "reliable")
func player_died() -> void:
    health = 0

Reduza o tick rate de rede. O jogo roda a 60fps, mas a rede não precisa acompanhar. Vinte atualizações por segundo costumam ser suficientes pra movimento, e isso é três vezes menos pacote.

const NET_TICK_RATE := 20
var _time_since_tick := 0.0

func _physics_process(delta: float) -> void:
    _time_since_tick += delta
    if _time_since_tick >= 1.0 / NET_TICK_RATE:
        _sync_state()
        _time_since_tick = 0.0

Otimize banda antes de ela virar problema, mas não antes de o jogo funcionar. Faça rodar, meça, e corte o que está pesando de fato. Otimização no escuro é tempo jogado fora.

Por Onde Começar de Verdade

Multiplayer é uma das áreas mais densas do game dev, e não tem como aprender de uma vez. O caminho que funciona é construir em camadas, cada uma rodando antes de empilhar a próxima.

Comece pelo chat: ele te ensina RPC e o fluxo cliente-servidor sem a pressão do tempo real. Depois faça dois bonecos se moverem e se enxergarem, usando autoridade e o MultiplayerSynchronizer. Em seguida adicione interpolação pra suavizar o movimento dos outros jogadores. Só então, e só se o seu jogo precisar, encare prediction. Quando movimento e sincronização estiverem firmes, aí entra o gameplay de fato: combate, objetivos, regras, sempre com o servidor mandando.

Pra ir mais fundo, a documentação oficial de multiplayer do Godot é o material mais confiável e atualizado que existe sobre a High-Level API. Leia ela de par com este post.

Uma última coisa, de quem já passou por isso: seu primeiro jogo multiplayer vai ter lag, vai ter bug e vai ter decisão de design que você vai querer apagar depois. Tudo bem. É assim pra todo mundo. Os jogos online que você admira foram construídos por times que erraram um monte antes de acertar. Então não espera entender tudo pra começar. Conecta dois clientes hoje, faz eles se verem na tela, e parte daí. O primeiro pacote enviado é o passo que destrava todos os outros.