Ever wondered how you can make a Spectating System and then immediately panicked? Well guess you don’t have to panic anymore, as it’s easier than you thought! While in theory there could be several ways to implement this system, I’m going to walk you through the engine way of doing it!
Introduction
The Spectating System lets some type of players spectate some other type of players. Spectators are usually dead players, and those Spectated are alive players. While it has many benefits, the most prominent is that it makes dead players view the game flow so they are busy with something till they respawn.
Spectator Pawn
The SpectatorPawn class is a subclass of DefaultPawn. Through a GameMode, different classes can be specified as defaults for Pawn and SpectatorPawn, and this class provides a simple framework ideal for spectating functionality. We will use it mainly to setup the spectating input.
The header file of our custom subclass of SpectatorPawn class will have the following function declarations:
The source file will have their implementations:
Note that both ServerViewNextPlayer
and ServerViewPrevPlayer
are part of the source code, and they are two core functions of the built in Spectating System.
The ActionMappings ViewNext
and ViewPrev
were configured using the old Input System (the new one is called Enhanced Input System).
Where does This Class Exist?
To answer this question we have to look at how the SpectatorPawn is spawned, and that is done via APlayerController::SpawnSpectatorPawn()
.
We can easily tell that it exists only at the local PlayerController level, i.e server-side for the listen-server player in case of a listen-server setup, and client-side for each other player in either one of the setups (listen or dedicated server), and that it’s possessed by the local PlayerController.
This means that we can’t replicate properties or route meaningful RPCs through the SpectatorPawn class. Though considering the fact that it’s possessed by the PlayerController class, we can retrieve and use the latter for such tasks as we’ve already seen it above in ViewNextPlayer() & ViewPrevPlayer()
class methods.
Note: Originally, this what was written: SpawnedSpectator->SetReplicates(false); // Client-side only
. I omitted the comment as it was totally misleading!
As we said above, that’s not the case for a listen-server player, in which his SpectatorPawn is server-side only.
Creating The HUD Infrastructure
As of nowadays terms this class is considered a legacy, though it’s still a perfect place to manage all UMG widgets.
It only exists on client, so it’s not suitable for all kinds of replication. It is owned and easily accessible by the PlayerController.
We will use it to define an EHUDState
enum class to make the HUD display different UMG widgets depending on the player’s gameplay state.
System Core
Most, if not all of the spectating functionality can be found in APlayerController
class.
Let’s subclass it first, then declare, override and define our methods accordingly:
As can be seen above, SetPlayerSpectate
is the instigator function for spectating. While there are many useful stuff in there, I want to focus on the interesting APlayerController::ChangeState()
& APlayerController::ClientGotoState()
.
Gameplay States
If we take a look at UnrealNames.inl
file, we find all the hardcoded names that Unreal defines. Part of the hardcoded names defined there are the following:
Those are gameplay states that are meant to be set into the following property:
As can be seen, this property isn’t replicated, so we need to set it separately on server and client version of the controller.
Luckily, that can be done with the handy methods ChangeState()
& ClientGotoState()
.
Note: All state names need to be defined in name table in UnrealNames.h
to be correctly replicated (so they are mapped to the same thing on client and server).
How does SpectatorPawn Spawn After All?
Whenever ChangeState(NAME_Spectating)
is called on server, it calls APlayerController::BeginSpectatingState()
which makes the PlayerController unpossess (server only) the player pawn. When ClientGotoState(NAME_Spectating)
is called on server, ChangeState(NAME_Spectating)
is called on client, which again calls APlayerController::BeginSpectatingState()
which destroys any previously spawned SpectatorPawn, spawns a new one, and set it as the current one in use.
Finally I Can Spectate?
Congrats if you’ve made it this far, cause you finally have a working basic Spectating System:
However, like every other system out there, it has a handful of bugs waiting to be fixed.
Fixing Bugs
1. Clients Can’t Spectate
Some of you might notice this weird behavior where the client isn’t able to spectate. If you’re playing as a listen-server then you’ll notice that the server player, aka the host, is able though!
Ensure APlayerCameraManager.bClientSimulatingViewTarget
is set to false (it’s defaulted to false), otherwise clients are handling setting their own ViewTarget
and the server should not replicate it. We definitely don’t want such behavior, so let’s set this flag to false. To enforce this, set it in your custom PlayerCameraManager’s class constructor, and make sure it’s set to false in the subclassed BP PlayerCameraManager class in case such one exists.
2. SpectatorPawn not Spawning at Death Location
While it’s up to you to decide when spectating takes place (usually on player pawn death), you’ll notice that the SpectatorPawn is spawned at the player pawn APlayerController.SpawnLocation
:
The default behavior can be seen in the method APlayerController::SpawnSpectatorPawn()
at the following line:
Instead of spawning the SpectatorPawn at SpawnLocation
, which is the spawn location of our pawn, we want to spawn it at PlayerCameraManager->GetCameraLocation()
, which is the current camera location, or in the other words the player pawn death location.
Before I discuss how I fix the issue, let’s look at the this handy function first:
As its documentation suggests, I will be calling the function above as we enter NAME_Spectating
state:
While some might override APlayerController::SpawnSpectatorPawn()
, I chose to override APlayerController:BeginSpectatingState()
, so the death location is always stored in the properties it was meant to be stored, making the system persistent, and also I so don’t clutter my custom PlayerController class.
3. Spectated Character Movement Looks Jittery
While this issue might not be noticeable in simple movement modes, i.e., walking or running, it will be a real hassle for other more complex movement modes.
Before we discuss the solution I found, thanks to KaosSpectrum, let’s discuss the reasoning behind the problem.
Problem is CameraComponent (or, more specifically SpringArmComponent) is attached to the CapsuleComponent inside your custom Character class. The way CharacterMovementComponent works is, the capsule is always teleported, which isn’t smooth, and that’s why you see a jump every time the server sends a new capsule location in. The skeletal mesh is what actually is smoothed, and interpolated client-side. Thus, CameraComponent (or, more specifically SpringArmComponent) needs to be attached to the SkeletalMeshComponent instead. Yep, as simple as that!
4. Simulated Proxies Invisible to Spectator due to Net Relevancy
While you might not experience this bug at first glance, due to testing on small scale maps, you’ll notice it quite fast when testing on larger scale maps. Simulated proxies won’t render on spectator’s screen as the simulated proxy actor isn’t relevant for the SpectatorPawn. For example, if the simulated proxy is a weapon actor, then the corresponding function that decides what’s relevant and what’s not for such a generic actor is the following:
By looking at the code you can tell that setting bAlwaysRelevant
to true would be a quite easy fix. In my opinion, this should be the way to go for most actors in small scale maps (maps whom maximum squared distance between two arbitrary actors is less than NetCullDistanceSquared
), as actors are generally always relevant to one another, and having them set that way will eliminate the need to check the other conditions including the distance check (this is a server performance killer for higher player counts) at the end, as bAlwaysRelevant
is checked first. On the other hand, for large scale maps making every actor bAlwaysRelevant
is just rubbing salt in the wound, as no matter how actors are away from each other, they are always relevant, and that’s a huge burden on the server. For that we got the last condition, the net relevancy distance. It’s the same condition that is causing this bug, as the SrcLocation
is the viewing location, i.e the location of the SpectatorPawn, which we’ve seen it before as PlayerController.SpawnLocation
, and we changed it to be at the player pawn death location. Instead of testing against SrcLocation
, we want to test against a location closer to the actor in question (a weapon in our example), and that could be the location of the view target. Therefore, we’ll override the function above and change the last condition to the following:
Note: Pawn and PlayerController classes override AActor::IsNetRelevantFor()
and have different conditions for relevancy as a result.
Thus if the simulated proxy in question is a player pawn, you would need to look into APawn::IsNetRelevantFor()
, which also has the two conditions we discussed above, so the solution wouldn’t be any different but to override this function this time.
Note: IsNetRelevantFor()
is called once (at an interval) on every actor for each connection. Not on every actor for every other actor.
Note: Attaching the SpectatorPawn to the spectated pawn to cure this issue won’t help (unless it’s done to the listen-server player), as the SpectatorPawn exists locally, which is generally client-side. The server should be aware of the changing location of the SpectatorPawn to compute relevancy.
5. Auto Spectate not Working Properly
You may have noticed earlier that dead players default to a static freecam mode, and on a key press they begin spectating alive players. Now what if you wanted that to happen automatically, i.e., camera moves automatically to an alive player without the need to press any key. At which point, you will find yourself running into a brick wall. A failing try could be trying to edit the function that initiates spectating:
ViewAPlayer(1);
was added, and here what this function does:
It sets the player’s view target based on the specified direction (+1
is right/next, while -1
is left/previous), exactly like we want. However, you’ll realize that function call doesn’t yield any real result, meaning the player is still stuck in a static freecam mode. Other better, but still unreliable try is to override APlayerController::BeginSpectatingState()
to have it call APlayerController::ServerViewNextPlayer()
.
The reason for such behavior is that we have two roughly parallel execution paths. When a player dies, server will call AController::UnPosses()
, either manually or indirectly by calling AMyPlayerController::SetPlayerSpectate()
:
/// Call stack ///
APlayerController::SetPawn(NULL)
AController::OnUnPossess()
AController::UnPosses()
APlayerController::BeginSpectatingState()
AMyPlayerController::BeginSpectatingState()
APlayerController::ChangeState()
AMyPlayerController::SetPlayerSpectate() // first call
By looking into AController::SetPawn(NULL)
you will quickly figure out that it keeps the camera at death location. But that shouldn’t be an issue, considering ViewAPlayer(1)
runs at a later point in time, right? Clearly not.
The other parallel (usually slower) execution path is being run by the client, after AController.Pawn
is repped down from the server (set inside AController::SetPawn()
), triggering AController::OnRep_Pawn()
which calls APlayerController::SetPawn(NULL)
, which again keeps the camera at death location.
The proper easy fix is to override APlayerController::OnRep_Pawn()
and trigger spectating after the fact:
Note that we make sure we are in a spectating state, as that function can run in other scenarios. Also, we fire a server RPC as OnReps fire on client.
6. ADS not Replicating to Spectator’s ViewTarget
Coming soon
System Seems Basic?
While the system is working fine, it may feel for some of you pretty basic. Making it more sophisticated is a matter of choice, and I will leave it for you to do so. Though, I would want to point you to some useful classes/methods that might interest you down the line:
- While we used some quite useful game framework classes, we still haven’t looked into a not less useful one. Considering that this system is heavily camera-based, we have to look into
APlayerCameraManager
class. You might find there some useful functions you want to use/override, like:AssignViewTarget()
andCheckViewTarget()
. -
APlayerController::GetActorEyesViewPoint(FVector& Location, FRotator& Rotation)
. -
APlayerController::ServerViewSelf()
. -
APlayerController::ViewAPlayer(int32 dir)
. -
APlayerController::GetNextViewablePlayer(int32 dir)
: The way it works, it lets you view alive players only. Override it if you want to be able to spectate dead players corpses for example. -
AGameModeBase::CanSpectate_Implementation(APlayerController* Viewer, APlayerState* ViewTarget)
: Controls if playerViewer
can spectate playerViewTarget
. This can be so useful if you want to dictate a different spectating functionality for different GameModes.