Lobby Multiplayer no Godot 4: Sala de Espera com ENet e RPC

Lobby multiplayer no Godot 4: sala de espera com ENetMultiplayerPeer, lista de jogadores sincronizada, ready check com RPC e troca de cena para a partida.
Quase todo jogo em rede tem uma tela antes da ação: a sala de espera onde os jogadores se juntam, veem quem chegou e marcam que estão prontos. Montar um lobby multiplayer no Godot 4 é o passo natural depois de aprender a conectar duas máquinas, e é também onde a maioria dos projetos multiplayer tropeça, porque envolve sincronizar estado entre peers antes mesmo do jogo começar. Neste tutorial a gente constrói um lobby completo: host cria a sala, clientes entram por IP, a lista de jogadores fica sincronizada em todas as telas, cada um marca "pronto" e, quando todos estiverem, a sala trava e todo mundo troca de cena para a partida junto.
Este post assume que você já viu o básico de rede na engine. Se @rpc, autoridade e ENetMultiplayerPeer ainda são novidade, leia antes o post sobre RPC no multiplayer do Godot, porque aqui a gente usa tudo isso sem parar para explicar do zero.
Por que separar o lobby da partida
Dá para pular direto para o gameplay quando alguém conecta? Dá. Mas você cria três problemas de uma vez.
Primeiro, jogadores entram no meio do estado do jogo. Se o cliente 2 conecta quando a partida já anda, você precisa mandar para ele tudo que já aconteceu, e isso é bem mais difícil do que mandar uma lista de nomes. Segundo, você não tem um ponto de sincronização: cada máquina começa a simular em um momento diferente e as posições divergem desde o frame um. Terceiro, você não tem onde colocar as decisões de antes do jogo, como escolha de personagem, mapa ou time.
O lobby resolve os três. Ele é uma cena leve, sem física nem gameplay, onde a única coisa sincronizada é um dicionário de jogadores. Quando todos estão prontos, a sala trava (ninguém mais entra) e todas as máquinas trocam de cena no mesmo instante, começando a partida do mesmo estado inicial. Essa separação entre "juntar gente" e "simular jogo" é o padrão em praticamente todo jogo online, do indie ao AAA.
A estrutura que a gente vai montar é um autoload chamado Lobby, registrado em Project Settings, seção Autoload. Ele precisa sobreviver à troca de cena, senão a conexão e a lista de jogadores morrem junto com a tela de lobby.
Lobby multiplayer no Godot: host cria a sala, cliente entra por IP
O modelo é host e cliente. Um jogador abre a sala: a máquina dele vira o servidor e ele mesmo é o primeiro jogador. Os outros conectam pelo IP dele. Começamos pelo estado do autoload:
extends Node
const PORTA: int = 8910
const MAX_JOGADORES: int = 4
var jogadores: Dictionary[int, String] = {}
var prontos: Dictionary[int, bool] = {}
var meu_nome: String = ""
var sala_travada: bool = false
signal lista_atualizada
Dois dicionários tipados: jogadores mapeia o id do peer para o nome, prontos mapeia o id para o estado de pronto. O sinal lista_atualizada é o que a interface vai escutar para redesenhar a tela.
Criar a sala é abrir um servidor ENet. O host se registra direto nos dicionários, sem RPC, porque ele já está na própria máquina:
func criar_sala(nome: String) -> void:
var peer := ENetMultiplayerPeer.new()
var erro: int = peer.create_server(PORTA, MAX_JOGADORES)
if erro != OK:
push_error("Nao abriu a porta %d. Ela pode estar em uso." % PORTA)
return
multiplayer.multiplayer_peer = peer
meu_nome = nome
jogadores[1] = nome
prontos[1] = false
lista_atualizada.emit()
O host é sempre o peer de id 1. Entrar na sala é o espelho: o cliente precisa do IP do host (na rede local, algo como 192.168.0.10) e da mesma porta:
func entrar_na_sala(ip: String, nome: String) -> void:
var peer := ENetMultiplayerPeer.new()
var erro: int = peer.create_client(ip, PORTA)
if erro != OK:
push_error("Nao consegui iniciar conexao com %s" % ip)
return
multiplayer.multiplayer_peer = peer
meu_nome = nome
Repare que o cliente ainda não se adiciona em jogadores. Ele nem sabe se a conexão vai dar certo. Quem decide quem está na sala é o servidor, e é isso que a gente resolve agora.
Lista de jogadores sincronizada com peer_connected
O objeto multiplayer emite sinais quando peers entram e saem. Conectamos tudo no _ready() do autoload:
func _ready() -> void:
multiplayer.peer_connected.connect(_ao_entrar_peer)
multiplayer.peer_disconnected.connect(_ao_sair_peer)
multiplayer.connected_to_server.connect(_ao_conectar_no_servidor)
multiplayer.connection_failed.connect(_ao_falhar_conexao)
O fluxo de registro tem três passos. Um: quando o cliente confirma que entrou no servidor, ele manda o próprio nome para o peer 1 via RPC. Dois: o servidor grava o novo jogador nos dicionários. Três: o servidor reenvia a lista inteira para todo mundo.
func _ao_conectar_no_servidor() -> void:
_registrar_jogador.rpc_id(1, meu_nome)
func _ao_falhar_conexao() -> void:
multiplayer.multiplayer_peer = null
push_error("Conexao recusada ou host fora do ar.")
@rpc("any_peer", "reliable")
func _registrar_jogador(nome: String) -> void:
if not multiplayer.is_server():
return
var id: int = multiplayer.get_remote_sender_id()
jogadores[id] = nome
prontos[id] = false
_atualizar_lista.rpc(jogadores, prontos)
A RPC de registro é "any_peer" porque qualquer cliente pode chamar, mas o corpo dela só executa de verdade no servidor: o if not multiplayer.is_server() descarta a chamada em qualquer outra máquina. multiplayer.get_remote_sender_id() diz qual peer mandou a mensagem, então o cliente não consegue se registrar com o id de outro jogador.
A redistribuição da lista é a RPC de autoridade, com call_local para o host também atualizar a própria tela:
@rpc("authority", "call_local", "reliable")
func _atualizar_lista(lista: Dictionary[int, String], estados: Dictionary[int, bool]) -> void:
jogadores = lista
prontos = estados
lista_atualizada.emit()
Mandar o dicionário inteiro a cada mudança parece desperdício, mas num lobby de 4 a 16 jogadores estamos falando de dezenas de bytes. O ganho é enorme: nenhuma máquina acumula estado incremental, então não existe o bug clássico de "a lista do cliente 3 está diferente da do host". A cada mudança, todo mundo recebe a verdade completa do servidor.
A saída de um jogador segue a mesma lógica. O sinal peer_disconnected dispara no servidor (e nos clientes), mas só o servidor mexe na lista e redistribui:
func _ao_sair_peer(id: int) -> void:
if not multiplayer.is_server():
return
jogadores.erase(id)
prontos.erase(id)
_atualizar_lista.rpc(jogadores, prontos)
E a interface? Ela só precisa escutar o sinal e redesenhar. Um script de tela de lobby com um VBoxContainer chamado ListaUI fica assim:
extends Control
@onready var lista_ui: VBoxContainer = $ListaUI
func _ready() -> void:
Lobby.lista_atualizada.connect(_redesenhar)
_redesenhar()
func _redesenhar() -> void:
for filho: Node in lista_ui.get_children():
filho.queue_free()
for id: int in Lobby.jogadores:
var linha := Label.new()
var estado: String = "pronto" if Lobby.prontos.get(id, false) else "esperando"
linha.text = "%s (%s)" % [Lobby.jogadores[id], estado]
lista_ui.add_child(linha)
Destruir e recriar labels a cada atualização seria pecado num inventário com 500 itens, mas para meia dúzia de nomes num lobby é a solução mais simples e não dá para medir a diferença.
Sistema de pronto com RPC
O ready check usa o mesmo padrão do registro: cliente pede, servidor decide, servidor redistribui. A única pegadinha é o host, que não pode mandar RPC para si mesmo pelo caminho normal, então ele aplica direto:
func alternar_pronto() -> void:
var meu_id: int = multiplayer.get_unique_id()
var novo_valor: bool = not prontos.get(meu_id, false)
if multiplayer.is_server():
_aplicar_pronto(1, novo_valor)
else:
_pedir_pronto.rpc_id(1, novo_valor)
@rpc("any_peer", "reliable")
func _pedir_pronto(valor: bool) -> void:
if not multiplayer.is_server():
return
_aplicar_pronto(multiplayer.get_remote_sender_id(), valor)
func _aplicar_pronto(id: int, valor: bool) -> void:
prontos[id] = valor
_atualizar_lista.rpc(jogadores, prontos)
_checar_todos_prontos()
Na interface, isso vira um botão que chama Lobby.alternar_pronto(). Como o servidor redistribui a lista logo depois, o sinal lista_atualizada dispara em todas as máquinas e todas as telas mostram o novo estado. O jogador nunca atualiza a própria tela por conta própria: ele pede, e a tela muda quando a resposta oficial do servidor chega. Esse hábito de nunca confiar no estado local é o que separa código de rede que funciona de código de rede que "funciona no meu teste com duas janelas".
Note também que _aplicar_pronto() não tem anotador @rpc. É uma função interna comum, que só o servidor chama. Separar "a porta de entrada da rede" (a RPC, que valida) da "lógica de verdade" (a função interna) deixa o código mais fácil de auditar: você sabe exatamente quais funções um cliente malicioso consegue invocar.
Travar a sala e começar a partida
Quando todos os valores de prontos são true, o servidor faz duas coisas: fecha a porta para novos jogadores e manda todo mundo trocar de cena.
func _checar_todos_prontos() -> void:
if not multiplayer.is_server() or sala_travada:
return
if jogadores.size() < 2:
return
for id: int in prontos:
if not prontos[id]:
return
sala_travada = true
multiplayer.multiplayer_peer.refuse_new_connections = true
_comecar_partida.rpc()
@rpc("authority", "call_local", "reliable")
func _comecar_partida() -> void:
get_tree().change_scene_to_file("res://cenas/partida.tscn")
Três detalhes importantes aqui. O refuse_new_connections = true é a trava de verdade: a partir dela o ENet recusa qualquer conexão nova, então ninguém entra no meio da partida. O jogadores.size() < 2 evita a sala de um jogador só que se marca pronto e "começa" sozinho; ajuste o mínimo para o seu jogo. E a RPC _comecar_partida é "authority" com call_local: só o servidor pode dispará-la, e ela executa também no host, então todas as máquinas chamam get_tree().change_scene_to_file() praticamente no mesmo instante.
Como o Lobby é autoload, ele sobrevive à troca de cena com a conexão e os dicionários intactos. A cena da partida nasce já sabendo quem está jogando: basta ler Lobby.jogadores no _ready() dela e instanciar um personagem por id, dando a cada um a autoridade do respectivo peer.
Limitações honestas: isso é um lobby de rede local
Antes de você anunciar multiplayer online na página do seu jogo, precisamos falar do que esse lobby não faz.
Ele conecta por IP direto. Na mesma rede local (a sua casa, um escritório, uma LAN party), funciona sem configurar nada. Pela internet aberta, o roteador do host bloqueia conexões que chegam de fora, porque o IP do host é privado atrás de NAT. As saídas são: o host configurar port forwarding no roteador (inviável de pedir para jogador comum), você alugar um servidor dedicado com IP público que hospeda as salas, ou usar um serviço externo que faz NAT punchthrough e encontra partidas para o jogador. Esse mundo de salas públicas, filas e pareamento por habilidade é outro assunto, e já expliquei em detalhe como funciona matchmaking em jogos online.
Também não há aqui autenticação nem proteção contra trapaça: qualquer pessoa com o IP e a porta entra na sala e diz que se chama o que quiser. Para um jogo entre amigos, irrelevante. Para um jogo comercial, é trabalho de backend que custa dinheiro de verdade, e vale ler sobre o custo de servidor para jogo online antes de prometer crossplay ranqueado no Kickstarter.
Nada disso diminui o que você construiu. O padrão que esse lobby ensina, cliente pede, servidor valida e redistribui, todo mundo reage ao estado oficial, é exatamente o mesmo que você vai usar com servidor dedicado e matchmaking na frente. A infraestrutura muda; a arquitetura fica.
Resumo do fluxo
Vale gravar a sequência completa, porque ela se repete em todo jogo online que você fizer. O host chama create_server() e se registra como peer 1. O cliente chama create_client() com o IP e, ao receber connected_to_server, manda o nome via rpc_id(1). O servidor grava o jogador no dicionário e devolve a lista completa para todos com uma RPC de autoridade. O botão de pronto repete o ciclo: pedido do cliente, decisão do servidor, redistribuição. Quando todos estão prontos, o servidor trava a sala com refuse_new_connections e dispara a RPC que troca de cena em todas as máquinas.
Teste com duas ou três instâncias do jogo na mesma máquina (127.0.0.1 como IP) antes de testar em máquinas separadas. Derrube um cliente no meio, feche o host com gente na sala, marque pronto e desmarque rápido. Lobby bom é o que sobrevive a jogador fazendo besteira, e é bem mais barato descobrir isso agora do que depois da partida existir.
Perguntas frequentes
Como fazer lobby multiplayer no Godot?
Você cria um ENetMultiplayerPeer, chama create_server() no host e create_client() em quem entra, e guarda os jogadores num dicionário indexado pelo id de cada peer. Os sinais multiplayer.peer_connected e peer_disconnected avisam quando alguém entra ou sai, e RPCs sincronizam a lista e o estado de pronto entre todas as máquinas.
Como sincronizar a lista de jogadores no lobby?
A forma mais simples é o servidor ser o dono da lista. Cada cliente que conecta manda seu nome para o servidor por RPC, o servidor atualiza o dicionário de jogadores e reenvia o dicionário inteiro para todo mundo com uma RPC de autoridade. Como um lobby tem poucos jogadores, mandar a lista completa a cada mudança é barato e elimina bugs de estado divergente.
Como fazer sistema de pronto no lobby?
Cada jogador tem um bool num dicionário de prontos. Quando o jogador aperta o botão, o cliente manda uma RPC para o servidor com o novo valor, o servidor grava e redistribui o estado para todos. Quando todos os valores do dicionário são true, o servidor trava a sala e dispara a RPC que troca de cena em todas as máquinas ao mesmo tempo.
O lobby do Godot funciona pela internet?
O ENet funciona por IP e porta, então tecnicamente sim, mas na prática o roteador do host bloqueia conexões vindas de fora. Para jogar pela internet aberta você precisa de port forwarding no roteador do host, de um servidor dedicado com IP público ou de um serviço de matchmaking com NAT punchthrough. Para rede local e testes, funciona direto sem configuração.
Quantos jogadores o ENet aguenta?
Muito mais do que um lobby de jogo indie precisa. O limite prático não é o ENet, é a sua lógica de jogo: quanto estado você sincroniza por segundo e quanto processamento o host gasta por jogador. Para salas pequenas, de 2 a 16 jogadores, que é o cenário típico de um jogo cooperativo ou versus indie, o ENet dá conta com folga. Você define o teto no segundo argumento de create_server().


