Players of my game losing their save (only partially!)

Hi there,

My game has been out for a few months on Android & iOS, and things go mostly smoothly, but some players are reporting that part of their save has been missing.
I insist on this point: only part of it has been missing, which is what has been driving me crazy for the past week because despite all my investigation, I have no clue on how to reproduce it.

In my PlayerData class (which holds all the variables I’m saving), at some point you can find Boosters, Skip’its, and a bunch of other things.
It seems like from this moment on, players are loosing their Boosters and everything that follows.

Here are some relevant reviews:



So I’ve tried to “corrupt” the save file by removing part of it, but then the game won’t load because the Deserialization won’t work (which is something I plan to correct by handling the exception thrown by the way), and the “LoadPlayer()” method will just stop the execution moving forward.

I wonder then, what could cause this behavior? Could it be something wrong happening during the LoadPlayer method, which still makes the game function, but that lets all the variables in their default states? (which would mean, boosters count = 0, skip’its tickets count = 0, etc.).

I use BinaryFormatter (I’m aware now of the security flaws it contains, I didn’t when I launched the game, but I don’t think this is what is causing the issue).

Below are some relevant parts of the code.
Save file is very small (~19ko).
I’ve found only one iOS player complaining about it, so it looks like more of an Android issue. But I also have fewer players on iOS, + the devices diversity is bigger on Android so it might be just that.

Let me know if I can provide any further information !

Thanks a lot :slight_smile:

Here are my SavePlayer and LoadPlayer methods.

 public static void SavePlayer(Player player)
    {
        BinaryFormatter formatter = new BinaryFormatter();
        string path = Application.persistentDataPath + "/player.save";
        FileStream stream = new FileStream(path, FileMode.Create);
        PlayerData data = new PlayerData(player);
        formatter.Serialize(stream, data);
        stream.Close();

        PlayerPrefs.SetInt("saveExists", 1);
    }

    public static PlayerData LoadPlayer()
    {
        string path = Application.persistentDataPath + "/player.save";

        if (File.Exists(path))
        {
            BinaryFormatter formatter = new BinaryFormatter();
            FileStream stream = new FileStream(path, FileMode.Open);
            PlayerData data = formatter.Deserialize(stream) as PlayerData;
            stream.Close();
            return data;
        }
        else
        {
            if (PlayerPrefs.GetInt("saveExists") == 1)
            {
                Debug.LogError("Save file was created before but couldn't be found in: " + path);
                Utility.gameManager.analyticsManager.SendEvent_SaveFileNotFound();
                //TODO : Add error popup for user.
            }

            return LoadBlankPlayerData();
        }
    }

    public static PlayerData LoadBlankPlayerData()
    {
        PlayerData data = new PlayerData(); //save par défaut
        return data;
    }

