Skip to content

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

  1. Your OnlineManagerSubsystem performs custom EOS login directly via EOS SDK:

  2. Calls EOS_Connect_Login() directly with Device ID credentials

  3. Stores the result in LocalPuid member variable
  4. Does NOT notify the OnlineServicesEOS Auth interface

  5. The OnlineServicesEOS system creates a FSocketSubsystemEOS during initialization

  6. Uses FSocketSubsystemEOSUtils_OnlineServicesEOS to get the local user ID

  7. This utils class queries AuthEOS->GetLocalOnlineUserByPlatformUserId() (line 29-39 in SocketSubsystemEOSUtils_OnlineServicesEOSGS.cpp)

  8. When you try to create a listen server:

  9. UNetDriverEOS::GetSocketSubsystem() gets the socket subsystem
  10. Socket subsystem calls Utils->GetLocalUserId() to bind the P2P socket
  11. GetLocalUserId() queries AuthEOS interface, which doesn't know about your custom login
  12. Returns nullptr
  13. 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

  1. Source/Metal_terra/Public/Core/Subsystems/OnlineManagerSubsystem.h

  2. Added RegisterUserWithAuthInterface() declaration

  3. Added RegisterUserWithAuthInterfaceInternal() declaration
  4. Added OnNewOnlineServicesCreated() callback declaration
  5. Added FDelegateHandle OnOnlineServicesCreatedHandle member variable

  6. Source/Metal_terra/Private/Core/Subsystems/OnlineManagerSubsystem.cpp

  7. Added includes: Online/AuthEOSGS.h, Online/OnlineIdEOSGS.h, Online/OnlineServicesRegistry.h, Online/OnlineServicesDelegates.h

  8. Bound to OnOnlineServicesCreated delegate in Initialize()
  9. Unbound delegate in Deinitialize()
  10. Implemented RegisterUserWithAuthInterface() to register with all existing instances
  11. Implemented RegisterUserWithAuthInterfaceInternal() with account registry manipulation
  12. Implemented OnNewOnlineServicesCreated() callback for auto-registration

  13. Source/Metal_terra/Metal_terra.Build.cs

  14. Added OnlineServicesEOSGS and OnlineServicesCommon to PublicDependencyModuleNames

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 invalid FUniqueNetIdWrapper during 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:

  1. Flag to prevent multiple auto-joins: bool bHasAutoJoinedOnFirstLogin = false;
  2. Delegate binding: After EOS login succeeds in HandleAuthLoginComplete(), bind to NakamaSubsystem->OnNakamaLoginComplete delegate
  3. Auto-join callback: OnNakamaLoginCompleteInternal() checks the flag and calls CleanupAndCreateEosLobby(30) on first login
  4. Lobby/Session creation: After lobby and session are created, automatically join them
  5. Host RPC call: After joining, call house_player_join RPC 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:

  1. Added member variable: FString PendingHostPuid; to store host PUID extracted from session details
  2. 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);
  1. Use in join callback: In OnEosSessionJoinCompleted(), removed all active session extraction code and simply use PendingHostPuid

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

  1. Multiple OnlineServices Instances in PIE:

  2. Default instance: Name=None

  3. PIE world instance: Name=Context_1, Context_2, etc.
  4. Socket subsystem uses GetWorldForOnline() to find the correct instance for the world
  5. Must register with the world-specific instance, not just the default

  6. OnlineServices Instance Creation Timing:

  7. Context_1 is created during PIE initialization, BEFORE login completes

  8. Must register with existing instances after login AND listen for new instances

  9. Global Delegate for Instance Creation:

  10. UE::Online::OnOnlineServicesCreated broadcasts whenever a new instance is created
  11. Fires from FOnlineServicesRegistry::GetNamedServicesInstance() (line 108)
  12. Can be used to auto-register with new instances without knowing when/where they're created