Volver al Blog

Reaparición de Ghost Actors: Solución al desync de network replication en Multiplayer para estructuras de cuadrícula destructibles

Publicado el 21 de junio de 2026
Reaparición de Ghost Actors: Solución al desync de network replication en Multiplayer para estructuras de cuadrícula destructibles

En resumen

Esta guía aborda el problema de los ghost builds en entornos Multiplayer, analizando cómo el desfase temporal entre la client-side prediction y la desincronización de paquetes de red provoca la reaparición visual de actores destructibles. Se presenta una solución práctica en C++ basada en un buffer de predicción que suprime temporalmente las actualizaciones del servidor hasta que se complete el cierre del canal de red. Por último, se enumeran buenas prácticas de optimización de Netcode para mitigar desyncs de replicación en estructuras de cuadrícula.

Reaparición de Ghost Actors: Solución al desync de network replication en Multiplayer para estructuras de cuadrícula destructibles

Tu jugador balancea una herramienta de recolección, una pared de madera se destruye instantáneamente en su pantalla, pero 80 milisegundos después vuelve a aparecer con la vida al máximo antes de desaparecer de forma permanente 400 milisegundos más tarde. Esta anomalía visual, comúnmente conocida como "ghost build", es una manifestación clásica de un desajuste en la client-side prediction y de una out-of-order replication. En entornos Multiplayer de ritmo rápido, estos breves rollbacks de estado rompen la inmersión del jugador e introducen ruido visual. Para solucionar esto, los desarrolladores deben implementar un fix de desync de network replication en Multiplayer que sea robusto y reconcilie el feedback local inmediato con la validación autoritativa del servidor.

Anatomía de un Ghost Build: Prediction vs. Replication

Al desarrollar gameplay en red, los creadores deben equilibrar la capacidad de respuesta con la autoridad del servidor. Para que el movimiento y la destrucción se sientan instantáneos, los clientes predicen los resultados de las acciones antes de recibir la confirmación del servidor. Por ejemplo, cuando un jugador ataca una estructura destructible, la lógica de juego del client-side ejecuta inmediatamente el cálculo de daño, desactiva las colisiones y activa los efectos de partículas.

Bajo el capó, esto crea una divergencia de fracciones de segundo en la que la simulación del cliente va por delante de la del servidor. En un modelo de Netcode estándar, esta divergencia se resuelve una vez que el servidor procesa la RPC de input del cliente y replica el nuevo estado de vuelta. Sin embargo, si un paquete se retrasa, o si el tick rate del servidor (normalmente de 20 Hz a 30 Hz) se queda atrás respecto al frame rate del cliente (de 60 Hz a 120 Hz), se produce una race condition. La client-side prediction elimina al actor, pero la siguiente actualización de replicación del servidor todavía contiene el estado anterior del actor (vivo y con salud).

Esta race condition específica es muy visible en estructuras de madera. En comparación con la piedra o el metal, la madera tiene un umbral de salud más bajo (por ejemplo, 90 HP frente a 300 HP), lo que significa que se destruye de un solo golpe. Esto hace que la ventana de tiempo entre la acción del jugador y el acknowledgment del servidor sea extremadamente estrecha. Cualquier retraso en la replicación obliga al network driver del cliente a reconciliar el estado, reconstruyendo el actor porque el servidor todavía informa que está vivo.

El impacto de Packet Loss y Tick Rates

Cuando ocurre packet loss, la destrucción predicha por el cliente se queda en un estado de limbo. Si un cliente transmite un paquete de daño que se pierde, el servidor nunca lo procesa, pero el cliente asume que el daño fue aplicado. Luego, el cliente continúa la simulación bajo la falsa suposición de que el actor ha desaparecido. Cuando el servidor transmite la siguiente actualización de estado, la discrepancia se hace evidente, lo que obliga al cliente a volver a spawnear el actor en el mundo. Este proceso de reconciliación crea un pop visual molesto, especialmente bajo un packet loss de entre el 1.5% y el 3%, donde estas pérdidas ocurren con frecuencia.

Bajo el capó de Actor Lifecycle y Channel Teardown

Unreal Engine y motores Multiplayer modernos similares sincronizan la presencia de actores mediante canales dedicados de conexión de red. A cada actor replicado se le asigna un actor channel. Cuando un actor se destruye en el servidor, este cierra este canal, enviando un mensaje de control de cierre de canal (NetGUID retirement) al cliente.

El problema crítico es que la property replication y el cierre del canal no comparten la misma ruta de replicación. Las actualizaciones de propiedades (como los cambios en la variable Health de una estructura) se serializan y se envían como parte del replication bundle regular del actor. Si el servidor procesa un evento de daño pero aún no ha ejecutado la Garbage Collection del actor, podría serializar una actualización final de propiedad antes de que el actor esté completamente marcado para su destrucción. Si el paquete UDP que contiene la actualización de la propiedad llega antes que el paquete que contiene el cierre del canal, el cliente actualiza la salud del actor y sobrescribe la destrucción local predicha.

