PlayerController confusion (multiplayer)

In a multiplayer game the PlayerController exists on the server and client (but not other clients). I am a little confused on the interaction between these 2 instances of the PlayerController.

Let’s say the player is in playing state and dies and we call DetachFromControllerPendingDestroy(). This will cause the player to go to inactive state and BeginInactiveState() will be called which sets a timer to call UnFreeze().

So both the client and server instances of PlayerController will call BeginInactiveState() and both have their own copy of TimerHandle_UnFreeze. So both instances (server and client) will set their respective timers and both will call UnFreeze(). Both instances represent the same PlayerController but one exists on the server and one exists on the client. As a side note, should DetachFromControllerPendingDestroy() only be called on the server or should it be called on both the client and the server?

What if we want to call ChangeState() to change the player’s state? Does this need to be called on the server instance of the PlayerController only? What effect will calling ChangeState() on the client PlayerController have?

Let’s say we created a bool bRespawnOnDeath which we set to determine if we want the player to respawn automatically after dying. So in our UnFreeze() we might have:


void AMyPlayerController::UnFreeze()
{
if(bRespawnOnDeath)
{
ServerRestartPlayer();
}
}

But UnFreeze() gets called on both the client and the server. So ServerRestartPlayer() would get called on both the client and server instances of the PlayerController? What if we wanted to change the bool bRespawnOnDeath during gameplay? Would we have to make this bool replicated and then have the server set this value?

For example:



// In AMyPlayerController class 
UFUNCTION(Server, Reliable, WithValidation)
void SetRespawnOnDeath();


If so, would it be better to put this bool in the PlayerState instead of the PlayerController (just like the bool bIsSpectating)?

What about what happens to a PlayerController during a seamless travel? I did some testing and it looks like a new PlayerController is created for each player. But the documentation says that all PlayerControllers (server only) and all local PlayerControllers (server and client) will persist automatically during a seamless travel. So they persist but then are replaced by new PlayerController instances once the travel is complete?

Sometimes my UnFreeze() gets called on a stale PlayerContoller after the seamless travel. The traveling playing switches to inactive state which calls BeginInactiveState() which sets the unfreeze timer and then a seamless travel happens. After the seamless travel, the player has a new instance of the PlayerController but the timer has already been set on the old one. So UnFreeze() on the now stale PlayerController is called which leads to undesirable behavior.

AFAIK, there is no way to stop BeginInactiveState() from setting the freeze timer unless you override BeginInactiveState() without calling Super::BeginInactiveState()? I don’t want my UnFreeze() code to be called on the stale PlayerController after a seamless travel. Do I have to manually clear the timer on both the server and the client before the seamless travel occurs to prevent this from happening?

This has been one of the most confusing parts of learning Unreal for me anyway. If anyone has time to answer all my questions and give a lot of examples in action I would appreciate it… I feel that there is not enough documentation on this part of the system and the interaction between the server/client instances of the PlayerController.

I also spent a lot of time looking at the strange behavior with Unfreeze so the final solution was to remove it completly. As you said, override anything that sets up the timer and make your own system. I moved everything that has to do with player restart to the GameMode.

ChangeState changes the state locally only, the usual flow is that the state is changed on the server and then manually calls ClientGotoState, which calls ChangeState on the owning client. Personally I like the design that UnFreeze, BeginInactiveState, EndInactiveState etc. are all called on the server and owning client. If you want to do something in there only on the client or server simply do a IsLocallyControlled() or IsNetMode(NM_Client) check. About respawning, it depends on your game design. It would be nicer to avoid unnecessarily replicated function calls or variables, if your game requires the client to press a button for example to respawn you should call a server RPC from the client. If you can press a button to ready up and automatically respawn when the redeploy timer is up (which is I assume what you are trying to do) that would still be nicer and more reliable to give the client authority over. If you would always respawn on the UnFreeze timer it would be easier not to call an RPC and just have the server respawn you.

I haven’t had to do much with SeamlessTravel, but I assume there must be some flag set on the old player state that lets you test if it is a stale player controller, so just check for that in your UnFreeze function. The old player controller has to be destroyed at some point though right? As soon as Destroy() gets called it should be marked as pending kill and no callbacks get executed anymore.

When it comes to checking the stale PlayerController, I found this in the documentation:

So I wonder if it’s enough to check in UnFreeze if it’s not null like this:


void AMyPlayerController::UnFreeze()
{
  if(PendingSwapConnection)
    return;

}

But PlayerState also has a bool bFromPreviousLevel:

So I wonder if it would work to check this in UnFreeze():


void AMyPlayerController::UnFreeze()
{
  if(PlayerState && PlayerState->bFromPreviousLevel)
    return;
}

I wonder which would be better for checking for the “stale” player controller after a seamless travel.

I think check PendingSwapConnection is the right way to go, here is a snipped of code how Epic does it:



bool APlayerController::CanRestartPlayer()
{
    return PlayerState && !PlayerState->bOnlySpectator && HasClientLoadedCurrentWorld() && PendingSwapConnection == NULL;
}


I would suggest calling CanRestartPlayer since that’s probably your condition to restart anyways.