Back to Blog

How to Fix Unreal Engine Xbox Save Roaming in GDK and Windows Store Builds

Published on July 7, 2026
How to Fix Unreal Engine Xbox Save Roaming in GDK and Windows Store Builds

In a nutshell

Fix broken cloud saves and master unreal engine xbox save roaming using our step-by-step GDK integration guide to resolve Connected Storage issues.

The GDK Save Roaming Nightmare

You package your Xbox Series X and Windows Store GDK builds, verify achievements are syncing, see the cloud sync spinner spin on boot, and then watch in horror as your player's save files remain completely isolated on each platform. Everything looks correct from a user perspective: the sign-in succeeds, notifications fire, and both builds show save files persisting locally between game launches. Yet, progress on Xbox never roams to the PC build, and vice-versa.

Dealing with save data discrepancies is just as frustrating as troubleshooting multiplayer desyncs during live gameplay; both issues stem from a mismatch of state authorities. In the case of Xbox Play Anywhere (XPA), this problem is rarely a console-level failure. It is almost always a race condition in how Unreal Engine maps its abstract, integer-based user indices to Microsoft's GDK-specific identity systems. Let's look at why this happens and how to fix it in both C++ and Blueprints.

The Anatomy of GDK Connected Storage

To understand why save roaming fails, we must first understand how Microsoft's GDK handles user save files under the hood. Unlike Steam or custom game backends, which typically treat save files as raw files uploaded to a remote server, the Microsoft Game Development Kit (GDK) uses Connected Storage (the XGameSave API). Under this architecture, saves are not raw file paths but structured container systems. These containers hold individual save file blobs that are securely synchronized with the Xbox Live servers.

For cloud save roaming to work between Xbox console and Windows Store (PC) builds, the GDK relies on a strict matching matrix. The two builds must share the exact same Title ID, Service Configuration ID (SCID), and Sandbox (such as RETAIL or XDKS.1). Additionally, the player must be signed into the same Microsoft Account (MSA) on both devices. Finally, the container names generated by your save system must match character-for-character; otherwise, the cloud syncing system will treat them as independent data sets.

Furthermore, Connected Storage allocates a maximum of 256 megabytes of storage per user, per title, with an individual container limit of 16 megabytes. If your save system exceeds these hard limits, the API silently fails or returns error codes that Unreal Engine's default save system doesn't bubble up to the surface. It is critical to design your save systems with these constraints in mind, ensuring your file sizes remain small and structured.

The Root Cause: The Asynchronous Login Race Condition

The most common reason for GDK save roaming failure in Unreal Engine projects is a subtle race condition in how the game determines who is saving. On an Xbox Series X console, the operating system is built around active controller profiles. When the game boots, a user is already selected at the OS level. The console automatically assigns an XUserHandle to the game's execution context, and Unreal Engine maps this active profile to UserIndex 0 almost immediately.

On Windows PC, the flow is entirely different. The game boots immediately, without prompting for an active profile. The Xbox App or Windows Store login flow runs asynchronously in the background. If your game code calls LoadGameFromSlot or DoesSaveGameExist immediately upon boot (e.g., in a GameInstance::Init call or your main menu's BeginPlay), the GDK Online Subsystem has not yet completed the user sign-in.

When Unreal Engine's ISaveGameSystem (specifically the GDK implementation, FGameSaveSystemGDK) is called with UserIndex = 0 before a user is signed in, it falls back to a local guest user. GDK then maps this session to local offline storage on the PC, bypassing the cloud-synced container. Once the asynchronous sign-in finally completes a few seconds later, the game is already running with local data. If the player writes a new save, the engine may write it to the local guest profile or attempt to write to the newly resolved MSA container, creating a state split where the cloud profile on the console and the local profile on PC have entirely different files.

How Unreal Engine Maps UserIndex to XUserHandle

Unreal Engine's save system was designed long before modern platform identity systems. It uses a simple integer (UserIndex) to differentiate between players in local co-op. In the GDK implementation, the engine tries to resolve this integer to an XUserHandle by calling internal GDK subsystem helpers. If the player index maps to an invalid or uninitialized handle, the save operation silently falls back to a local directory.

To prevent this, you must ensure two things. First, you must not touch the save system until the Online Subsystem identity interface reports that the player is fully logged in. Second, you must resolve the correct, platform-specific UserIndex associated with the signed-in user's FUniqueNetId, rather than assuming the local player controller is always index 0. Let's implement a system that guarantees this workflow.

Implementation Guide: Resolving the Race Condition

Step 1: The GDK Save Utility Class

Create a custom UGDKSaveHelper class that inherits from UBlueprintFunctionLibrary. This class will contain the logic to get the correct user index and ensure the user is logged in. Here is the header file (GDKSaveHelper.h):

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "GDKSaveHelper.generated.h"

UCLASS()
class HORIZON_API UGDKSaveHelper : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, Category = "GDK Save System")
    static int32 GetGDKUserIndex(const APlayerController* PlayerController);

    UFUNCTION(BlueprintPure, Category = "GDK Save System")
    static bool IsPlayerLoggedInGDK(const APlayerController* PlayerController);
};