Which I call from my GameManager :

    public void SavePlayer()
    {
        SaveSystem.SavePlayer(player);
        isFreshSave = false;
    }

    public void LoadPlayer()
    {
        PlayerData data = SaveSystem.LoadPlayer();
        ApplyPlayerLoadedValues(data);
    }

   public void ApplyPlayerLoadedValues(PlayerData data)
    {
        highestLevelUnlocked = data.highestLevelUnlocked;
        scoreManager.highestScore = data.highestScore;
        settingsManager.setting_SFX = data.setting_SFX;

        settingsManager.setting_MUSIC = data.setting_MUSIC;

        settingsManager.setting_VIBRATE = data.setting_VIBRATE;
        settingsManager.setting_LEFTHANDED = data.setting_LEFTHANDED;
        settingsManager.setting_LANGUAGE = (Languages)System.Enum.Parse(typeof(Languages), data.setting_LANGUAGE);

        settingsManager.InitAudioChannels();
        localizationManager.UpdateLanguage(settingsManager.setting_LANGUAGE);

        isFreshSave = data.isFreshSave;

        playerRank = data.playerRank;
        playerCurrencyAmount = data.playerCurrencyAmount;

        if (data.skillsEquippedDictionary != null)
            skillsManager.skillsEquippedDictionary = new Dictionary<string, int>(data.skillsEquippedDictionary);
        else
            skillsManager.skillsEquippedDictionary = new Dictionary<string, int>(skillsManager.skillsEquippedDictionary_BLANK);

        if(data.skillsLevelsDictionary != null)
            skillsManager.skillsLevelsDictionary = new Dictionary<string, int>(data.skillsLevelsDictionary);
        else
            skillsManager.skillsLevelsDictionary = new Dictionary<string, int>(skillsManager.skillsLevelsDictionary_BLANK);

        if (data.levelsPerfectlyDrawn != null)
        {
            int minCount = Mathf.Min(data.levelsPerfectlyDrawn.Count, levelsManager.levelsPerfectlyDrawn.Count);

            for (int i = 0; i < minCount; i++)
            {
                levelsManager.levelsPerfectlyDrawn[i] = data.levelsPerfectlyDrawn[i];
            }
        }
        else
            levelsManager.levelsPerfectlyDrawn = new List<int>(levelsManager.levelsPerfectlyDrawn_BLANK);


        if (data.levelsDangerSurvived != null)
        {
            int minCount = Mathf.Min(data.levelsDangerSurvived.Count, levelsManager.levelsDangerSurvived.Count);

            for (int i = 0; i < minCount; i++)
            {
                levelsManager.levelsDangerSurvived[i] = data.levelsDangerSurvived[i];
            }
        }
        else
            levelsManager.levelsDangerSurvived = new List<int>(levelsManager.levelsDangerSurvived_BLANK);


        foreach (var kvp in skillsManager.skillsEquippedDictionary)
        {
            skillsManager.skillsDictionary[kvp.Key].isEquipped = kvp.Value;
        }

        foreach (var kvp in skillsManager.skillsLevelsDictionary)
        {
            skillsManager.skillsDictionary[kvp.Key].skillLevel = kvp.Value;
        }

        //skins

        Dictionary<string, int> tempSkinsDico = new Dictionary<string, int>();

        foreach (var kvp in Utility.customizationManager.playerSkinsEquipped)
        {
            if (data.playerSkinsEquipped.ContainsKey(kvp.Key))
            {
                tempSkinsDico.Add(kvp.Key, data.playerSkinsEquipped[kvp.Key]);
            }
            else
            {
                tempSkinsDico.Add(kvp.Key, 0);
            }

            if (tempSkinsDico[kvp.Key] == 0 && (Utility.customizationManager.skinButtons_Dico[kvp.Key].skinUnlockType == SkinUnlockType.crowns && Utility.gameManager.levelsManager.GetTotalNumberOfStarsAcquired() >= Utility.customizationManager.skinButtons_Dico[kvp.Key].crownsCount))
                tempSkinsDico[kvp.Key] = 1;
        }

        Utility.customizationManager.playerSkinsEquipped = new Dictionary<string, int>(tempSkinsDico);

        tempSkinsDico.Clear();

        foreach (var kvp in Utility.customizationManager.trailSkinsEquipped)
        {
            if (data.trailSkinsEquipped.ContainsKey(kvp.Key))
            {
                tempSkinsDico.Add(kvp.Key, data.trailSkinsEquipped[kvp.Key]);
            }
            else
            {
                tempSkinsDico.Add(kvp.Key, 0);
            }

            if (tempSkinsDico[kvp.Key] == 0 && (Utility.customizationManager.skinButtons_Dico[kvp.Key].skinUnlockType == SkinUnlockType.crowns && Utility.gameManager.levelsManager.GetTotalNumberOfStarsAcquired() >= Utility.customizationManager.skinButtons_Dico[kvp.Key].crownsCount))
                tempSkinsDico[kvp.Key] = 1;

        }

        Utility.customizationManager.trailSkinsEquipped = new Dictionary<string, int>(tempSkinsDico);
        Utility.customizationManager.playerLastSkinUnlockedIndex = data.playerLastSkinUnlockedIndex;
        Utility.customizationManager.trailLastSkinUnlockedIndex = data.trailLastSkinUnlockedIndex;
        Utility.customizationManager.EquipCustomization();


        //===CHALLENGES
        if(data.activeChallenges != null) 
        {
            challengeManager.activeChallengesExpirationDate = data.activeChallengesExpirationDate;
            challengeManager.activeChallenges = data.activeChallenges; 
            challengeManager.numberOfChestsOpened = data.numberOfChestsOpened; 
            challengeManager.hasChestBeenClaimed = data.hasChestBeenClaimed;
            challengeManager.numberOfActiveChallengesGroupsGenerated = data.numberOfActiveChallengesGroupsGenerated; 

            if ((challengeManager.activeChallengesExpirationDate - System.DateTime.Now).Seconds <= 0)
            {
                if(highestLevelUnlocked >= levelsManager.challengesLevel)
                {
                    challengeManager.isSkipPossible = true;
                    challengeManager.GenerateActiveChallenges();
                }
            }
            else
            {
                challengeManager.InitChallengesDictionary();
                challengeManager.StartChallengesTimer();
            }

            for (int i = 0; i < challengeManager.activeChallenges.Count; i++)
            {
                if(challengeManager.activeChallenges[i].description_LocID == null)
                {
                    challengeManager.activeChallenges[i].description_LocID = challengeManager.challengesTypeDico[challengeManager.activeChallenges[i].challengeType].description_LocID;
                }
            }
        }
        else //challenges = null
        {
            if(highestLevelUnlocked >= levelsManager.challengesLevel)
            {
                challengeManager.GenerateActiveChallenges();
            }
        }

        challengeManager.isSkipPossible = data.isSkipPossible;

        //===GAME LAUNCHES
        numberOfGameLaunches = data.numberOfGameLaunches;

        //===REVIEW REQUESTS
        settingsManager.hasAcceptedReviewRequest = data.hasAcceptedReviewRequest;

        //customization ad unlock
        customizationManager.playerSkin_currentAdWatch = data.playerSkin_currentAdWatch;
        customizationManager.trailSkin_currentAdWatch = data.trailSkin_currentAdWatch;

        Utility.gameManager.rythmManager.rythmRank = data.rythmRank;

        Utility.gameManager.rythmManager.rythmContestRefreshDate = data.rythmContestRefreshDate;
        Utility.gameManager.rythmManager.dualTrackSelected = data.dualTrackSelected;
        Utility.gameManager.rythmManager.currentNumberOfRythmContestTriesDone = data.currentNumberOfRythmContestTriesDone;

        foreach (var dualTrack in Utility.gameManager.rythmManager.dualTracks)
        {
            if (data.dualTracksUnlockedDico != null && data.dualTracksUnlockedDico.ContainsKey(dualTrack.name))
            {
                Utility.gameManager.rythmManager.dualTracksUnlockedDico[dualTrack.name] = data.dualTracksUnlockedDico[dualTrack.name];
                Utility.gameManager.rythmManager.dualTracksSeenDico[dualTrack.name] = data.dualTracksSeenDico[dualTrack.name];
                Utility.gameManager.rythmManager.dualTracksPlayedCountDico[dualTrack.name] = data.dualTracksPlayedCountDico[dualTrack.name];
                Utility.gameManager.rythmManager.dualTracksScoresDico[dualTrack.name] = data.dualTracksScoresDico[dualTrack.name];
            }
            else
            {
                Utility.gameManager.rythmManager.dualTracksUnlockedDico[dualTrack.name] = 0;
                Utility.gameManager.rythmManager.dualTracksSeenDico[dualTrack.name] = 0;
                Utility.gameManager.rythmManager.dualTracksPlayedCountDico[dualTrack.name] = 0;
                Utility.gameManager.rythmManager.dualTracksScoresDico[dualTrack.name] = 0;
            }

            if (dualTrack.unlockLevel == 0)
                Utility.gameManager.rythmManager.dualTracksSeenDico[dualTrack.name] = 1; //on considère que les tracks de départ n'ont pas de "new"
        }


        //===CHEST ROOM
        chestRoomManager.chestRoomKeysCount = data.chestRoomKeysCount;
        chestRoomManager.chestRoomCompletedCount = data.chestRoomCompletedCount;


        //=== BOOSTERS
        boosterManager.boosterCount_Health = data.boosterCount_Health;
        boosterManager.boosterCount_Cooldown = data.boosterCount_Cooldown;
        boosterManager.boosterCount_Coins = data.boosterCount_Coins;
        boosterManager.totalBoostersAcquiredCount = data.totalBoostersAcquiredCount;
        boosterManager.skipitOrBoosterBonusEnemiesSpawnedCount = data.skipitOrBoosterBonusEnemiesSpawnedCount;


        //=== SHOP
        shopManager.skipItsCounts = data.skipItsCounts;
        shopManager.numberOfRuneChestOpened = data.numberOfRuneChestOpened;
        shopManager.runeChestRefreshDate = data.runeChestRefreshDate;


        //=== ADS
        adsManager.interstitialsEnabled = data.interstitialsEnabled;
        adsManager.showBannerInMenus = data.showBannerInMenus;

        levelsManager.levelCompletion.noThanksPressedCount = data.noThanksPressedCount;
        adsManager.hasSeenSkipitsExplanationBubble = data.hasSeenSkipitsExplanationBubble;

        //=== TRAINING
        foreach (var shape in Utility.gameManager.trainingManager.shapesOrderedByDifficulty)
        {
            if (data.shapesAlreadyDiscovered != null && data.shapesAlreadyDiscovered.ContainsKey(shape.name))
            {
                Utility.gameManager.trainingManager.shapesAlreadyDiscovered[shape.name] = data.shapesAlreadyDiscovered[shape.name];
                Utility.gameManager.trainingManager.shapesTrainingCompletion[shape.name] = data.shapesTrainingCompletion[shape.name];
                Utility.gameManager.trainingManager.shapesDrawingContestScore[shape.name] = data.shapesDrawingContestScore[shape.name];
            }
            else
            {
                Utility.gameManager.trainingManager.shapesAlreadyDiscovered[shape.name] = 0;
                Utility.gameManager.trainingManager.shapesTrainingCompletion[shape.name] = 0;
                Utility.gameManager.trainingManager.shapesDrawingContestScore[shape.name] = 0;
            }
        }

        trainingManager.drawingContestPlayPressedCount = data.drawingContestPlayPressedCount;


        //Update of the currency module
        currencyManager.InitCurrencyManager();

        //Update UI according to ratio and whether there are banners or not.
        touchManager.AdaptUIAccordingToScreenRatio();
    }