Este comportamiento está estrechamente relacionado con otros problemas de sincronización de Netcode, como los que analizamos en nuestra guía sobre cómo solucionar desyncs de multiplayer y arreglar el problema de replicación de RPC en Unreal Engine que rompe tus estados. En esa guía, analizamos cómo los desajustes en el orden de ejecución entre RPCs y propiedades corrompen los estados del mundo. De manera similar, al manejar el posicionamiento de los jugadores, los desarrolladores a menudo encuentran discrepancias parecidas, como se detalla en nuestra guía sobre cómo solucionar el desync de la ubicación del jugador en Uefn y el multiplayer de Unreal Engine.

Cuando el cliente procesa el paquete de replicación que llegó fuera de orden (out-of-order replication), ve que el actor está vivo en el servidor y lo fuerza a volver al pool activo. El cliente se ve obligado a esperar hasta que finalmente llegue el paquete de cierre del canal —a menudo 0.4 segundos después— para eliminar permanentemente al actor.

Bajo el capó, los paquetes de replicación están limitados por la Maximum Transmission Unit (MTU), que suele ser de 1400 bytes. Si el connection rate de tu juego está limitado (por ejemplo, con MaxClientRate configurado en 15000 bytes/s), las actualizaciones se encolan y se dividen en múltiples paquetes UDP. Dado que el mensaje de control para el cierre del canal se envía de forma fiable (reliable), debe ser confirmado, mientras que las actualizaciones de propiedades a menudo se envían de forma no fiable (unreliable). Si ocurre congestión de red o packet loss, el mensaje fiable de cierre de canal puede retrasarse por detrás de paquetes de propiedades no fiables más antiguos, provocando un desajuste en el que el cliente reconstruye al actor.

Implementación de un Predictive State Buffer en C++

Para solucionar los ghost builds, debemos interceptar las actualizaciones de replicación entrantes en el cliente para los actores cuya destrucción ha sido predicha. Al implementar un buffer de client-side prediction, podemos suprimir la reconciliación de propiedades durante una ventana de tiempo específica (por ejemplo, 500 ms), dando tiempo suficiente para que llegue el paquete de cierre del canal del servidor. A continuación, se muestra una implementación completa y funcional en C++ de un actor destructible predictivo. Este sobrescribe el comportamiento de replicación y utiliza un timestamp local para determinar si se debe suprimir la replicación.

// 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;
    
    // Variable de salud autoritativa del servidor
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    float Health;

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

    // Se activa cuando la salud cambia en el cliente
    UFUNCTION()
    void OnRep_Health();

    // Flags de seguimiento para client-side prediction
    bool bClientPredictedDestroyed;
    float ClientPredictionTime;
    
    // Tiempo máximo (en segundos) que el cliente suprimirá las actualizaciones del servidor
    UPROPERTY(EditAnywhere, Category = "Networking")
    float PredictionTimeout;

    // Función auxiliar para efectos visuales
    void TriggerDestructionEffects();

public:
    // Se llama cuando el jugador local destruye la estructura en el client-side
    UFUNCTION(BlueprintCallable, Category = "Destruction")
    void PredictDestruction();

    // Restablece el estado predicho si el servidor rechaza la destrucción
    void ResetPredictionState();

    virtual void Tick(float DeltaTime) override;
};

Aquí está el archivo de implementación correspondiente, que muestra cómo filtrar los estados entrantes del servidor:

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

APredictedDestructibleActor::APredictedDestructibleActor()
{
    PrimaryActorTick.bCanEverTick = true;
    bReplicates = true;
    
    // Establece una frecuencia de actualización moderada para equilibrar el ancho de banda y la capacidad de respuesta
    NetUpdateFrequency = 33.0f; 
    
    MaxHealth = 100.0f;
    Health = MaxHealth;
    bClientPredictedDestroyed = false;
    ClientPredictionTime = 0.0f;
    PredictionTimeout = 0.5f; // Ventana de seguridad de 500 ms
}

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

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

void APredictedDestructibleActor::OnRep_Health()
{
    // Si el cliente ha predicho la muerte de este actor, suprime las actualizaciones de propiedades del servidor
    if (bClientPredictedDestroyed)
    {
        return;
    }

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

void APredictedDestructibleActor::PredictDestruction()
{
    // La predicción solo se ejecuta en el cliente que simuló el evento
    if (GetNetMode() == NM_Client)
    {
        bClientPredictedDestroyed = true;
        ClientPredictionTime = GetWorld()->GetTimeSeconds();
        
        // Oculta el actor y desactiva la colisión de inmediato para un feedback local responsivo
        SetActorEnableCollision(false);
        SetActorHiddenInGame(true);
        
        // Spawnea partículas locales y audio al instante
        TriggerDestructionEffects();
    }
}

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

void APredictedDestructibleActor::TriggerDestructionEffects()
{
    // Spawnea efectos visuales locales (por ejemplo, astillas de madera, nubes de polvo)
    // y reproduce el audio de destrucción.
}

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

    if (GetNetMode() == NM_Client && bClientPredictedDestroyed)
    {
        float CurrentTime = GetWorld()->GetTimeSeconds();
        
        // Si el timeout expira y el servidor no ha cerrado el canal, el servidor debe haber rechazado el daño. Debemos hacer rollback.
        if (CurrentTime - ClientPredictionTime > PredictionTimeout)
        { 
            ResetPredictionState();
        }
    }
}

