Voltar para o Blog
Quest Log

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

Janela de jogo feito na Godot com caixa de chat mostrando mensagens de dois jogadores trocadas em tempo real

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? RichTextLabel interpreta 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:

  1. No Scroll, marque Vertical em Size Flags como Expand + Fill, para o histórico ocupar o espaço e o LineEdit ficar colado embaixo.
  2. No Historico, ative bbcode_enabled (vamos usar negrito e cor nas mensagens de sistema) e fit_content, para o label crescer com o texto e o ScrollContainer cuidar da rolagem.
  3. No CampoTexto, defina max_length como 200. 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ê usaria unreliable, 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.

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

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:

  1. 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.
  2. 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. Sempre rpc_id(1) para o servidor, sempre.
  3. Validar só no cliente. max_length no LineEdit e filtro de palavras no lado de quem digita são cortesia de UX. Segurança é só o que roda no servidor.
  4. 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.
  5. 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.
  6. Chamar get_remote_sender_id() fora de RPC. Fora do contexto de uma chamada remota a função devolve 0. 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.