Here is my class PlayerData, holding all the saved variables

[System.Serializable]
public class PlayerData
{
    public int highestLevelUnlocked;
    public int highestScore;

    public bool setting_SFX;
    public MusicSetting setting_MUSIC;

    public bool setting_VIBRATE;
    public bool setting_LEFTHANDED;
    public string setting_LANGUAGE;
    public bool isFreshSave;


    //New since first release
    public bool hqGraphics;

    public int playerRank;
    public int playerCurrencyAmount;
    public Dictionary<string, int> skillsEquippedDictionary;
    public Dictionary<string, int> skillsLevelsDictionary;
    public List<int> levelsPerfectlyDrawn;
    public List<int> levelsDangerSurvived;
    //skins
    public Dictionary<string, int> playerSkinsEquipped; //0 = LOCKED, 1 = UNLOCKED & UNEQUIPPED, 2 = EQUIPPED
    public Dictionary<string, int> trailSkinsEquipped; //0 = LOCKED, 1 = UNLOCKED & UNEQUIPPED, 2 = EQUIPPED
    public int playerLastSkinUnlockedIndex;
    public int trailLastSkinUnlockedIndex;
    //save version
    public int latestSaveVersion; //Used to know which save version the player has. According to this, if player has an old save, ApplyLoadedValues can force some values in variables.
    //challenges
    public List<Challenge> activeChallenges; 
    public int numberOfChestsOpened; 
    public bool hasChestBeenClaimed; 
    public int numberOfActiveChallengesGroupsGenerated; 
    public System.DateTime activeChallengesExpirationDate; 
    public bool isSkipPossible;
    //game launches
    public int numberOfGameLaunches; 
    //review request
    public bool hasAcceptedReviewRequest;
    //customization ad unlock
    public int playerSkin_currentAdWatch; 
    public int trailSkin_currentAdWatch; 


