Chat Multiplayer na Godot 4: Texto em Tempo Real com RPC

Chat multiplayer na Godot 4 na prática: UI com RichTextLabel, RPC confiável, validação no servidor, rate limit e escape de BBCode em GDScript tipado.
Fazer um chat multiplayer na Godot é o melhor segundo passo depois de conectar dois jogadores pela primeira vez. É pequeno o bastante para caber numa tarde, mas te obriga a encostar em tudo que importa em jogo online: RPC confiável, identificação de quem mandou o quê, validação no servidor e até segurança de UI. Neste tutorial a gente monta um chat de texto funcional na Godot 4, com GDScript tipado e a API real da engine: ENetMultiplayerPeer, o anotador @rpc e o objeto multiplayer. No fim você terá um chat que aguenta jogador digitando bobagem, spammando Enter e tentando injetar BBCode na tela dos outros.
Se você ainda não conectou duas instâncias do jogo, vale passar antes pelo básico de RPC e rede high-level na Godot 4, porque aqui eu assumo que a conexão já existe e vou direto ao chat.
Por que um chat multiplayer parece simples e não é
Um chat local é trivial: pega o texto do campo, joga no histórico, pronto. Em rede, cada decisão ingênua vira um problema:
- Quem disse isso? O pacote chega no servidor, mas você não pode confiar no nome que o cliente escreveu no próprio pacote. Um cliente modificado se passaria por qualquer um.
- E se o jogador spammar? Segurar Enter com um peso em cima do teclado enche a tela de todo mundo e o tráfego da partida.
- E se a mensagem tiver 50 mil caracteres? Você acabou de deixar qualquer cliente travar a renderização dos outros.
- E se o texto tiver BBCode?
RichTextLabelinterpreta tags. Um jogador que digita[color=red]pinta o chat inteiro dos outros, e[img]abre portas piores.
A regra que resolve tudo isso é uma só, e é a mesma de qualquer jogo online: o cliente pede, o servidor decide. A mensagem nunca vai direto de um cliente para os outros. Ela vai do cliente para o servidor, o servidor valida, e só então o servidor distribui. Guarde esse desenho, o resto do post é só implementação dele.
A cena de UI do chat
A estrutura de nodes é enxuta. Crie uma cena ChatUI assim:
ChatUI (Control)
└── PanelContainer
└── VBoxContainer
├── Scroll (ScrollContainer)
│ └── Historico (RichTextLabel)
└── CampoTexto (LineEdit)
Três ajustes no inspector fazem diferença:
- No
Scroll, marque Vertical emSize FlagscomoExpand + Fill, para o histórico ocupar o espaço e oLineEditficar colado embaixo. - No
Historico, ativebbcode_enabled(vamos usar negrito e cor nas mensagens de sistema) efit_content, para o label crescer com o texto e oScrollContainercuidar da rolagem. - No
CampoTexto, definamax_lengthcomo200. Isso melhora a experiência de quem digita, mas atenção: é só cosmético. Um cliente alterado ignora esse limite, então o servidor vai checar de novo.
Marque Historico, CampoTexto e Scroll como Unique Name (clique direito no node, "Access as Unique Name") para o script ficar limpo com %Nome.
Um detalhe de rede que quase ninguém fala: RPCs são resolvidas pelo caminho do node. A cena ChatUI precisa existir no mesmo caminho da árvore em todas as máquinas, senão a Godot não sabe em qual node entregar a chamada. Instancie o chat no mesmo lugar da cena principal para todo mundo e isso nunca será problema.
O script do chat com RPC
Antes do chat em si, o contexto de conexão, resumido. O host cria o servidor, os demais conectam:
func hospedar() -> void:
var socket: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
socket.create_server(8910, 8)
multiplayer.multiplayer_peer = socket
func conectar(ip: String) -> void:
var socket: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
socket.create_client(ip, 8910)
multiplayer.multiplayer_peer = socket
Agora o script da cena ChatUI. Primeiro as constantes e referências:
extends Control
const TAMANHO_MAXIMO: int = 200
const INTERVALO_MINIMO_MS: int = 700
const HISTORICO_MAXIMO: int = 100
const PALAVRAS_BLOQUEADAS: Array[String] = ["palavrao1", "palavrao2"]
@onready var historico: RichTextLabel = %Historico
@onready var campo_texto: LineEdit = %CampoTexto
@onready var scroll: ScrollContainer = %Scroll
var _linhas: Array[String] = []
var _ultimo_envio_por_peer: Dictionary = {}
func _ready() -> void:
campo_texto.text_submitted.connect(_ao_enviar)
if multiplayer.is_server():
multiplayer.peer_connected.connect(_ao_entrar_peer)
multiplayer.peer_disconnected.connect(_ao_sair_peer)
O sinal text_submitted do LineEdit dispara quando o jogador aperta Enter, já entregando o texto. O envio local é curto:
func _ao_enviar(texto: String) -> void:
campo_texto.clear()
var limpo: String = texto.strip_edges()
if limpo.is_empty():
return
_enviar_ao_servidor.rpc_id(1, limpo)
Repare no rpc_id(1, ...). O peer de id 1 é sempre o servidor na rede high-level da Godot. A mensagem não vai para todo mundo, vai só para quem tem autoridade para decidir o que fazer com ela.
E aqui está o coração do sistema, as duas RPCs:
@rpc("any_peer", "call_local", "reliable")
func _enviar_ao_servidor(texto: String) -> void:
if not multiplayer.is_server():
return
var remetente: int = multiplayer.get_remote_sender_id()
var limpo: String = texto.strip_edges().left(TAMANHO_MAXIMO)
if limpo.is_empty():
return
if not _passou_no_rate_limit(remetente):
return
if _contem_palavra_bloqueada(limpo):
return
_distribuir_mensagem.rpc("Jogador %d" % remetente, limpo)
@rpc("authority", "call_local", "reliable")
func _distribuir_mensagem(nome: String, texto: String) -> void:
_adicionar_linha("[b]%s:[/b] %s" % [nome, _escapar_bbcode(texto)])
Cada palavra do anotador está ali por um motivo:
"any_peer"em_enviar_ao_servidor: qualquer cliente pode chamar essa função remotamente. Sem isso, o padrão é"authority"e a chamada do cliente seria silenciosamente rejeitada."authority"em_distribuir_mensagem: só o servidor pode disparar essa RPC. Um cliente malicioso que tente chamar_distribuir_mensagem.rpc()direto, pulando a validação, é barrado pela própria engine."call_local": a função também roda em quem chamou. No host, que é servidor e jogador ao mesmo tempo, isso garante que a mensagem dele passe pelo mesmo funil de validação e apareça na tela dele."reliable": mensagem de chat não pode se perder nem chegar fora de ordem. Para posição de personagem você usariaunreliable, para texto, nunca.
A identidade do remetente vem de multiplayer.get_remote_sender_id(), que devolve o id do peer que fez a chamada RPC em execução. É a engine que preenche esse valor na camada de transporte, o cliente não tem como forjar. Por isso o nome exibido nasce no servidor, do id real, e não de um campo que o cliente enviou. Num jogo de verdade você teria um Dictionary de id para apelido, registrado quando o jogador entra, mas a fonte da verdade continua sendo o id.
Validação server-side: nunca confie no cliente
As três checagens que faltam são curtas e rodam todas dentro do servidor, onde cliente nenhum alcança:
func _passou_no_rate_limit(remetente: int) -> bool:
var agora: int = Time.get_ticks_msec()
var ultimo: int = int(_ultimo_envio_por_peer.get(remetente, 0))
if agora - ultimo < INTERVALO_MINIMO_MS:
return false
_ultimo_envio_por_peer[remetente] = agora
return true
func _contem_palavra_bloqueada(texto: String) -> bool:
var minusculo: String = texto.to_lower()
for palavra: String in PALAVRAS_BLOQUEADAS:
if minusculo.contains(palavra):
return true
return false
O rate limit é um Dictionary de peer para timestamp. Se a mensagem chegou antes de 700 ms da anterior daquele mesmo peer, ela morre ali. Sem Timer por jogador, sem node extra, só aritmética de Time.get_ticks_msec(). O limite de tamanho já foi aplicado com .left(TAMANHO_MAXIMO) na entrada: mesmo que o cliente hackeado mande um romance, o servidor corta nos primeiros 200 caracteres.
O filtro de palavras é o básico honesto: lista de termos, comparação em minúsculas, descarte. Ele não resiste a jogador trocando letra por número, e tudo bem. Filtro perfeito não existe, o objetivo é cortar o grosso e sinalizar que a casa tem regras. Se o seu jogo crescer, essa mentalidade de validar tudo no servidor é exatamente a mesma que se aplica a tiro, dano e economia, e eu detalho isso no post sobre anti-cheat para jogos indie online. Chat é só o primeiro lugar onde ela aparece.
Polimento: escape de BBCode, histórico e mensagens de sistema
O RichTextLabel com bbcode_enabled interpreta qualquer tag que chegar no texto. A defesa é neutralizar o colchete de abertura antes de exibir:
func _escapar_bbcode(texto: String) -> String:
return texto.replace("[", "[lb]")
A tag [lb] é a forma oficial da Godot de renderizar um [ literal. Com um replace, [color=red]oi do jogador vira texto inofensivo na tela, enquanto o negrito e as cores que o próprio código adiciona continuam funcionando, porque o escape roda só sobre o conteúdo do jogador, não sobre a moldura.
O histórico limitado evita que uma partida longa acumule milhares de linhas num label só:
func _adicionar_linha(linha: String) -> void:
_linhas.append(linha)
while _linhas.size() > HISTORICO_MAXIMO:
_linhas.pop_front()
historico.text = "\n".join(_linhas)
await get_tree().process_frame
scroll.scroll_vertical = int(scroll.get_v_scroll_bar().max_value)
Guardo as linhas num Array[String], descarto as mais velhas e reconstruo o texto. O await de um frame antes de rolar é necessário porque o ScrollContainer só recalcula o tamanho do conteúdo no frame seguinte; sem ele, a rolagem para uma linha antes do fim.
Por fim, as mensagens de sistema, que dão vida à sala. Como só o servidor conectou os sinais peer_connected e peer_disconnected no _ready, só ele anuncia:
func _ao_entrar_peer(id: int) -> void:
_distribuir_sistema.rpc("Jogador %d entrou na sala." % id)
func _ao_sair_peer(id: int) -> void:
_distribuir_sistema.rpc("Jogador %d saiu da sala." % id)
@rpc("authority", "call_local", "reliable")
func _distribuir_sistema(texto: String) -> void:
_adicionar_linha("[color=gray][i]%s[/i][/color]" % texto)
Cinza e itálico separam visualmente o que é fala de jogador do que é aviso do jogo. Detalhe: aqui não escapo BBCode porque o texto nasce no próprio servidor, nunca do teclado de um cliente. Se um dia você interpolar apelido de jogador nessas mensagens, volte a escapar.
Erros comuns (e como fugir deles)
Depois de ver esse chat quebrar de vários jeitos em consultoria e em aula, estes são os tropeços que mais se repetem:
- Esquecer o
"any_peer". O cliente chama a RPC, nada acontece, nenhum erro grita. O modo padrão do@rpcé"authority", que rejeita chamadas de quem não é dono do node. Se a mensagem some no caminho, cheque o anotador primeiro. - Broadcast direto do cliente. Fazer o cliente chamar
rpc()para todo mundo funciona na demo e vira pesadelo depois, porque elimina o ponto único de validação. Semprerpc_id(1)para o servidor, sempre. - Validar só no cliente.
max_lengthnoLineEdite filtro de palavras no lado de quem digita são cortesia de UX. Segurança é só o que roda no servidor. - Exibir texto de jogador sem escapar. Se o seu chat aceita
[, ele aceita injeção de BBCode. Teste digitando[rainbow]oi[/rainbow]no seu próprio jogo, é revelador. - Testar com uma instância só. Use o menu Debug e a opção de customizar instâncias de execução da Godot 4 para abrir duas ou mais janelas do jogo de uma vez, uma como host e outra como cliente. Chat só existe de verdade com duas telas lado a lado.
- Chamar
get_remote_sender_id()fora de RPC. Fora do contexto de uma chamada remota a função devolve0. Ela só faz sentido dentro da função anotada com@rpc.
Com isso você tem um chat completo: UI, envio confiável, servidor como juiz, tela protegida. O mesmo esqueleto de "cliente pede, servidor valida e distribui" serve para emotes, pings no mapa e trocas de item. E quando o projeto sair da rede local e você começar a pensar em hospedar essa autoridade na nuvem, faça as contas antes com o post sobre quanto custa manter um servidor de jogo online, porque chat é leve, mas partida persistente tem preço. Até a próxima!
Perguntas frequentes
Como fazer um chat multiplayer na Godot 4?
Você monta uma UI com LineEdit para digitar e RichTextLabel para o histórico, conecta os jogadores com ENetMultiplayerPeer e troca as mensagens com funções anotadas com @rpc. O cliente envia o texto só para o servidor com rpc_id(1), o servidor valida (tamanho, spam, palavras) e redistribui para todos com rpc(). Nunca deixe o cliente mandar direto para os outros.
O que é RPC na Godot?
RPC é Remote Procedure Call, chamada de procedimento remoto. Você marca uma função com o anotador @rpc e, ao chamar metodo.rpc() ou metodo.rpc_id(id), ela executa nas outras máquinas conectadas. Para chat, use o modo reliable, que garante que a mensagem chega e na ordem certa, e call_local para quem envia também ver a própria mensagem.
Como evitar spam no chat multiplayer?
Aplique um rate limit no servidor: guarde o timestamp do último envio de cada peer (Time.get_ticks_msec()) num Dictionary e descarte mensagens que chegam antes de um intervalo mínimo, algo entre 500 ms e 1 segundo. Como a checagem roda no servidor, um cliente modificado não consegue burlar removendo o limite local.
Chat multiplayer precisa de servidor dedicado?
Não para começar. Um dos jogadores atua como host, rodando servidor e cliente na mesma máquina, e os outros conectam pelo IP. Toda a validação roda no processo do host. Servidor dedicado só entra em cena quando o jogo cresce e você precisa de partidas persistentes ou de uma autoridade neutra fora da máquina de um jogador.
Como filtrar palavrões no chat do meu jogo?
Mantenha uma lista de termos bloqueados e cheque cada mensagem no servidor com to_lower() antes de distribuir, descartando ou censurando o texto. É um filtro básico, jogadores criativos sempre acham brechas, mas corta o grosso. Para jogos maiores existem serviços de moderação dedicados, e um botão de reportar ajuda mais que filtro perfeito.


