Voltar ao Blog

Reaparecimento de Ghost Actor: A Correção de Desync de Replicação de Rede Multiplayer para Estruturas de Grid Destrutíveis

Publicado em 21 de junho de 2026
Reaparecimento de Ghost Actor: A Correção de Desync de Replicação de Rede Multiplayer para Estruturas de Grid Destrutíveis

Em resumo

Este artigo aborda a resolução do problema de 'ghost builds', um desync de replicação de rede multiplayer comum na destruição de estruturas de grid. A causa raiz reside na divergência entre o client-side prediction e a replicação de propriedades fora de ordem em relação ao fechamento do canal de conexão. Propõe-se uma solução em C++ utilizando um buffer preditivo local para suprimir temporariamente as atualizações do servidor no client. Ao fim, são apresentadas boas práticas de otimização de netcode e pipelines de validação para garantir a integridade do estado do jogo.

Reaparecimento de Ghost Actor: A Correção de Desync de Replicação de Rede Multiplayer para Estruturas de Grid Destrutíveis

O seu jogador brande uma ferramenta de coleta, uma parede de madeira se despedaça instantaneamente na tela dele, mas 80 milissegundos depois ela pisca de volta à existência com vida cheia antes de sumir definitivamente 400 milissegundos mais tarde. Essa anomalia visual, comumente chamada de "ghost build", é uma manifestação clássica de incompatibilidade de client-side prediction e replicação fora de ordem (out-of-order replication). Em ambientes multiplayer de ritmo acelerado, esses breves rollbacks de estado quebram a imersão do jogador e introduzem ruído visual. Para solucionar isso, os desenvolvedores devem implementar uma correção robusta de desync de replicação de rede multiplayer que reconcilie o feedback local imediato com a validação autoritativa do servidor.

Anatomia de um Ghost Build: Prediction vs. Replicação

Ao desenvolver a gameplay de rede, os criadores precisam equilibrar a responsividade com a autoridade do servidor. Para que o movimento e a destruição pareçam instantâneos, os clients preveem os resultados das ações antes de receberem a confirmação do servidor. Por exemplo, quando um jogador ataca uma estrutura destrutível, a lógica de jogo client-side executa imediatamente o cálculo de dano, desativa a colisão e dispara os efeitos de partículas.

Por baixo do capô, isso cria uma divergência de frações de segundo em que a simulação do client está à frente do servidor. Em um modelo de netcode padrão, essa divergência é resolvida assim que o servidor processa o RPC de entrada do client e replica o novo estado de volta. No entanto, se um pacote atrasar, ou se o tick rate do servidor (normalmente de 20Hz a 30Hz) ficar atrás da taxa de quadros (frame rate) do client (60Hz a 120Hz), ocorre uma race condition. A client-side prediction remove o actor, mas a próxima atualização de replicação do servidor ainda contém o estado antigo do actor (vivo e com vida).

Essa race condition específica é altamente visível em estruturas de madeira. Em comparação com pedra ou metal, a madeira tem um limite de vida mais baixo (por exemplo, 90 HP vs. 300 HP), o que significa que é destruída com um único golpe. Isso torna a janela de tempo entre a ação do jogador e a validação do servidor extremamente estreita. Qualquer atraso na replicação força o network driver do client a reconciliar o estado, reconstruindo o actor porque o servidor ainda o reporta como vivo.

O Impacto de Packet Loss e Tick Rates

Quando ocorre packet loss, a destruição prevista do client fica em um estado de limbo. Se um client transmite um pacote de dano que é descartado (dropped), o servidor nunca o processa, mas o client assume que o dano foi aplicado. O client então continua a simulação sob a falsa premissa de que o actor sumiu. Quando o servidor transmite a próxima atualização de estado, a inconsistência torna-se evidente, forçando o client a spawnar o actor de volta ao mundo. Esse processo de reconciliação cria um pop visual irritante, especialmente sob taxas de 1,5% a 3% de packet loss, onde esses descartes ocorrem com frequência.

Por Baixo do Capô: Lifecycle de Actors e Channel Teardown

A Unreal Engine e engines multiplayer modernas semelhantes sincronizam a presença de actors usando canais de conexão de rede dedicados. Cada actor replicado recebe um canal de actor (actor channel). Quando um actor é destruído no servidor, o servidor fecha esse canal, enviando uma mensagem de controle de fechamento de canal (NetGUID retirement) para o client.

O problema crítico é que a replicação de propriedades (property replication) e o fechamento do canal não compartilham o mesmo caminho de replicação. As atualizações de propriedade (como alterações na variável de vida, Health, de uma estrutura) são serializadas e enviadas como parte do pacote de replicação regular do actor. Se o servidor processar um evento de dano, mas ainda não tiver feito o Garbage Collection do actor, ele poderá serializar uma atualização final de propriedade antes que o actor seja totalmente marcado para destruição. Se o pacote UDP contendo a atualização de propriedade chegar antes do pacote com o fechamento do canal, o client atualizará a vida do actor e anulará a destruição prevista localmente.