    //=== Rythm Contest 
    public int rythmRank = 0; 
    public System.DateTime rythmContestRefreshDate;
    public string dualTrackSelected;
    public Dictionary<string, int> dualTracksUnlockedDico;
    public Dictionary<string, int> dualTracksSeenDico;
    public Dictionary<string, int> dualTracksPlayedCountDico;
    public Dictionary<string, int> dualTracksScoresDico;
    public int currentNumberOfRythmContestTriesDone;

    //=== CHEST ROOM
    public int chestRoomKeysCount; 
    public int chestRoomCompletedCount; 


    //=== BOOSTERS
    public int boosterCount_Health;
    public int boosterCount_Cooldown;
    public int boosterCount_Coins;
    public int totalBoostersAcquiredCount;
    public int skipitOrBoosterBonusEnemiesSpawnedCount;


    //=== SHOP
    public int skipItsCounts;
    public int numberOfRuneChestOpened;
    public System.DateTime runeChestRefreshDate;


    //=== ADS
    public bool showBannerInMenus; 
    public bool interstitialsEnabled;
    public int noThanksPressedCount;
    public bool hasSeenSkipitsExplanationBubble;

    //=== TRAINING
    public Dictionary<string, int> shapesAlreadyDiscovered; 
    public Dictionary<string, int> shapesTrainingCompletion; 
    public Dictionary<string, int> shapesDrawingContestScore; 
    public int drawingContestPlayPressedCount; 