And here is the implementation file (GDKSaveHelper.cpp) which uses the online subsystem to safely map the local player's network ID to their platform-specific user index:

#include "GDKSaveHelper.h"
#include "GameFramework/PlayerController.h"
#include "OnlineSubsystem.h"
#include "OnlineSubsystemUtils.h"

int32 UGDKSaveHelper::GetGDKUserIndex(const APlayerController* PlayerController)
{
    if (!PlayerController)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlayerController is null."));
        return -1;
    }

    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: OnlineSubsystem is null."));
        return -1;
    }

    IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
    if (!IdentityInterface.IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: IdentityInterface is invalid."));
        return -1;
    }

    ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
    if (!LocalPlayer)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: LocalPlayer is null."));
        return -1;
    }

    FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
    if (!UniqueId.IsValid() || !UniqueId.GetUniqueNetId()->IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: Unique ID is not valid yet."));
        return -1;
    }

    int32 PlatformUserIndex = IdentityInterface->GetPlatformUserIndexFromUniqueNetId(*UniqueId.GetUniqueNetId());
    
    if (PlatformUserIndex == PLATFORMUSERID_NONE)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlatformUserIndex mapped to PLATFORMUSERID_NONE."));
        return -1;
    }

    UE_LOG(LogTemp, Log, TEXT("GetGDKUserIndex: Successfully resolved UserIndex to %d"), PlatformUserIndex);
    return PlatformUserIndex;
}

bool UGDKSaveHelper::IsPlayerLoggedInGDK(const APlayerController* PlayerController)
{
    if (!PlayerController)
    {
        return false;
    }

    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
        if (IdentityInterface.IsValid())
        {
            ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
            if (LocalPlayer)
            {
                FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
                if (UniqueId.IsValid() && UniqueId.GetUniqueNetId()->IsValid())
                { 
                    ELoginStatus::Type LoginStatus = IdentityInterface->GetLoginStatus(*UniqueId.GetUniqueNetId());
                    return LoginStatus == ELoginStatus::LoggedIn;
                }
            }
        }
    }
    return false;
}

Step 2: Modifying Your Save and Load Logic

In your Blueprints or C++ game state management, you must wrap your load calls. Instead of calling LoadGameFromSlot immediately on startup, implement a check that waits for sign-in. Bind to the On Login Complete event from the Online Identity subsystem. Once the event fires, check if the user is fully logged in, query their resolved index, and pass it directly into the save game nodes.

If the sign-in fails or the player cancels it, you must handle the game in a 'guest' state. Warn the player that their progress will not save to the cloud and will remain local to the current machine. This ensures that the player is always aware of their saving status, preventing unexpected progress loss when switching devices.

Step 3: Aligning Configuration Files

Even if your code resolves the correct user index, save roaming will fail if the configuration files on the Xbox GDK and PC GDK packages don't match. Open your DefaultEngine.ini and ensure the GDK online subsystem settings are correct. The Title ID and Service Configuration ID (SCID) must match your Microsoft Partner Center registration precisely.