Esse comportamento está intimamente relacionado a outros problemas de sincronização de netcode, como os discutidos em nosso guia sobre como corrigir desyncs multiplayer corrigindo o problema de replicação de RPC do Unreal Engine que quebra seus estados. Nesse guia, analisamos como as incompatibilidades na ordem de execução entre RPCs e propriedades quebram os estados do mundo. Da mesma forma, ao lidar com o posicionamento dos jogadores, os desenvolvedores frequentemente encontram discrepâncias parecidas, conforme detalhado em nosso guia sobre como corrigir desync de localização de jogador no Uefn e Unreal Engine multiplayer.

Quando o client processa o pacote de replicação fora de ordem, ele vê que o actor está vivo no servidor e força o actor de volta para a pool ativa. O client é obrigado a esperar até que o pacote de fechamento de canal finalmente chegue — muitas vezes 0.4 segundos depois — para deletar permanentemente o actor.

Por baixo do capô, os pacotes de replicação são limitados pela Unidade Máxima de Transmissão (MTU), que normalmente é de 1400 bytes. Se a taxa de conexão do seu jogo for limitada (por exemplo, com MaxClientRate definido para 15000 bytes/s), as atualizações serão enfileiradas e divididas em vários pacotes UDP. Como a mensagem de controle para o fechamento do canal é enviada de forma reliable, ela precisa ser confirmada, enquanto as atualizações de propriedades geralmente são enviadas de forma unreliable. Se ocorrer congestão de rede ou packet loss, a mensagem confiável de fechamento de canal pode atrasar e ficar atrás de pacotes de propriedades não confiáveis mais antigos, gerando uma incompatibilidade na qual o client reconstrói o actor.

Implementando um Buffer de Estado Preditivo em C++

Para corrigir os ghost builds, devemos interceptar no client as atualizações de replicação recebidas para actors cuja destruição foi prevista. Ao implementar um buffer de client-side prediction, podemos suprimir a reconciliação de propriedades por uma janela de tempo específica (por exemplo, 500ms), dando tempo suficiente para que o pacote de fechamento de canal do servidor chegue. Abaixo está uma implementação completa e funcional em C++ de um actor destrutível preditivo. Ele sobrescreve o comportamento de replicação e usa um timestamp local para determinar se a replicação deve ser suprimida.

// PredictedDestructibleActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PredictedDestructibleActor.generated.h"

UCLASS()
class MULTIPLAYERGAME_API APredictedDestructibleActor : public AActor
{
    GENERATED_BODY()

public:
    APredictedDestructibleActor();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    
    // Server-authoritative health variable
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Destruction")
    float MaxHealth;

    // Triggered when health changes on the client
    UFUNCTION()
    void OnRep_Health();

    // Client-side prediction tracking flags
    bool bClientPredictedDestroyed;
    float ClientPredictionTime;
    
    // Maximum time (in seconds) the client will suppress server updates
    UPROPERTY(EditAnywhere, Category = "Networking")
    float PredictionTimeout;

    // Visual effect helper function
    void TriggerDestructionEffects();

public:
    // Called when the local player destroys the structure client-side
    UFUNCTION(BlueprintCallable, Category = "Destruction")
    void PredictDestruction();

    // Resets the predicted state if the server rejects the destruction
    void ResetPredictionState();

    virtual void Tick(float DeltaTime) override;
};

Aqui está o arquivo de implementação correspondente, mostrando como filtrar os estados recebidos do servidor:

// PredictedDestructibleActor.cpp
#include "PredictedDestructibleActor.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"

APredictedDestructibleActor::APredictedDestructibleActor()
{
    PrimaryActorTick.bCanEverTick = true;
    bReplicates = true;
    
    // Set a moderate update frequency to balance bandwidth and responsiveness
    NetUpdateFrequency = 33.0f; 
    
    MaxHealth = 100.0f;
    Health = MaxHealth;
    bClientPredictedDestroyed = false;
    ClientPredictionTime = 0.0f;
    PredictionTimeout = 0.5f; // 500ms safety window
}

void APredictedDestructibleActor::BeginPlay()
{
    Super::BeginPlay();
}

void APredictedDestructibleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(APredictedDestructibleActor, Health);
}