    public PlayerData(Player player)
    {
        highestLevelUnlocked = player.gameManager.highestLevelUnlocked;
        highestScore = player.gameManager.scoreManager.highestScore;

        setting_SFX = player.gameManager.settingsManager.setting_SFX;
        setting_MUSIC = player.gameManager.settingsManager.setting_MUSIC;
        setting_VIBRATE = player.gameManager.settingsManager.setting_VIBRATE;
        setting_LEFTHANDED = player.gameManager.settingsManager.setting_LEFTHANDED;
        setting_LANGUAGE = player.gameManager.settingsManager.setting_LANGUAGE.ToString();

        isFreshSave = false;

        //New since first release
        hqGraphics = player.gameManager.settingsManager.hqGraphics;

        playerRank = player.gameManager.playerRank;
        playerCurrencyAmount = player.gameManager.playerCurrencyAmount;

        skillsEquippedDictionary = player.gameManager.skillsManager.skillsEquippedDictionary;
        skillsLevelsDictionary = player.gameManager.skillsManager.skillsLevelsDictionary;
        levelsPerfectlyDrawn = player.gameManager.levelsManager.levelsPerfectlyDrawn;
        levelsDangerSurvived = player.gameManager.levelsManager.levelsDangerSurvived;

        playerSkinsEquipped = Utility.customizationManager.playerSkinsEquipped;
        trailSkinsEquipped = Utility.customizationManager.trailSkinsEquipped;
        playerLastSkinUnlockedIndex = Utility.customizationManager.playerLastSkinUnlockedIndex;
        trailLastSkinUnlockedIndex = Utility.customizationManager.trailLastSkinUnlockedIndex;

        latestSaveVersion = Utility.gameManager.latestSaveVersion;

        //challenges
        activeChallenges = player.gameManager.challengeManager.activeChallenges; 
        numberOfChestsOpened = player.gameManager.challengeManager.numberOfChestsOpened; 
        hasChestBeenClaimed = player.gameManager.challengeManager.hasChestBeenClaimed;
        numberOfActiveChallengesGroupsGenerated = player.gameManager.challengeManager.numberOfActiveChallengesGroupsGenerated; 
        activeChallengesExpirationDate = player.gameManager.challengeManager.activeChallengesExpirationDate;
        isSkipPossible = player.gameManager.challengeManager.isSkipPossible;

        //game launches
        numberOfGameLaunches = player.gameManager.numberOfGameLaunches; 

        //review request
        hasAcceptedReviewRequest = player.gameManager.settingsManager.hasAcceptedReviewRequest;

        //customization ad unlock
        playerSkin_currentAdWatch = player.gameManager.customizationManager.playerSkin_currentAdWatch; 
        trailSkin_currentAdWatch = player.gameManager.customizationManager.trailSkin_currentAdWatch;

        //=== Rythm Contest 
        rythmRank = player.gameManager.rythmManager.rythmRank; 
        rythmContestRefreshDate = player.gameManager.rythmManager.rythmContestRefreshDate;
        dualTrackSelected = player.gameManager.rythmManager.dualTrackSelected;
        dualTracksUnlockedDico = player.gameManager.rythmManager.dualTracksUnlockedDico;
        dualTracksSeenDico = player.gameManager.rythmManager.dualTracksSeenDico;
        dualTracksPlayedCountDico = player.gameManager.rythmManager.dualTracksPlayedCountDico;
        dualTracksScoresDico = player.gameManager.rythmManager.dualTracksScoresDico;
        currentNumberOfRythmContestTriesDone = player.gameManager.rythmManager.currentNumberOfRythmContestTriesDone;


        //=== Chest Room
        chestRoomKeysCount = player.gameManager.chestRoomManager.chestRoomKeysCount;
        chestRoomCompletedCount = player.gameManager.chestRoomManager.chestRoomCompletedCount;


        //=== BOOSTERS
        boosterCount_Health = player.gameManager.boosterManager.boosterCount_Health;
        boosterCount_Cooldown = player.gameManager.boosterManager.boosterCount_Cooldown;
        boosterCount_Coins = player.gameManager.boosterManager.boosterCount_Coins;
        totalBoostersAcquiredCount = player.gameManager.boosterManager.totalBoostersAcquiredCount;
        skipitOrBoosterBonusEnemiesSpawnedCount = player.gameManager.boosterManager.skipitOrBoosterBonusEnemiesSpawnedCount;

        //=== SHOP
        skipItsCounts = player.gameManager.shopManager.skipItsCounts;
        numberOfRuneChestOpened = player.gameManager.shopManager.numberOfRuneChestOpened;
        runeChestRefreshDate = player.gameManager.shopManager.runeChestRefreshDate;

        //=== ADS
        showBannerInMenus = player.gameManager.adsManager.showBannerInMenus;
        interstitialsEnabled = player.gameManager.adsManager.interstitialsEnabled;
        noThanksPressedCount = player.gameManager.levelsManager.levelCompletion.noThanksPressedCount;
        hasSeenSkipitsExplanationBubble = player.gameManager.adsManager.hasSeenSkipitsExplanationBubble;

        //=== TRAINING
        shapesAlreadyDiscovered = player.gameManager.trainingManager.shapesAlreadyDiscovered;
        shapesTrainingCompletion = player.gameManager.trainingManager.shapesTrainingCompletion;
        shapesDrawingContestScore = player.gameManager.trainingManager.shapesDrawingContestScore;
        drawingContestPlayPressedCount = player.gameManager.trainingManager.drawingContestPlayPressedCount; 
    }

