EOS Socket Binding Issue - Root Cause Analysis & Solution¶
Problem¶
When opening a level with ?listen in PIE standalone mode, you get:
LogSocketSubsystemEOS: Error: No local user to send p2p packets with
LogNet: Error: Could not bind local address
Root Cause¶
The Login Flow¶
-
Your
OnlineManagerSubsystemperforms custom EOS login directly via EOS SDK: -
Calls
EOS_Connect_Login()directly with Device ID credentials - Stores the result in
LocalPuidmember variable -
Does NOT notify the
OnlineServicesEOSAuth interface -
The
OnlineServicesEOSsystem creates aFSocketSubsystemEOSduring initialization -
Uses
FSocketSubsystemEOSUtils_OnlineServicesEOSto get the local user ID -
This utils class queries
AuthEOS->GetLocalOnlineUserByPlatformUserId()(line 29-39 in SocketSubsystemEOSUtils_OnlineServicesEOSGS.cpp) -
When you try to create a listen server:
UNetDriverEOS::GetSocketSubsystem()gets the socket subsystem- Socket subsystem calls
Utils->GetLocalUserId()to bind the P2P socket GetLocalUserId()queriesAuthEOSinterface, which doesn't know about your custom login- Returns
nullptr - Socket binding fails with "No local user to send p2p packets with"
Solution Implemented: Manual Auth Registration¶
Since we need to keep the custom Device ID login flow (to avoid EAS popup), we manually register the logged-in user with the Auth interface's account registry.
Implementation¶
1. Register User After Login¶
After successful EOS Connect login, call RegisterUserWithAuthInterface():
void UOnlineManagerSubsystem::HandleLoginSuccess(EOS_ProductUserId InLocalPuid)
{
LocalPuid = InLocalPuid;
bIsLoggedIn = true;
// CRITICAL: Register with Auth interface so socket subsystem can find the user
RegisterUserWithAuthInterface();
}
2. Register with ALL OnlineServices Instances¶
In PIE, multiple OnlineServices instances exist (e.g., None and Context_1). We must register with all of them:
void UOnlineManagerSubsystem::RegisterUserWithAuthInterface()
{
using namespace UE::Online;
// Get ALL existing OnlineServices instances
TArray<TSharedRef<IOnlineServices>> AllInstances;
FOnlineServicesRegistry::Get().GetAllServicesInstances(AllInstances);
// Register with each Epic/EOS instance
for (const TSharedRef<IOnlineServices>& Instance : AllInstances)
{
if (Instance->GetServicesProvider() == EOnlineServices::Epic)
{
RegisterUserWithAuthInterfaceInternal(Instance);
}
}
}
3. Auto-Register with New Instances¶
Listen for new OnlineServices instances being created (e.g., during level transitions):
// In Initialize()
OnOnlineServicesCreatedHandle = UE::Online::OnOnlineServicesCreated.AddUObject(
this, &UOnlineManagerSubsystem::OnNewOnlineServicesCreated);
void UOnlineManagerSubsystem::OnNewOnlineServicesCreated(TSharedRef<IOnlineServices> NewServices)
{
if (NewServices->GetServicesProvider() == EOnlineServices::Epic && bIsLoggedIn && LocalPuid)
{
RegisterUserWithAuthInterfaceInternal(NewServices);
}
}
4. Internal Registration Logic¶
Manually create and register account info with the Auth interface:
void UOnlineManagerSubsystem::RegisterUserWithAuthInterfaceInternal(IOnlineServicesPtr OnlineServices)
{
using namespace UE::Online;
// Cast to EOS services
TSharedPtr<FOnlineServicesEOSGS> EOSServices = StaticCastSharedPtr<FOnlineServicesEOSGS>(OnlineServices);
TSharedPtr<FAuthEOSGS> AuthEOS = StaticCastSharedPtr<FAuthEOSGS>(EOSServices->GetAuthInterface());
// Get registries
FAccountInfoRegistryEOS& AccountRegistry = AuthEOS->GetAccountInfoRegistryMutable();
IOnlineAccountIdRegistryEOSGS& AccountIdRegistry = AuthEOS->GetAccountIdRegistryMutable();
// Create FAccountId from ProductUserId
FAccountId AccountId = AccountIdRegistry.FindOrAddAccountId(LocalPuid);
// Create account info
TSharedRef<FAccountInfoEOS> AccountInfo = MakeShared<FAccountInfoEOS>();
AccountInfo->ProductUserId = LocalPuid;
AccountInfo->PlatformUserId = FPlatformMisc::GetPlatformUserForUserIndex(0);
AccountInfo->LoginStatus = ELoginStatus::LoggedIn;
AccountInfo->AccountId = AccountId;
// Register in the account registry (by PlatformUserId, ProductUserId, and AccountId)
AccountRegistry.Register(AccountInfo->PlatformUserId, AccountInfo);
AccountRegistry.Register(AccountInfo->ProductUserId, AccountInfo);
AccountRegistry.Register(AccountInfo->AccountId, AccountInfo);
}
Files Modified¶
-
Source/Metal_terra/Public/Core/Subsystems/OnlineManagerSubsystem.h
-
Added
RegisterUserWithAuthInterface()declaration - Added
RegisterUserWithAuthInterfaceInternal()declaration - Added
OnNewOnlineServicesCreated()callback declaration -
Added
FDelegateHandle OnOnlineServicesCreatedHandlemember variable -
Source/Metal_terra/Private/Core/Subsystems/OnlineManagerSubsystem.cpp
-
Added includes:
Online/AuthEOSGS.h,Online/OnlineIdEOSGS.h,Online/OnlineServicesRegistry.h,Online/OnlineServicesDelegates.h - Bound to
OnOnlineServicesCreateddelegate inInitialize() - Unbound delegate in
Deinitialize() - Implemented
RegisterUserWithAuthInterface()to register with all existing instances - Implemented
RegisterUserWithAuthInterfaceInternal()with account registry manipulation -
Implemented
OnNewOnlineServicesCreated()callback for auto-registration -
Source/Metal_terra/Metal_terra.Build.cs
- Added
OnlineServicesEOSGSandOnlineServicesCommontoPublicDependencyModuleNames
Status: ✅ RESOLVED - Auth Registration Working¶
Bug Found & Fixed: TankPlayerState::OnDeactivated() crashes with assertion failure:
Assertion failed: IsValid() [File:SharedPointer.h] [Line: 1071]
ATankPlayerState::OnDeactivated() [TankPlayerState.cpp:99]
- Issue:
GetUniqueId()returns invalidFUniqueNetIdWrapperduring level transition - Fixed: Added validity check in
TankPlayerState::OnDeactivated():
if (!GetUniqueId().IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("OnDeactivated: UniqueId is not valid, skipping data save"));
return;
}
The auth registration system is now working correctly. Users are properly registered with the Auth interface, allowing the socket subsystem to find the local user for P2P connections.
Join House Flow & Multiplayer Connection¶
Auto-Join on First Login¶
After the auth registration fix, we implemented auto-join functionality that runs once after the first successful login.
Implementation:
- Flag to prevent multiple auto-joins:
bool bHasAutoJoinedOnFirstLogin = false; - Delegate binding: After EOS login succeeds in
HandleAuthLoginComplete(), bind toNakamaSubsystem->OnNakamaLoginCompletedelegate - Auto-join callback:
OnNakamaLoginCompleteInternal()checks the flag and callsCleanupAndCreateEosLobby(30)on first login - Lobby/Session creation: After lobby and session are created, automatically join them
- Host RPC call: After joining, call
house_player_joinRPC to notify Nakama backend
First-Time Launch Flow (Host):
LoginForLocalPlayer
↓
EOS Login Success → HandleAuthLoginComplete
↓
Bind to Nakama login delegate
↓
Nakama Login Success → OnNakamaLoginCompleteInternal (first time only)
↓
CleanupAndCreateEosLobby(30)
↓
HandleEosLobbyCreated (stores lobby ID in CurrentHouse.EosLobbyId)
↓
BeginEosSessionCreation
↓
Session created (stores session ID in CurrentHouse.EosSessionId)
↓
JoinEosLobby (join the lobby we created)
↓
OnEosLobbyJoinCompleted
↓
JoinEosSession (join the session we created)
↓
OnJoinEosSessionSearchCompleted (extracts host PUID, stores in PendingHostPuid)
↓
OnEosSessionJoinCompleted (detects we're host via Nakama user ID comparison)
↓
Call house_player_join RPC
↓
STOP (no ClientTravel - host is already in game)
Host PUID Extraction Fix¶
Problem: When clients joined a session, the connection path was returning self PUID instead of the host's PUID, preventing proper connection.
Root Cause: The original code was using EOS_Sessions_CopyActiveSessionHandle() and EOS_ActiveSession_CopyInfo()
AFTER joining the session. The "active session" represents the local player's session state, not the session owner's
info.
Solution: Extract the host PUID from the search result using EOS_SessionDetails_CopyInfo() on the
SessionDetailsHandle BEFORE joining.
Implementation:
- Added member variable:
FString PendingHostPuid;to store host PUID extracted from session details - Extract before joining: In
OnJoinEosSessionSearchCompleted(), after getting the session details handle from search:
// Extract host PUID from session details BEFORE joining
EOS_SessionDetails_CopyInfoOptions InfoCopyOptions = {};
InfoCopyOptions.ApiVersion = EOS_SESSIONDETAILS_COPYINFO_API_LATEST;
EOS_SessionDetails_Info* SessionDetailsInfo = nullptr;
EOS_EResult InfoResult = EOS_SessionDetails_CopyInfo(SessionDetailsHandle, &InfoCopyOptions, &SessionDetailsInfo);
// Get the host Product User ID from the session owner
EOS_ProductUserId HostProductUserId = SessionDetailsInfo->OwnerUserId;
// Store for use in join callback
PendingHostPuid = LexToString(HostProductUserId);
- Use in join callback: In
OnEosSessionJoinCompleted(), removed all active session extraction code and simply usePendingHostPuid
Non-Launch Join Flow (Client):
JoinHouse(FHouseSummary) [from Blueprint with host's lobby/session IDs]
↓
JoinEosLobby(House.EosLobbyId)
↓
OnEosLobbyJoinCompleted
↓
JoinEosSession(House.EosSessionId)
↓
OnJoinEosSessionSearchCompleted
├─ Extract HOST PUID from EOS_SessionDetails_Info->OwnerUserId
└─ Store in PendingHostPuid
↓
OnEosSessionJoinCompleted
├─ Detect we're NOT host (via Nakama user ID comparison)
├─ Use PendingHostPuid for connection string
└─ PrepareForEOSNetworking
↓
ClientTravel("EOS:[HostPuid]:GameNetDriver:0")
Lobby and Session Cleanup¶
Problem: When switching houses, need to properly clean up existing lobbies and sessions.
Solution: Before joining a new lobby/session, check if already in one and clean up appropriately:
For Lobbies:
- Host: Call
EOS_Lobby_DestroyLobby()to completely remove the lobby from EOS backend - Client: Call
EOS_Lobby_LeaveLobby()to leave the current lobby
For Sessions:
- Host: Call
EOS_Sessions_DestroySession()to completely remove the session from EOS backend - Client: Clear
PendingEosSessionId(EOS auto-leaves when joining a new session)
This ensures no orphaned lobbies/sessions are left on the EOS backend and prevents EOS_Lobby_PresenceLobbyExists
errors.
Key Insights¶
-
Multiple OnlineServices Instances in PIE:
-
Default instance:
Name=None - PIE world instance:
Name=Context_1,Context_2, etc. - Socket subsystem uses
GetWorldForOnline()to find the correct instance for the world -
Must register with the world-specific instance, not just the default
-
OnlineServices Instance Creation Timing:
-
Context_1is created during PIE initialization, BEFORE login completes -
Must register with existing instances after login AND listen for new instances
-
Global Delegate for Instance Creation:
UE::Online::OnOnlineServicesCreatedbroadcasts whenever a new instance is created- Fires from
FOnlineServicesRegistry::GetNamedServicesInstance()(line 108) - Can be used to auto-register with new instances without knowing when/where they're created