Al utilizar este buffer predictivo, evitamos que la llamada de retorno OnRep_Health entrante restablezca la visibilidad visual del actor. Esto mantiene al actor del client-side oculto y libre de colisiones hasta que llegue el paquete de cierre de canal. Si el servidor no está de acuerdo con la destrucción (por ejemplo, debido a una discrepancia en la validación del Anti-Cheat), el timeout fuerza un rollback, lo que garantiza que la simulación no se desincronice de forma permanente.

Manejo de Rollbacks y Rechazo de Validación

Uncomponente crítico de este fix de replicación es el manejo de los casos en los que el servidor rechaza la acción del jugador. Si la lógica de validación del servidor determina que el jugador no pudo haber golpeado la estructura, rechaza el daño. En este escenario, el cliente debe hacer rollback de la destrucción predicha para evitar una desincronización permanente, razón por la cual el timeout restablece la colisión y la visibilidad del actor si no llega la confirmación del servidor en un plazo de 500 ms.

La carga de la implementación manual frente a Backends dedicados

Aunque la solución en C++ anterior soluciona el ghosting de actores individuales, aplicar esto a todo el entorno de un juego es complejo. Los desarrolladores de juegos deben escribir manualmente el código de predicción y rollback para cada tipo de objeto destructible, realizar el seguimiento de los buffers de predicción activos, optimizar los tick rates de los actores y gestionar las prioridades de replicación de red. Para un equipo indie, implementar y probar estos casos extremos puede llevar fácilmente de 4 a 6 semanas de ingeniería de red dedicada.

Desarrollar esto por tu cuenta requiere configurar load balancers, database sharding y complejos servidores de WebSockets/UDP. Con horizOn, estos servicios de Backend vienen preconfigurados, lo que te permite lanzar tu juego en lugar de gestionar infraestructura de red. La gestión de lobbies en tiempo real y la orquestación de sesiones de horizOn garantizan que los estados de los jugadores y las propiedades de las partidas se sincronicen de forma fiable con una latencia inferior a 50 ms, mitigando los retrasos de replicación que causan los ghost builds.

Buenas prácticas accionables para un fix de desync de network replication en Multiplayer

Al optimizar tu Netcode para objetos destructibles, sigue estas pautas para mantener sincronizado el estado del mundo:

  1. Desacopla los assets visuales del Actor Lifecycle: Evita depender de la ejecución inmediata de AActor::Destroy() para el feedback visual. Establece un flag de replicación booleano como bIsDead y activa los sistemas de partículas locales de inmediato. Esto te permite desactivar la colisión en el cliente sin esperar a las rutinas de limpieza del servidor.
  2. Prioriza la destrucción del canal sobre las actualizaciones de propiedades: Configura bOnlyRelevantToOwner o aumenta la NetPriority en los objetos destructibles para garantizar que las actualizaciones de destrucción tengan prioridad en el network driver. Esto asegura que no se retrasen por detrás de la replicación de propiedades ambientales estándar.
  3. Define una ventana de timeout de predicción activa: Nunca permitas que una client-side prediction se ejecute indefinidamente. Implementa siempre un timeout de seguridad (normalmente de 1.5 a 2 veces tu RTT máximo aceptable, más un margen para la variación de ticks del servidor) para forzar un rollback del cliente si el servidor rechaza una acción. Esto evita que el actor permanezca oculto de forma permanente si se pierde un paquete.
  4. Ajusta la NetUpdateFrequency: Mantén bajas las tasas de actualización de tus estructuras destructibles (por ejemplo, 10-15 Hz) en condiciones normales. Aumenta dinámicamente la frecuencia de actualización a 33 Hz solo cuando reciban daño, reduciendo el ancho de banda inactivo mientras preservas la capacidad de respuesta. Esto equilibra el uso de la red durante las interacciones intensas de los jugadores.
  5. Optimiza las pipelines de validación del servidor: Asegúrate de que la validación de daños en el lado del servidor sea rápida y ligera. Si el servidor tarda más de 100 ms en validar un impacto, es probable que el buffer de predicción del cliente expire, provocando un jitter visible. Simplifica el código de verificación para minimizar los retrasos de procesamiento.

Resumen y próximos pasos

Resolver los desyncs de replicación requiere una comprensión profunda del network pipeline de tu motor de desarrollo. Al suprimir las actualizaciones de propiedades del servidor en los actores destruidos según la predicción del cliente, puedes eliminar los ghost builds y ofrecer una experiencia fluida y responsiva para los jugadores.

¿Listo para escalar tu Backend de Multiplayer y reducir los problemas de sincronización? Prueba horizOn de forma gratuita o consulta la documentación de la API para aprender a implementar una gestión de sesiones de baja latencia en tu próximo proyecto.


Fuente: Ghost builds aparecen poco después de romper estructuras de madera construidas por el jugador