    public PlayerData() 
    {
        highestLevelUnlocked = 0;
        highestScore = 0;

        setting_SFX = true;
        setting_MUSIC = MusicSetting.On;
        setting_VIBRATE = true;
        setting_LEFTHANDED = false;
        setting_LANGUAGE = GetSystemLanguage().ToString();

        isFreshSave = true;

        //New since first release
        if (SystemInfo.systemMemorySize > 3000)
            hqGraphics = true;
        else
            hqGraphics = false;

        playerRank = 0;
        playerCurrencyAmount = 0;

        skillsEquippedDictionary = Utility.gameManager.skillsManager.skillsEquippedDictionary_BLANK;
        skillsLevelsDictionary = Utility.gameManager.skillsManager.skillsLevelsDictionary_BLANK;
        levelsPerfectlyDrawn = Utility.gameManager.levelsManager.levelsPerfectlyDrawn_BLANK;
        levelsDangerSurvived = Utility.gameManager.levelsManager.levelsDangerSurvived_BLANK;

        playerSkinsEquipped = Utility.customizationManager.playerSkinsEquipped_BLANK;
        trailSkinsEquipped = Utility.customizationManager.trailSkinsEquipped_BLANK;
        playerLastSkinUnlockedIndex = 0;
        trailLastSkinUnlockedIndex = 0;

        latestSaveVersion = Utility.gameManager.latestSaveVersion;

        //challenges
        activeChallenges = null; 
        numberOfChestsOpened = 0; 
        hasChestBeenClaimed = false;
        numberOfActiveChallengesGroupsGenerated = 0; 
        activeChallengesExpirationDate = System.DateTime.Now.AddMinutes(-1);
        isSkipPossible = true;

        //game launches
        numberOfGameLaunches = 0; 

        //review request
        hasAcceptedReviewRequest = false;

        //customization ad unlock
        playerSkin_currentAdWatch = 0;
        trailSkin_currentAdWatch = 0;

        //=== Rythm Contest 
        rythmRank = -1; 
        rythmContestRefreshDate = System.DateTime.Now.AddMinutes(-1);
        dualTrackSelected = null;
        dualTracksUnlockedDico = Utility.gameManager.rythmManager.dualTracksUnlockedDico_BLANK;
        dualTracksSeenDico = Utility.gameManager.rythmManager.dualTracksSeenDico_BLANK;
        dualTracksPlayedCountDico = Utility.gameManager.rythmManager.dualTracksPlayedCountDico_BLANK;
        dualTracksScoresDico = Utility.gameManager.rythmManager.dualTracksScoresDico_BLANK;
        currentNumberOfRythmContestTriesDone = 0;

        //=== Chest Room
        chestRoomKeysCount = 0;
        chestRoomCompletedCount = 0;

        //=== BOOSTERS
        boosterCount_Health = 0;
        boosterCount_Cooldown = 0;
        boosterCount_Coins = 0;
        totalBoostersAcquiredCount = 0;
        skipitOrBoosterBonusEnemiesSpawnedCount = 0;

        //=== SHOP
        skipItsCounts = 0;
        numberOfRuneChestOpened = 0;
        runeChestRefreshDate = System.DateTime.Now.AddMinutes(-1);

        //=== ADS
        showBannerInMenus = true;
        interstitialsEnabled = true;
        noThanksPressedCount = 0;
        hasSeenSkipitsExplanationBubble = false;

        //=== TRAINING
        shapesAlreadyDiscovered = Utility.gameManager.trainingManager.shapesAlreadyDiscovered_BLANK;
        shapesTrainingCompletion = Utility.gameManager.trainingManager.shapesTrainingCompletion_BLANK;
        shapesDrawingContestScore = Utility.gameManager.trainingManager.shapesDrawingContestScore_BLANK;
        drawingContestPlayPressedCount = 0;
    }


