9 minute read

In this article we’ll learn how to implement a killcam.

Introduction

Instant replays (or Killcams) can help the player understand how their character died during a game. They can be very useful in competitive scenarios to visualize the position of the instigator at the moment of your death, so they will play an important role in your game design if you decide to bring them in.

I’ve always wondered how to implement them properly in Unreal Engine, however, the DemoNetDriver documentation was scarce and duplicating the whole UWorld, like Unreal Tournament did, never convinced me. For that reason I’ve put this on hold for many years until the present day.

A year ago (at the time of writing), a tutorial written by Alex Koumandarakis surfaced in the Unreal Engine forums presenting a Sample Code for Implementing Instant Replays accompanied by a small description. The tutorial explains how they approached this problem in Paragon, and the technique employed to avoid duplicating the UWorld. This piece of information and code has been critical to develop this article, and for that reason, I highly recommend everyone to read Alex’s post before proceeding here.

Prerequisites

To follow this tutorial you’ll need the following resources:

  • Lyra (at least 5.2) (optional): Download through the Epic Games launcher. Lyra is optional, but this article employs it to implement the feature.
  • Killcam sample code by Epic (download here). Add the header and the cpp file to your project. Ensure you change the Pawn and PlayerState references you’ll find in the source code so it compiles.
  • Your IDE, like Visual Studio and… some patience.

With all the materials ready, we can start the implementation!

Note: The implementation provided in this article only supports Dedicated Servers and isn’t production ready. The provided code isn’t totally reliable given the current state of Game Features (UE 5.2), as they require some engine extra work to differ between the replay and game world; I expect future versions of the engine to improve this aspect, since to date, it is required to do some overengineering to control their execution scope. However, if you don’t use Game Features, most of the concepts provided in this article will work out of the box.

Implementation

Duplicating levels

If you’ve gone through Alex’s article, you’ve learned that we are going to use level duplication to achieve our instant replays. For that, we have to override UEngine::Experimental_ShouldPreDuplicateMap to return true for the appropriate levels.

bool ULyraGameEngine::Experimental_ShouldPreDuplicateMap(const FName MapName) const
{
	// Right now enabled for all our levels, but you can make custom logic using the MapName
	return true;
}

Note however, that this feature is still experimental and enabling it comes with a risk.

Adding the Killcam manager in the Local Player

A good place to put our instant replay manager is in the ULocalPlayer, as replays are totally client sided. To do that, we can use Lyra’s ULyraLocalPlayer.

public:

	UFUNCTION()
	UKillcamPlayback* GetKillcamPlaybackManager() const { return KillcamPlayback; }

 protected:
 	
	UPROPERTY()
	TObjectPtr<UKillcamPlayback> KillcamPlayback;

Which we can create in Lyra’s Local Player constructor:

ULyraLocalPlayer::ULyraLocalPlayer()
{
	KillcamPlayback = CreateDefaultSubobject<UKillcamPlayback>(TEXT("KillcamPlayback"));
}

Record instant replay

The next step is to start recording in every respawn, we can do that by hooking locally to the OnPossessedPawnChanged delegate in ALyraPlayerController. Since it will get called every time we posses a different Pawn:

void ALyraPlayerController::BeginPlay()
{
	...

	// Listen for pawn possession changed events
	if (IsLocalController())
	{
		OnPossessedPawnChanged.AddDynamic(this, &ThisClass::PossessedPawnChanged);
		if (APawn* ControlledPawn = GetPawn())
		{
			PossessedPawnChanged(nullptr, ControlledPawn);
		}
	}
}

void ALyraPlayerController::PossessedPawnChanged(APawn* PreviousPawn, APawn* NewPawn)
{
	if (NewPawn)
	{
		Cast<ULyraLocalPlayer>(Player)->GetKillcamPlaybackManager()->SetUpKillcam(GetWorld());
	}
}

The in-memory killcam replay representation can free data that’s not needed to rewind beyond CVarKillcamBufferTimeInSeconds. This means that every time we call SetUpKillcam, we’ll fill the replay buffer up to CVarKillcamBufferTimeInSeconds seconds and it will advance in a circular buffer manner. This value shall be adjusted accordingly given the Local Player-end memory budget determined for your project.

