top of page
Search

Unreal Engine - Network Replication (2024)

  • Writer: Vichakshana Arangala
    Vichakshana Arangala
  • Jun 27, 2025
  • 4 min read

This is a demo for a Multiplayer Roguelike developed in Unreal Engine using Unreal’s Paragon Asset Pack Character Gideon. The development of this project was focused on Network Replication. This has been achieved through a custom Ability System that is replicated properly on both Client and Server. The game was made entirely in C++.


A standard gameplay encounter within this setup.
A standard gameplay encounter within this setup.

In a standard a combat encounter, once an enemy has spotted Gideon, a question mark would light up above their heads, reminiscent of Metal Gear Solid. Both the Enemy and the Player are made up of modular components that allow them to implement similar behavior such as getting burned.


Gideon Dashes across the level at great speed.
Gideon Dashes across the level at great speed.

The Dash is one of Gideon’s many special abilities, allowing Gideon to speed across the map and avoid enemies.


Gideon releases his Rage in a powerful blackhole which sucks up everything in range.
Gideon releases his Rage in a powerful blackhole which sucks up everything in range.

Gideon gains Rage when he is attacked. And once Gideon’s Rage has reach 50%, he can send out a devastating blackhole that sucks up everything.


Gideon uses his teleportation powers to quickly move across the level.
Gideon uses his teleportation powers to quickly move across the level.

Gideon can also teleport across the map to quickly escape Danger.


Gideons abilities are seamlessly replicated.
Gideons abilities are seamlessly replicated.

All of Gideon’s abilities are replicated properly across both Client and Server in the most optimal way possible.


NOTE: Enemies were disable for this recording.


Behind Enemy Lines...

AI Character Character Overview
// Sets default values
ASHAICharacter::ASHAICharacter()
{
	PawnSensingComp = CreateDefaultSubobject<UPawnSensingComponent>("PawnSensingComp");
	AttributeComp = CreateDefaultSubobject<USHAttributeComponent>("AttributeComp");
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
	ActionComp = CreateDefaultSubobject<USHActionComponent>("ActionComp");
	//GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	// Enabled on mesh to react to incoming projectiles
	USkeletalMeshComponent* SkelMesh = GetMesh();
	SkelMesh->SetGenerateOverlapEvents(true);
	// Skip performing overlap queries on the Physics Asset after animation (17 queries in case of our MinionRangedBP)
	SkelMesh->bUpdateOverlapsOnAnimationFinalize = false;
	// Skip bones when not visible, may miss anim notifies etc. if animation is skipped so these options must be tested per use case
	SkelMesh->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;
}
void ASHAICharacter::PostInitializeComponents()
{
	Super::PostInitializeComponents();
	PawnSensingComp->OnSeePawn.AddDynamic(this, &ASHAICharacter::OnPawnSeen);
	AttributeComp->OnHealthChanged.AddDynamic(this, &ASHAICharacter::OnHealthChanged);
}
void ASHAICharacter::OnHealthChanged(AActor* InstigatorActor, USHAttributeComponent* OwningComp, float newHealth, float Delta)
{
	if (Delta < 0.0f) //make sure we didn't heal or anything
	{
		if (InstigatorActor != this)
		{
			SetTargetActor(InstigatorActor);
		}
		if (ActiveHealthBar == nullptr)
		{
			ActiveHealthBar = CreateWidget<USHWorldUserWidget>(GetWorld(), HealthBarWidgetClass);
			if (ActiveHealthBar)
			{
				ActiveHealthBar->AttachedActor = this;
				ActiveHealthBar->AddToViewport();
			}
		}
		
		GetMesh()->SetScalarParameterValueOnMaterials(TimeToHitParamName, GetWorld()->TimeSeconds);
		//Died
		if (newHealth <= 0.0f)
		{
			//Just died - stop behaviour tree, enable ragdoll , set lifespan(how long till we call destroy actor)
			//Sopping logic
			AAIController* AIC = Cast<AAIController>(GetController());
			if (ensureAlways(AIC))
			{
				AIC->GetBrainComponent()->StopLogic("Killed");
			}
			//Ragdoll
			GetMesh()->SetAllBodiesSimulatePhysics(true);
			GetMesh()->SetCollisionProfileName("Ragdoll");
			GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
			GetCharacterMovement()->DisableMovement();
			//Destroy actor in 10 seconds
			SetLifeSpan(10.0f);
		}
	}
}
void ASHAICharacter::SetTargetActor(AActor* NewTarget)
{
	AAIController* AIC = Cast<AAIController>(GetController());
	if (AIC)
	{
		AIC->GetBlackboardComponent()->SetValueAsObject("TargetActor", NewTarget);
	}
}
AActor* ASHAICharacter::GetTargetActor() const
{
	AAIController* AIC = GetController<AAIController>();
	return Cast<AActor>(AIC->GetBlackboardComponent()->GetValueAsObject("TargetActor"));
}
void ASHAICharacter::OnPawnSeen(APawn* Pawn)
{
	//Ignore if target is already set
	if (GetTargetActor() != Pawn)
	{
		SetTargetActor(Pawn);
		//DrawDebugString(GetWorld(), GetActorLocation(), "PLAYER SPOTTED", nullptr, FColor::White, 4.0f, true);
		MulticastPawnSeen();
		
	}
}
void ASHAICharacter::MulticastPawnSeen_Implementation()
{
	USHWorldUserWidget* NewWidget = CreateWidget<USHWorldUserWidget>(GetWorld(), SpottedWidgetClass);
	if (NewWidget)
	{
		NewWidget->AttachedActor = this;
		//Index of 10 (or anything higher than default of 0) places this on top of any other widget.
		//May end up being minion health bar otherwise.
		NewWidget->AddToViewport(10);
	}
}