void APredictedDestructibleActor::OnRep_Health()
{
    // If the client has predicted this actor's death, suppress server property updates
    if (bClientPredictedDestroyed)
    {
        return;
    }

    if (Health <= 0.0f)
    {
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::PredictDestruction()
{
    // Prediction only runs on the client that simulated the event
    if (GetNetMode() == NM_Client)
    {
        bClientPredictedDestroyed = true;
        ClientPredictionTime = GetWorld()->GetTimeSeconds();
        
        // Hide the actor and disable collision immediately for responsive local feedback
        SetActorEnableCollision(false);
        SetActorHiddenInGame(true);
        
        // Spawn local particles and audio instantly
        TriggerDestructionEffects();
    }
}

void APredictedDestructibleActor::ResetPredictionState()
{
    bClientPredictedDestroyed = false;
    SetActorEnableCollision(true);
    SetActorHiddenInGame(false);
}

void APredictedDestructibleActor::TriggerDestructionEffects()
{
    // Spawn local visual effects (e.g. wood splinters, dust clouds)
    // and play destruction audio.
}

void APredictedDestructibleActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
    {
        float CurrentTime = GetWorld()->GetTimeSeconds();
        
        // If the timeout expires and the server hasn't torn down the channel, 
        // the server must have rejected the damage. We must roll back.
        if (CurrentTime - ClientPredictionTime > PredictionTimeout)
        { 
            ResetPredictionState();
        }
    }
}

Ao utilizar esse buffer preditivo, evitamos que a callback OnRep_Health recebida resete a visibilidade visual do actor. Isso mantém o actor do client oculto e sem colisão até que o pacote de fechamento de canal chegue. Se o servidor não concordar com a destruição (por exemplo, devido a um erro de validação de anti-cheat), o timeout força um rollback, garantindo que a simulação não sofra um desync permanente.

Lidando com Rollbacks e Rejeição de Validação

Um componente crítico desta correção de replicação é lidar com os casos em que o servidor rejeita a ação do jogador. Se a lógica de validação do servidor determinar que o jogador não poderia ter atingido a estrutura, ela rejeitará o dano. Nesse cenário, o client deve fazer o rollback da destruição prevista para evitar a dessincronização permanente, e é por isso que o timeout restaura a colisão e a visibilidade do actor caso nenhuma confirmação do servidor chegue em 500ms.

O Peso da Implementação Manual vs. Backends Dedicados

Embora a solução em C++ acima resolva o ghosting de actors individuais, aplicar isso a todo o ambiente de um jogo é algo complexo. Os desenvolvedores de jogos devem escrever manualmente códigos de prediction e rollback para cada tipo de objeto destrutível, rastrear buffers de prediction ativos, otimizar os tick rates dos actors e gerenciar as prioridades de replicação de rede. Para uma equipe indie, construir e testar esses casos extremos pode facilmente levar de 4 a 6 semanas de engenharia de rede dedicada.

Desenvolver isso por conta própria exige a configuração de load balancers, database sharding e servidores WebSockets/UDP complexos. Com o horizOn, esses serviços de backend já vêm pré-configurados, permitindo que você lance o seu jogo em vez de gerenciar infraestrutura de rede. O gerenciamento de lobby em tempo real e a orquestração de sessões do horizOn garantem que os estados dos jogadores e as propriedades da partida sejam sincronizados de forma confiável com latência abaixo de 50ms, mitigando os atrasos de replicação que causam os ghost builds.

Boas Práticas Acionáveis para uma Correção de Desync de Replicação de Rede Multiplayer

Quando otimizar o seu netcode para objetos destrutíveis, siga estas diretrizes para manter o estado do seu mundo sincronizado:

  1. Desacople Visual Assets do Lifecycle do Actor: Evite depender da execução imediata de AActor::Destroy() para o feedback visual. Defina uma flag de replicação booleana como bIsDead e dispare os sistemas de partículas locais imediatamente. Isso permite que você desative a colisão no client sem esperar pelas rotinas de limpeza do servidor.
  2. Priorize a Destruição de Canais sobre as Atualizações de Propriedades: Defina bOnlyRelevantToOwner ou aumente o NetPriority em destrutíveis para garantir que as updates de destruição tenham prioridade no network driver. Isso assegura que elas não sejam atrasadas por conta da replicação de propriedades de ambiente padrão.
  3. Defina uma Janela de Timeout de Prediction Ativa: Nunca permita que uma client-side prediction rode indefinidamente. Sempre implemente um timeout de segurança (normalmente 1,5x a 2x o seu RTT máximo aceitável, mais uma margem para variação de ticks do servidor) para forçar um rollback no client caso o servidor rejeite uma ação. Isso evita que o actor permaneça oculto para sempre se um pacote for perdido.
  4. Ajuste a NetUpdateFrequency: Mantenha as taxas de atualização de suas estruturas destrutíveis baixas (por exemplo, 10-15Hz) sob condições normais. Aumente dinamicamente a frequência de atualização para 33Hz apenas quando elas sofrerem dano, reduzindo a largura de banda ociosa enquanto preserva a responsividade. Isso equilibra o uso de rede durante interações intensas dos jogadores.
  5. Otimize os Pipelines de Validação do Servidor: Garanta que a validação de dano server-side seja rápida e leve. Se o servidor demorar mais de 100ms para validar um acerto, o buffer de prediction do client provavelmente sofrerá timeout, causando um jitter visível. Otimize o código de verificação para minimizar atrasos no processamento.

Resumo e Próximos Passos

Resolver desyncs de replicação exige um entendimento profundo do pipeline de rede da sua engine. Ao suprimir as atualizações de propriedades do servidor em actors cuja destruição foi prevista no client, você pode eliminar os ghost builds e fornecer uma experiência fluida e responsiva para os jogadores.

Pronto para escalar seu backend multiplayer e reduzir problemas de sincronização? Experimente o horizOn gratuitamente ou confira a API docs para aprender a implementar gerenciamento de sessões de baixa latência em seu próximo projeto.


Fonte: Ghost builds appear shortly after breaking wooden player build structures