    public Languages GetSystemLanguage()
    {
        return Languages.english;
    }

}

And finally my Challenges class (which is, I suspect, is where things start to go wrong … please note that players don’t mention losing their challenges because it’s a daily thing and it would be very easy to miss it if it was just reset) :

[System.Serializable]
public class Challenge
{
    public ChallengeType challengeType;
    public int minimumLevelUnlocked; 
    public int maximumLevelOffset; 
    //[HideInInspector]
    public int maximumLevelUnlocked; 
    public string description;
    public string description_LocID;
    public int requiredAmount;
    public int currentAmount;
    public int reward;
    public bool hasRewardBeenClaimed;
    public int difficulty; // 0, 1, 2

    public Challenge(Challenge challenge)
    {
        challengeType = challenge.challengeType;
        minimumLevelUnlocked = challenge.minimumLevelUnlocked;
        description = challenge.description;
        description_LocID = challenge.description_LocID;
        requiredAmount = challenge.requiredAmount;
        currentAmount = challenge.currentAmount;
        reward = challenge.reward;
        hasRewardBeenClaimed = challenge.hasRewardBeenClaimed;
        difficulty = challenge.difficulty;
    }

    public bool IsChallengeCompleted()
    {
        return (currentAmount >= requiredAmount);
    }

    public bool IsChallengeHighlighted()
    {
        return (currentAmount >= requiredAmount && !hasRewardBeenClaimed);
    }

    public bool IsChallengeCompletedAndClaimed()
    {
        return (currentAmount >= requiredAmount && hasRewardBeenClaimed);
    }
}

First let me compliment you on a very tidy little piece of PlayerData code above… you are very organized.

As for what might cause this, I think you can assume BinaryFormatter works… I think… so that really leaves either data corruption (extremely unlikely as you point out), or some post-load bug that zeroes out that data in some way. I assume you’ve scoured your code for anything that might call reset on that collection.

So that leaves you with “oh man this is the MOST-infuriating bug ever: works for me, occasionally fails for users and completely borks their experience.”

The worst part is you will never know all the silently-leaving people who see it happen and just quit.

Since this issue is an “unlock only” issue, one way to inoculate yourself against that is to keep two save games, rolling between the two, so let’s call them CURR and PREV. When you write a save you age CURR into PREV and then write CURR.

Now whenever you contemplate transacting against this collection that gets reset, first compare the unlocks in memory to the unlocks in PREV and if there are ever fewer in memory, reload PREV and use those.

Ideally if you have remote telemetry installed, whenever this condition is detected, send the user’s binary save file back (CURR and PREV) to you over the net (requires a lot of infrastructure), or at least report via some kind of eventing that the issue occurred.

1 Like

Hi Kurt!

Thank you so much for your very fast response, I was actually hoping you would see this post as you were involved in the resolution of a kinda similar post a few years ago :wink: BinaryFormater is losing saved data on mobile

Thank you also for your empathy, as you describe exactly the state of mind I’m in now!