[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true

[OnlineSubsystem]
DefaultPlatformService=GDK

In your MicrosoftGame.config (which packages your application), verify the Identity and Title ID match the values inside your Partner Center dashboard exactly:

<GameConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Identity Name="YourPublisher.YourGameName"
            Publisher="CN=12345678-1234-abcd-1234-1234567890ab"
            Version="1.0.0.0"/>
  
  <TitleId>1234ABCD</TitleId>
  <MSAAppId>0000000012345678</MSAAppId>
  
  <DevelopmentServer>RETAIL</DevelopmentServer>
</GameConfig>

Keep in mind that when testing in a sandbox (e.g. XDKS.1), your test accounts must be added to the sandbox and must sign in properly inside the Xbox App on PC. If the Xbox App is signed into a different account than the Windows Store, the Windows GDK API will fail to initialize the XGameSave provider, leading to mismatched local directories. You can verify this by checking the local AppData folder to see where the save files are being written.

Alternative: Cloud Saves Without Platform Lock-In via horizOn

Configuring Microsoft's GDK Connected Storage APIs, handling offline guest fallbacks, and maintaining identical sandboxes across PC and console environments can take weeks of setup and debugging. Even once it works, you are locked into the Microsoft ecosystem. These saves will not roam to Steam, Epic Games Store, Playstation, or Nintendo Switch.

Instead of writing platform-specific wrappers, or resorting to complex real-time setups like an Unreal Engine WebSockets tutorial for real-time backends, you can offload your save state architecture entirely to a platform-agnostic backend service like horizOn. By using horizOn, save state management is decoupled from platform-specific APIs. You authenticate players using their platform identity (like Steam, Epic, or Xbox Live) and save their data to a unified cloud database.

This bypasses Xbox sandbox mismatch issues completely, allowing true cross-platform save roaming between Steam, Xbox, and PlayStation. Here is a conceptual example of how simple it is to save player progress globally using the service's REST API in Unreal Engine C++:

#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"

void SaveToHorizonCloud(const FString& PlayerToken, const FString& SaveDataJson)
{
    TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
    Request->SetURL(TEXT("https://api.horizon.pm/v1/save-state"));
    Request->SetVerb(TEXT("POST"));
    Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
    Request->SetHeader(TEXT("Authorization"), FString::Printf(TEXT("Bearer %s"), *PlayerToken));
    
    TSharedPtr<FJsonObject> Payload = MakeShareable(new FJsonObject());
    Payload->SetStringField(TEXT("slot_name"), TEXT("SaveSlot1"));
    Payload->SetStringField(TEXT("data"), SaveDataJson);
    
    FString OutputString;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
    FJsonSerializer::Serialize(Payload.ToSharedRef(), Writer);
    
    Request->SetContentAsString(OutputString);
    Request->OnProcessRequestComplete().BindLambda([](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bWasSuccessful)
    {
        if (bWasSuccessful && Res->GetResponseCode() == 200)
        {
            UE_LOG(LogTemp, Log, TEXT("Save state synchronized successfully."));
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to sync save state to cloud."));
        }
    });
    
    Request->ProcessRequest();
}

Building a robust cloud save system yourself requires setting up global databases, managing secure session tokens, and handling offline sync conflicts—easily 4 to 6 weeks of dedicated engineering time. With horizOn, these backend services come pre-configured, letting you ship your game instead of building database infrastructure. It simplifies the pipeline from a multi-platform configuration headache to a single API call.

Best Practices for Cross-Platform Save Architecture

Whether you choose to struggle through Microsoft's GDK Connected Storage configuration or abstract it away with a backend service, you should adhere to these core best practices:

  1. Never Save on Tick or Frame-End: Connected Storage limits writing operations to prevent disk degradation and cloud sync throttling. Batch saves to specific trigger points (checkpoints, level transitions, menu exits).
  2. Implement Save Conflict Resolvers: If a save on PC has a timestamp of 14:00 and an Xbox save has a timestamp of 14:05, do not blindly overwrite the older save. Present a UI prompt showing play times, level progress, and metadata, letting the player choose their active state.
  3. Validate Save File Integrity: Prior to writing, serialize your save data and generate an MD5 or SHA-256 hash. Save this hash inside a separate metadata block to verify that file downloads have not corrupted during cloud transit.
  4. Gracefully Handle Sign-Outs: In GDK, players can sign out via the Xbox dashboard while the game is running. Register for the OnLoginStatusChanged delegate and pause the game immediately if the active user changes.

If you are ready to implement a scalable, platform-agnostic cloud save system without the GDK configuration headaches, you can get started today. Learn more by trying horizOn for free or reviewing the API docs.


Source: Xbox/GDK XPA cross platform save roaming not working