Overriding SetPawn won’t yield the same results, as SetPawn doesn’t provide a mechanism to differ against the previous Pawn.

Play and stop instant replay

In order to stop recording and play the rewound recording we have to call KillcamStart in UKillcamPlayback, but for that, we have to determine first what will trigger our local Instant Replays. In this specific case, we’ll play the KillCam after our Pawn dies. For that, we can extend ULyraHealthComponent and ALyraPlayerController:

void ULyraHealthComponent::HandleOutOfHealth(AActor* DamageInstigator, AActor* DamageCauser, const FGameplayEffectSpec& DamageEffectSpec, float DamageMagnitude)
{
 	...
	ClientHandleOutOfHealth(DamageInstigator, DamageCauser);
}

void ULyraHealthComponent::ClientHandleOutOfHealth_Implementation(const AActor* const DamageInstigator, const AActor* const DamageCauser)
{
	AActor* Owner = GetOwner();
	check(Owner);
	
	if(ALyraPlayerController* PlayerController = Cast<ALyraPlayerController>(Owner->GetOwner()))
	{
		PlayerController->StartKillcam(Cast<APlayerState>(DamageInstigator), DamageCauser);
	}	
}

void ALyraPlayerController::StartKillcam(const APlayerState* const DamageInstigator, const AActor* const DamageCauser)
{
	if (ULyraLocalPlayer* LocalPlayer = Cast<ULyraLocalPlayer>(Player))
	{
		LocalPlayer->GetKillcamPlaybackManager()->OnLocalHeroDeath(GetPlayerState<APlayerState>(), GetPawn(), DamageInstigator, DamageCauser);
		LocalPlayer->GetKillcamPlaybackManager()->KillcamStart(FOnKillcamStartComplete());
		// We use a timer to determine when to stop the killcam
		GetWorld()->GetTimerManager().SetTimer(FinishKillCamTimerHandle, this, &ThisClass::FinishKillcam, KillcamDuration, false);
	}
}

void ALyraPlayerController::FinishKillcam()
{
	if (ULyraLocalPlayer* LocalPlayer = Cast<ULyraLocalPlayer>(Player))
	{
		GetWorld()->GetTimerManager().ClearTimer(FinishKillCamTimerHandle);
		LocalPlayer->GetKillcamPlaybackManager()->KillcamStop(FOnKillcamStopComplete());
		// Tell the server our killcam stopped
		ServerRequestKillcamStop();
	}
}

void ALyraPlayerController::ServerRequestKillcamStop_Implementation()
{
	unimplemented();
}

ClientHandleOutOfHealth is a Reliable Client RPC that triggers the KillCam replay by calling StartKillcam in ALyraPlayerController. The timer inside StartKillcam stops the instant replay by calling FinishKillcam, which also notifies the server that the killcam stopped. FinishKillcam can also be mapped to an input action to skip the instant replay.

ServerRequestKillcamStop can be used to notify the server that we are ready to respawn, however, to simplify our setup, we will rely on the RespawnDelayDuration variable defined in the GA_AutoRespawn Blueprint Ability.

For preview purposes, initialize these variables to the following values:

  • RespawnDelayDuration: 10.f - This variable is used in GA_AutoRespawn to determine the time between the character death and its respawn through RequestPlayerRestartNextFrame.
  • KillcamDuration: 8.f - This variable is employed in ALyraPlayerController::StartKillcam and determines the display time of the instant replay.

If everything is setup corrently, after our death, we should see the instant replay for 8 seconds while the respawn is in coldown (10 seconds).

Experiences and Game Features

If you are using Lyra to implement the Killcam, you have to deal with Game Features and their execution scope, which isn’t trivially simple…

First, we have to prevent the Experience to load in the replay world, to do that we need to skip the code called in OnExperienceLoadComplete. We also need to do the same for unloading part in EndPlay. The best way I found for this is by checking against the DynamicDuplicatedLevels collection, however note that there might be better ways to do this, if you find out a better one, please let me know!