As for your suggestion, I was actually thinking about a backup save management system. Below is an attempt at doing that. Instead of creating a backup at every save, I do it after every successful loading (I didn’t want to make the save process longer as it happens during gameplay, but maybe it’s negligeable?). Any feedback of the code below would be very appreciated :slight_smile: (I’ve tried to make it more generic because I plan to add updates to the game, and was thinking that it could be a wise idea to split the save files for further data storage, so it minimizes the risk of corruption. And this is the first time I work with generics…).

Also if I undetstand your solution correctly, you are suggesting that I check the loaded data, and if I notice that something is at zero, then it means that it must have gone wrong at some point and I should then check the PREV state?
The thing is, it would work for example for the practice mode, as indeed there are some “unlocks” there. But the Boosters and Skip’its are consumables that you can earn and spend in game, so if I check the value and it’s at zero, it could very well be that the players used them all, it’s not necessarily an issue.

public static void SaveData<T>(T data)
    {
        string path;

        if (typeof(T) == typeof(PlayerData))
            path = Application.persistentDataPath + "/player.save";
        else
            path = ""; //TODO : include right relative path according to data type

        using FileStream stream = new FileStream(path, FileMode.Create);
        new BinaryFormatter().Serialize(stream, data);
        stream.Close();
    }

    public static T LoadData<T>()
    {
        string path;
        string backupPath;

        if (typeof(T) == typeof(PlayerData))
        {
            path = Application.persistentDataPath + "/player.save";
            backupPath = Application.persistentDataPath + "/playerBackup.save";
        }
        else
        {
            path = "";
            backupPath = "";//TODO : include right relative path according to data type
        }

        try
        {
            if (File.Exists(path))
            {
                BinaryFormatter formatter = new BinaryFormatter();
                using FileStream stream = new FileStream(path, FileMode.Open);
                T data = (T)formatter.Deserialize(stream);
                File.Copy(path, backupPath, true); //creation of the backup save file.
                stream.Close();
                return data;
            }
            else
            {
                return TryDeserializeBackupSave<T>(backupPath);
            }
        }
        catch (Exception e)
        {
            Debug.LogError($"Unable to load data, {(File.Exists(backupPath) ? "backup file found and will be loaded" : "no backup file found, loading blank data")}. Error due to: {e.Message} {e.StackTrace}");
            return TryDeserializeBackupSave<T>(backupPath);
        }
    }

    public static T TryDeserializeBackupSave<T>(string backupPath)
    {
        if (File.Exists(backupPath))
        {
            try
            {
                using FileStream stream = new FileStream(backupPath, FileMode.Open);
                T data = (T)new BinaryFormatter().Deserialize(stream);
                stream.Close();
                return data;
            }
            catch (Exception eb)
            {
                Debug.LogError($"Unable to load backup data even though it exists, loading blank data. Error due to: {eb.Message} {eb.StackTrace}");
                return LoadBlankData<T>();
            }
        }
        else
        {
            return LoadBlankData<T>();
        }
    }

    public static T LoadBlankData<T>()
    {
        if(typeof(T) == typeof(PlayerData))
        {
            object obj = new PlayerData(); //save par défaut
            return (T)obj;
        }
        else
        {
            return default(T);
        }
    }

For unlocks, if any one of them was unlocked and now it isn’t, well, you know to fix that from the PREV data.

Consumables are harder. Without a server that tracks each user’s consumables, it’s technically impossible because as you note, you cannot tell if the user USED it or if something corrupted it.

One possible way might be to have PREV, CURR and then some kind of crosscheck, perhaps a total, or even a seperately-saved instance of the same data, just broken out and written to another file. Then you compare them and when they diverge, pick the one more generous to the user, as most people won’t mind getting freebies once in a while.

It’s a hard problem. Ideally you fix the bug (assuming it IS a bug and it IS fixable! That’s a big assumption!).

You might want to consider shadow-saving the entire struct in JSON, then when you read it, compare the JSON vs the BinaryFormatter and possibly catch it there…

Thanks for these interesting leads.

Coming back to your first point, when you say age curr into prev, you mean that the very first step to do when I call the Save method would be to do File.Copy(currPath, prevPath, true), correct?
Could this also possibly fail? Or is it fast / safe enough that it’s reasonable to consider that it won’t?

Anything can fail… that’s why we have error codes!

You could Move/Rename, Copy, read and write it yourself, whatever you think.

All engineering entails risk. Manage that risk.

19k is pretty small in 2024 so the size is unlikely to be what makes it fail.

1 Like