On Xbox: Is seamless drop IN/OUT of local players (couch coop) supported? Does it work on online multiplayer games too?

We are trying to support drop in drop out local players (couch coop) in an online multiplayer game… meaning… while connected to a dedicated server we want to be able to remove local (couch coop) players from the game seamlessly. By this I mean being able to add or remove a local player while connected to a server without interrupting gameplay.

I’ve been somewhat successful in removing the local player at the tail of GameInstance::LocalPlayers but when attempting to remove the PrimaryPlayer (the player at index 0) things don’t behave as expected; the indices shift

Upon further investigation: LocalUserNum, LocalUserIndex, ControllerId, FPlatformUserId and XUserHandle have a somewhat shared meaning but not quite and may change when a player is removed:
These are the classes Ive been investigating while attempting to support this feature

  • LocalPlayer
  • PlayerController
  • GameInstance
  • GDKPlatformInputDeviceMapper
  • GDKGameInputInterface
  • GDKUserManager
  • OnlineSubsystemIdentityInterfaceGDK
  • OnlineEngineInterface

Our players are signed in onto multiple OSSes each of which identify the local player through a map like: TMap<LocalUserNum:int32, UniqueId:FUniqueNetIdOSS>
The LocalUserNum is generally derived from PlayerController->GetControllerId() which is assigned upon construction of the ULocalUser on UGameInstance::CreateLocalPlayer.
Here are the primary player creation relevant functions:

// GenericPlatformMisc.cpp
FPlatformUserId FGenericPlatformInputDeviceMapper::GetPrimaryPlatformUser() const
{
	// Most platforms will want the primary user to be 0
	static const FPlatformUserId PrimaryPlatformUser = FPlatformUserId::CreateFromInternalId(0);
	return PrimaryPlatformUser;
}

// GameInstance.cpp
ULocalPlayer* UGameInstance::CreateInitialPlayer(FString& OutError)
{
	return CreateLocalPlayer(IPlatformInputDeviceMapper::Get().GetPrimaryPlatformUser(), OutError, false);
}

// GameInstance.cpp
ULocalPlayer* UGameInstance::CreateLocalPlayer(int32 ControllerId, FString& OutError, bool bSpawnPlayerController)
{
	// A compatibility call that will map the old int32 ControllerId to the new platform user
	FPlatformUserId UserId = FGenericPlatformMisc::GetPlatformUserForUserIndex(ControllerId);
	FInputDeviceId DummyInputDevice = INPUTDEVICEID_NONE;
	IPlatformInputDeviceMapper::Get().RemapControllerIdToPlatformUserAndDevice(ControllerId, UserId, DummyInputDevice);
	return CreateLocalPlayer(UserId, OutError, bSpawnPlayerController);
}

On Xbox the secondary players are added on powerON and/or whenever the application receives a button press from an unmapped user device. I use the account picker to trigger the mapping on the platform and things work like a charm.

Note that these events are issued by the GDKPlatformInputDeviceMapper which listens to a gdk library event directly and maps a FPlatformUserId to a DeviceId for the purposes of triggering events but it cannot (should not) change the user-device mapping as that is GDKs responsibility

NOW… back to the problem: Logging out/removing players (sorry for the long preamble)

Removing a player at index 0 makes LocalUserNum no longer match LocalUserIndex as it has shifted.
With that: consider the following function (vanilla Engine function)

FUniqueNetIdWrapper UOnlineServicesEngineInterfaceImpl::GetUniquePlayerIdWrapper(UWorld* World, int32 LocalUserNum, FName Type)
{
	...
	UE::Online::FAuthGetLocalOnlineUserByPlatformUserId::Params GetAccountParams = { FPlatformMisc::GetPlatformUserForUserIndex(LocalUserNum) };
	...
	return FUniqueNetIdWrapper();
}

This function receives a LocalUserNum as a parameter but is used as an argument for a function that expects an index parameter
This is not the only case Ive found like this. There are cases where FPlatformUserId::GetInternal() is used as LocalPlayerIndex like

// GenericPlatformMisc.cpp
int32 FGenericPlatformMisc::GetUserIndexForPlatformUser(FPlatformUserId PlatformUser)
{
	return PlatformUser.GetInternalId();
}

Is this supported?
What would I need to do to support seamless drop IN/OUT of local users on an online multiplayer game?

Is this a user-hosted game? If so, the primary player is the host, and removing them, will remove the game server.
If you run the server yourself (dedicated server), and make every player join remotely, then this is not a problem.
Migrating host-ness is “possible” but not “easily built in.” I’ve never done it myself, and it looks like a fair bit of work, but not impossible.

Thanks for the reply @jwatte.

This is not a peer to peer game; the players connect to dedicated servers.

From the looks of it the work implies fixing the uses of LocalUserNum, LocalUserIndex and whatnot. Ideally; all (or most) of these should be changed to use FPlatformUniqueId

I think you are missunderstanding the problem. The issue is not hosting the game simulation but how the systems uniquely identify the LocalPlayers; exclusively a client issue.

The various systems (attempt to) uniquely identify local players in different ways:

  • By int32:Index in the GameInstance::LocalPlayers
  • By int32:ControllerId (see ULocalPlayer::ControllerId and APlayerController::GetControllerId())
  • By FUniqueNetId (see any and all OnlineSubsystems, et all)
  • By FPlatformUniqueId (see APlayerController, IPlatformInputDeviceMapper, et all)

With that distinction some systems work correctly when a local player is removed but others dont.
Even more so in some cases you can find invalid translations like:

int32 UserIndex = PlatformUserId.GetInternalId();
int32 LocalUserNum = GetLocalUserNumFromPlatformUserId(GetPlatformUserIdFromUniqueNetId(*UniqueNetId));

With that, once the indices are invalidated, a lot of modules on the engine fail to properly identify the remaining local users.

Oh, that’s terrible! As far as I know, you’re supposed to only ever use those looked-up values within a frame, and always go back to UniqueId when there’s been a tick …

It’s likely that “split screen multi-player” hasn’t been tested together with “online multiplayer” in any Epic game, and thus these kinds of bugs have been allowed to replicate …

Although, grepping through the 5.2 code, I can’t find that particular use of GetLocalUserNumFromPlatformUserId() – is this in your own system?
Or XDK code? (been a long while since I’ve had access to that now …)

that particular one is found on OnlineIdentityInterfacePlayFab.cpp

Today I found a post on UDN that clarifies this problem: Xbox One Controller ID and User ID clarifications (unrealengine.com)

And also: Trouble handling controller connection/disconnection in game (unrealengine.com)
which amongst its replies suggests some solutions like reusing player zero and resetting its ControllerId to -1 and PlatfomUserId to null (-1 too)

Im yet to try those suggestions.

Do you have a fix? I’m not have access to udn…