void ULyraExperienceManagerComponent::OnExperienceLoadComplete()
{
	FLevelCollection* const DuplicateCollection = GetWorld()->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);
	// Nothing to do here if the DuplicateCollection exists and is visible
	if (DuplicateCollection && DuplicateCollection->IsVisible())
	{
		OnExperienceFullLoadCompleted();
		return;
	}
	...
}

void ULyraExperienceManagerComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	Super::EndPlay(EndPlayReason);

	FLevelCollection* const DuplicateCollection = GetWorld()->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);
	// Nothing to do here if the DuplicateCollection exists
	if (DuplicateCollection)
	{
		return;
	}
	...
}

Besides culling the feature activation, we might find other issues, like animations not playing after the first death cam. However, I’m going to leave those issues to the reader as it goes a bit beyond the scope of the article.

Setting up the ViewTarget

If we run what we have programmed so far, we will notice that the camera stays in our death position.

Instant replay without changing ViewTarget

This is because our ViewTarget is still our Character, as it only gets destroyed when the Delay RespawnDelayDuration expires. In my implementation, I’d like to set the ViewTarget to the Pawn that killed us, so we can see how we got killed. To do that, we can extend ULyraCameraComponent and UKillcamPlayback:

void ULyraCameraComponent::PushCameraMode(TSubclassOf<ULyraCameraMode> CameraModeClass)
{
	check(CameraModeStack);

	if (CameraModeStack->IsStackActivate())
	{
		CameraModeStack->PushCameraMode(CameraModeClass);
	}
}