Ranged Attack -  Behaviour Tree Task
USHBTTask_RangedAttack::USHBTTask_RangedAttack()
{
    MuzzleSocket = "Muzzle_01";
    MaxBulletSpread = 2.0f;
}
EBTNodeResult::Type USHBTTask_RangedAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    AAIController* MyController = OwnerComp.GetAIOwner();
    if (ensureAlways(MyController))
    {
        ACharacter* MyPawn = Cast<ACharacter>(MyController->GetPawn());
        if (MyPawn == nullptr)
        {
            return EBTNodeResult::Failed;
        }
        FVector MuzzleLocation = MyPawn->GetMesh()->GetSocketLocation(MuzzleSocket);
        AActor* TargetActor = Cast<AActor>(OwnerComp.GetBlackboardComponent()->GetValueAsObject("TargetActor"));
        if (TargetActor == nullptr)
        {
            return EBTNodeResult::Failed;
        }
        if (!USHAttributeComponent::IsActorAlive(TargetActor))
        {
            return EBTNodeResult::Failed;
        }
        FVector Direction = TargetActor->GetActorLocation() - MuzzleLocation;
        FRotator MuzzleRotation = Direction.Rotation();
        MuzzleRotation.Pitch += FMath::RandRange(0.0f, MaxBulletSpread);
        MuzzleRotation.Yaw += FMath::RandRange(-MaxBulletSpread, MaxBulletSpread);
        FActorSpawnParameters Params;
        Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
        Params.Instigator = MyPawn;
        AActor* NewProj = GetWorld()->SpawnActor<AActor>(ProjectileClass, MuzzleLocation, MuzzleRotation, Params);
     
       
        return NewProj ? EBTNodeResult::Succeeded : EBTNodeResult::Failed;
    }
    return EBTNodeResult::Failed;
}
Check Health - Behaviour Tree Service
USHBTService_CheckHealth::USHBTService_CheckHealth()
{
	LowHealthFraction = 0.3f;
}
void USHBTService_CheckHealth::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
	APawn* AIPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (ensure(AIPawn))
	{
		USHAttributeComponent* AttributeComp = USHAttributeComponent::GetAttributes(AIPawn);
		if (ensure(AttributeComp))
		{
			bool bLowHealth = (AttributeComp->GetHealth() / AttributeComp->GetHealthMax()) < LowHealthFraction;
			UBlackboardComponent* BlackBoardComp = OwnerComp.GetBlackboardComponent();
			BlackBoardComp->SetValueAsBool(LowHealthKey.SelectedKeyName, bLowHealth);
		}
	}
}


Heal Self -  Behaviour Tree Task
EBTNodeResult::Type USHBTTask_HealSelf::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    APawn* MyPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
    if (MyPawn == nullptr)
    {
        return EBTNodeResult::Failed;
    }
    USHAttributeComponent* AttributeComp = USHAttributeComponent::GetAttributes(MyPawn);
    if (ensure(AttributeComp))
    {
        AttributeComp->ApplyHealthChange(MyPawn, AttributeComp->GetHealthMax());
    }
    return EBTNodeResult::Succeeded;
}

Putting it all together...

Once an enemy has spawned, it will decide it’s movement based on the its health, and if it’s in attack range.


If it has low health, it will find a hiding spot, move to said spot and heal itself before engaging in combat again.


Once it’s in attack range, it will attack upto 3 times before deciding on its next action.


If it’s not in attack range or if it has already attacked, the enemy will then move to a random location, wait for 5 seconds, and then go through its motions once more.


The end result...

The end result allows a seamless, lag-free replication of both Enemy AI logic and Gameplay Mechanics.

What I learned....


The goal of this exercise was to teach myself Unreal Engine Network replication, and that much was achieved. This project gave me a solid understanding of Unreal Engine's Network replication framework.


Future Plans

The experience of this project can be easily transferred over to a number of Unreal Engine projects, and I plan on using this knowledge to create a dedicated Unreal Engine Multiplayer game.


 
 
 

Comments


bottom of page