void UKillcamPlayback::ShowKillcamToUser_Internal(FOnKillcamStartComplete StartCompleteDelegate)
{
	if (SourceWorld.IsValid())
	{
		FLevelCollection* const SourceCollection = SourceWorld->FindCollectionByType(ELevelCollectionType::DynamicSourceLevels);
		FLevelCollection* const DuplicatedCollection = SourceWorld->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);
		
		if (SourceCollection && DuplicatedCollection)
		{
			SetViewTargetToKillingActor();
			...
}

void UKillcamPlayback::SetViewTargetToKillingActor()
{
	const UDemoNetDriver* const DemoNetDriver = GetPlaybackDemoNetDriver();
	if (DemoNetDriver)
	{
		if (AActor* FocusActor = DemoNetDriver->GetActorForGUID(GetCachedKillingActorGUID()))
		{
			if (ensure(ALyraCharacter* KillingCharacter = Cast<ALyraCharacter>(FocusActor)))
			{
				if (ULyraPawnExtensionComponent* PawnExtComp = ULyraPawnExtensionComponent::FindPawnExtensionComponent(KillingCharacter))
				{
					if (const ULyraPawnData* PawnData = PawnExtComp->GetPawnData<ULyraPawnData>())
					{
						ULocalPlayer* LocalPlayer = Cast<ULocalPlayer>(GetOuter());
						APlayerController* PlayerController = LocalPlayer->GetPlayerController(SourceWorld.Get());
						PlayerController->SetViewTarget(KillingCharacter);
						KillingCharacter->GetCameraComponent()->PushCameraMode(PawnData->DefaultCameraMode);
					}
				}
			}
		}
	}
}

In the previous snippet we are assigning the ViewTarget to the killing Actor from the replay world, this is done through DemoNetDriver->GetActorForGUID(GetCachedKillingActorGUID()) within SetViewTargetToKillingActor. However, due to how the Camera Manager of Lyra works, we have also to set the proper Camera Mode, for this we will reuse the DefaultCameraMode contained in PawnData (a third person camera). Note that I created an accesor to the Camera Component (GetCameraComponent) to set the Camera Mode.

Interpolating the KillCam

If we implemented everything as stated in the article, we will notice that the KillCam is jittery. This is because of the recording rate and update frequency of the replay Actors, to solve this we will modify Lyra’s Third Person Camera mode:

void ULyraCameraMode_ThirdPerson::UpdateView(float DeltaTime)
{
	UpdateForTarget(DeltaTime);
	UpdateCrouchOffset(DeltaTime);

	AActor* TargetActor = GetTargetActor();
	APawn* TargetPawn = Cast<APawn>(TargetActor);
	AController* TargetController = TargetPawn ? TargetPawn->GetController() : nullptr;

	CurrentPivotLocation = FMath::VInterpTo(CurrentPivotLocation, GetPivotLocation(), DeltaTime, 20.f);
	CurrentPivotRotation = FMath::RInterpTo(CurrentPivotRotation, GetPivotRotation(), DeltaTime, 20.f);

	FVector PivotLocation = (!TargetController ? CurrentPivotLocation : GetPivotLocation()) + CurrentCrouchOffset;
	FRotator PivotRotation = (!TargetController ? CurrentPivotRotation : GetPivotRotation());

	PivotRotation.Pitch = FMath::ClampAngle(PivotRotation.Pitch, ViewPitchMin, ViewPitchMax);

	View.Location = PivotLocation;
	View.Rotation = PivotRotation;
	View.ControlRotation = View.Rotation;
	View.FieldOfView = FieldOfView;

	// Apply third person offset using pitch.
	if (!bUseRuntimeFloatCurves)
	{
		if (TargetOffsetCurve)
		{
			const FVector TargetOffset = TargetOffsetCurve->GetVectorValue(PivotRotation.Pitch);
			View.Location = PivotLocation + PivotRotation.RotateVector(TargetOffset);
		}
	}
	else
	{
		FVector TargetOffset(0.0f);

		TargetOffset.X = TargetOffsetX.GetRichCurveConst()->Eval(PivotRotation.Pitch);
		TargetOffset.Y = TargetOffsetY.GetRichCurveConst()->Eval(PivotRotation.Pitch);
		TargetOffset.Z = TargetOffsetZ.GetRichCurveConst()->Eval(PivotRotation.Pitch);

		View.Location = PivotLocation + PivotRotation.RotateVector(TargetOffset);
	}

	// Adjust final desired camera location to prevent any penetration
	UpdatePreventPenetration(DeltaTime);
}

FRotator ULyraCameraMode_ThirdPerson::GetPivotRotation() const
{
	AActor* TargetActor = GetTargetActor();
	APawn* TargetPawn = Cast<APawn>(TargetActor);
	AController* TargetController = TargetPawn ? TargetPawn->GetController() : nullptr;
	if (TargetController)
	{
		return Super::GetPivotRotation();
	}
	else
	{
		return TargetPawn->GetBaseAimRotation();
	}
}

With this change we fix the jitter by interpolating rotation and location snapshots. Note that for the replay rotation we use GetBaseAimRotation() as the TargetPawn has no Controller.

Final results

If you did everything accordingly you should be seeing something like this:

Final result

Note that at this point it’s just a matter to tweak the provided implementation to instantiate and remove the Instant Replay at the desired moment. Take also a look at all the available parameters for the rewinding in UKillcamPlayback.

Conclusion

Today we learned how to integrate Instant Replays in Lyra.

Note that this toy example uses a very simple setup for preview purposes and could be drastically improved by evolving GA_AutoRespawn into a more convoluted Gameplay Ability that handles AI and Player Pawns separately to introduce Killcam instantiation and skip logic (could be another separate Ability). Also, the implementation provided doesn’t support Pawn swapping, so if the Pawn that killed you didn’t exist at the beginning of the replay rewind (because it didn’t spawn yet) your Instant Replay camera will stay in your death Pawn ViewTarget. However, I leave these challenges to the reader, I am sure that by playing with the system you will get a better understanding of how it all works.

You may run into problems (ie: ensure checks) implementing the instant replays, but I would like you to understand why they occur and why they make sense in the way Lyra is programmed. With that said, I want to warn the reader that the implementation provided in this article is not production ready, so please, don’t blindly rely on articles you find on the internet and do your own debugging and profiling.

As always, feel free to contact me (and follow me! hehe) if you find any issues with the article or have any questions about the whole topic.

Enjoy, vori.