00:00
00:00
3p0ch
If you like hard games try my Daxolissian System series

plasmid @3p0ch

Cat

Scientist

Read the manual & try stuff

So Cal

Joined on 2/13/10

Level:
13
Exp Points:
1,708 / 1,880
Exp Rank:
38,124
Vote Power:
5.50 votes
Audio Scouts
1
Rank:
Portal Security
Global Rank:
23,485
Blams:
50
Saves:
377
B/P Bonus:
8%
Whistle:
Normal
Medals:
4,746
Supporter:
3y 10m 16d

Updating Unity games without losing PlayerPrefs data

Posted by 3p0ch - February 18th, 2020


--== Update ==--


This implementation solves some of the issues with the previous implementation I posted.

If you haven't published your game yet and want to make sure save data in PlayerPrefs doesn't get wiped out when you publish updates, or you've already published but you're willing to make one update that will wipe out everyone's save data right now but make it possible to post future updates without wiping out everyone's save data, then here are step-by-step instructions. (See the very end of this post for a discussion about the underlying problem with PlayerPrefs and how this handles it.)


1) This step is optional* but to be on the safe side: Make sure your game has an opening scene right after the preloading that essentially just has a "Play" button. Graphics and text are fine to include but nothing in the scene should attempt to read or write PlayerPrefs data. (You should do this anyway to force the user to interact with the screen before really running the game so music starts properly.)

2) Save the code below in a C# script named StartScript.

3) Set the savePathName in the C# code to be your NewGrounds project number (look at the URL of your game's upload page for the number).

4) At the end of the C# code, make

SceneManager.LoadScene("Level Select", LoadSceneMode.Single);

load whatever scene is appropriate for your game if it doesn't happen to be named Level Select.

5) Add this script to the Play button as a component, and in the Play button's On Click make its target be the Play button game object and the function be this script's startGame() function.


*If you don't want to make a start screen like this, try just putting the top part of the code (excluding the StartScript class definition at the very end) into any C# script that you're already using in the first scene of the game, and do step #3 while disregarding the other steps.


I made two major changes compared to the original version based on @Rallyx suggesting conditional compilation, and the tactic of making the new emulating class be called PlayerPrefs so it overrides the built-in PlayerPrefs function if you're running it from a WebGL build but otherwise just uses the regular Unity PlayerPrefs function. That way it doesn't write save game data to an /idbfs/ directory on your hard drive when you run your game from the Unity editor (or the player's drive if you make a non-WebGL build), and you don't have to find/replace the PlayerPrefs statements throughout your code.


using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using UnityEngine.EventSystems;

// ***************************************************
// ** PUT YOUR SAVE PATH NAME IN THE VARIABLE BELOW **
// ***************************************************

// This class acts sort of as an emulator for
// PlayerPrefs, but saves to a specified
// place in IndexedDB so saved games don't
// get wiped when game updates are uploaded

// PlayerPrefs is similar (but not identical)
// to a hash of ints, floats, and strings, so
// this uses a Dictionary in C# (like a hash)

// Note that in this implementation all of
// the values are stored as strings even if
// they're really int or float, but they
// get parsed when values are returned

// Also note that I call Save() any time
// a value gets changed, because I think
// the risk of not saving stuff leading to
// bugginess outweighs the computational
// cost of saving more frequently.
// That could potentially be changed fairly
// easily as long as you know it's happening
// and that you just need to comment out the
// Save() statements at the end of the Set
// methods.

#if UNITY_EDITOR
#elif UNITY_WEBGL
public static class PlayerPrefs {
// **********************************
// ** PUT YOUR SAVE PATH NAME HERE **
// **********************************  
  static string savePathName = "12345";
  static string fileName;
  static string[] fileContents;
  static Dictionary<string, string> saveData = new Dictionary<string, string>();
  
  // This is the static constructor for the class
  // When invoked, it looks for a savegame file
  // and reads the keys and values
  static PlayerPrefs() {
    fileName = "/idbfs/" + savePathName + "/NGsave.dat";
    
    // Open the savegame file and read all of the lines
    // into fileContents
    // First make sure the directory and save file exist,
    // and make them if they don't already
    // (If the file is created, the filestream needs to be
    // closed afterward so it can be saved to later)
    if (!Directory.Exists("/idbfs/" + savePathName)) {
      Directory.CreateDirectory("/idbfs/" + savePathName);
    }
    if (!File.Exists(fileName)) {
      FileStream fs = File.Create(fileName);
      fs.Close();
    } else {
      // Read the file if it already existed
      fileContents = File.ReadAllLines(fileName);
      
      // If you want to use encryption/decryption, add your
      // code for decrypting here
      //   ******* decryption algorithm ********
      
      // Put all of the values into saveData
      for (int i=0; i<fileContents.Length; i += 2) {
        saveData.Add(fileContents[i], fileContents[i+1]);
      }
    }
  }
  
  // This saves the saveData to the player's IndexedDB
  public static void Save() {
    // Put the saveData dictionary into the fileContents
    // array of strings
    Array.Resize(ref fileContents, 2 * saveData.Count);
    int i=0;
    foreach (string key in saveData.Keys) {
      fileContents[i++] = key;
      fileContents[i++] = saveData[key];
    }
    
    // If you want to use encryption/decryption, add your
    // code for encrypting here
    //   ******* encryption algorithm ********
    
    // Write fileContents to the save file
    File.WriteAllLines(fileName, fileContents);
  }
  
  // The following methods emulate what PlayerPrefs does
  public static void DeleteAll() {
    saveData.Clear();
    Save();
  }
  
  public static void DeleteKey(string key) {
    saveData.Remove(key);
    Save();
  }
  
  public static float GetFloat(string key) {
    return float.Parse(saveData[key]);
  }
  public static float GetFloat(string key, float defaultValue) {
    if (saveData.ContainsKey(key)) {
      return float.Parse(saveData[key]);
    } else {
      return defaultValue;
    }
  }
  
  public static int GetInt(string key) {
    return int.Parse(saveData[key]);
  }
  public static int GetInt(string key, int defaultValue) {
    if (saveData.ContainsKey(key)) {
      return int.Parse(saveData[key]);
    } else {
      return defaultValue;
    }
  }
  
  public static string GetString(string key) {
    return saveData[key];
  }
  public static string GetString(string key, string defaultValue) {
    if (saveData.ContainsKey(key)) {
      return saveData[key];
    } else {
      return defaultValue;
    }
  }
  
  public static bool HasKey(string key) {
    return saveData.ContainsKey(key);
  }
  
  public static void SetFloat(string key, float setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue.ToString();
    } else {
      saveData.Add(key, setValue.ToString());
    }
    Save();
  }
  
  public static void SetInt(string key, int setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue.ToString();
    } else {
      saveData.Add(key, setValue.ToString());
    }
    Save();
  }
  
  public static void SetString(string key, string setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue;
    } else {
      saveData.Add(key, setValue);
    }
    Save();
  }
}
#endif

public class StartScript : MonoBehaviour {
  public void startGame() {
    SceneManager.LoadScene("Level Select", LoadSceneMode.Single);
  }
}

In case you're wondering, the reason why it's


#if UNITY_EDITOR

#elif UNITY_WEBGL

<code>

#endif


instead of just


#if UNITY_WEBGL

<code>

#endif


is because if you run a game made for WebGL from the Unity editor then both UNITY_EDITOR and UNITY_WEBGL are true, so the version with just #if UNITY_WEBGL will still run while you're in editor mode and write files to your hard drive.


--== Original ==--


Brief instructions: If you’re using C# scripting and the NewGrounds.io API you can 1) pop this code into the top of core.cs just after the “using” statements, 2) change the savePathName variable to be the number that shows up on NewGrounds in your game’s project page URL so it’s unique to your game if you haven't already published it yet*, 3) find/replace PlayerPrefs with NGData throughout your game's code.


*If you've already published your game then people's saves will be wiped out when you make this update, but will persist during any future updates. Note that PlayerPrefs doesn't save data in the same stringified format as this code, so simply looking in the same directory the PlayerPrefs function had been pointing to won't work.


Be aware that if you test your game out locally within the Unity environment, then it’ll save your game data to your hard drive under /idbfs/[game number]/NGsave.dat. It’s not really a particularly bad thing, but you should be aware that it'll happen so it doesn't take you off guard. I have not yet found a way to change that behavior.


// ***************************************************
// ** PUT YOUR SAVE PATH NAME IN THE VARIABLE BELOW **
// ***************************************************

// This class acts sort of as an emulator for
// PlayerPrefs, but saves to a specified
// place in IndexedDB so saved games don't
// get wiped when game updates are uploaded

// PlayerPrefs is similar (but not identical)
// to a hash of ints, floats, and strings, so
// this uses a Dictionary in C# (like a hash)

// Note that in this implementation all of
// the values are stored as strings even if
// they're really int or float, but they
// get parsed when values are returned

// Also note that I call Save() any time
// a value gets changed, because I think
// the risk of not saving stuff leading to
// bugginess outweighs the computational
// cost of saving more frequently.
// That could potentially be changed fairly
// easily as long as you know it's happening
// and that you just need to comment out the
// Save() statements at the end of the Set
// methods.

public static class NGData {
// **********************************
// ** PUT YOUR SAVE PATH NAME HERE **
// **********************************  
  static string savePathName = "12345";
  static string fileName;
  static string[] fileContents;
  static Dictionary<string, string> saveData = new Dictionary<string, string>();
  
  // This is the static constructor for the class
  // When invoked, it looks for a savegame file
  // and reads the keys and values
  static NGData() {
    fileName = "/idbfs/" + savePathName + "/NGsave.dat";
    
    // Open the savegame file and read all of the lines
    // into fileContents
    // First make sure the directory and save file exist,
    // and make them if they don't already
    // (If the file is created, the filestream needs to be
    // closed afterward so it can be saved to later)
    if (!Directory.Exists("/idbfs/" + savePathName)) {
      Directory.CreateDirectory("/idbfs/" + savePathName);
    }
    if (!File.Exists(fileName)) {
      FileStream fs = File.Create(fileName);
      fs.Close();
    } else {
      // Read the file if it already existed
      fileContents = File.ReadAllLines(fileName);
      
      // If you want to use encryption/decryption, add your
      // code for decrypting here
      //   ******* decryption algorithm ********
      
      // Put all of the values into saveData
      for (int i=0; i<fileContents.Length; i += 2) {
        saveData.Add(fileContents[i], fileContents[i+1]);
      }
    }
  }
  
  // This saves the saveData to the player's IndexedDB
  public static void Save() {
    // Put the saveData dictionary into the fileContents
    // array of strings
    Array.Resize(ref fileContents, 2 * saveData.Count);
    int i=0;
    foreach (string key in saveData.Keys) {
      fileContents[i++] = key;
      fileContents[i++] = saveData[key];
    }
    
    // If you want to use encryption/decryption, add your
    // code for encrypting here
    //   ******* encryption algorithm ********
    
    // Write fileContents to the save file
    File.WriteAllLines(fileName, fileContents);
  }
  
  // The following methods emulate what PlayerPrefs does
  public static void DeleteAll() {
    saveData.Clear();
    Save();
  }
  
  public static void DeleteKey(string key) {
    saveData.Remove(key);
    Save();
  }
  
  public static float GetFloat(string key) {
    return float.Parse(saveData[key]);
  }
  public static float GetFloat(string key, float defaultValue) {
    if (saveData.ContainsKey(key)) {
      return float.Parse(saveData[key]);
    } else {
      return defaultValue;
    }
  }
  
  public static int GetInt(string key) {
    return int.Parse(saveData[key]);
  }
  public static int GetInt(string key, int defaultValue) {
    if (saveData.ContainsKey(key)) {
      return int.Parse(saveData[key]);
    } else {
      return defaultValue;
    }
  }
  
  public static string GetString(string key) {
    return saveData[key];
  }
  public static string GetString(string key, string defaultValue) {
    if (saveData.ContainsKey(key)) {
      return saveData[key];
    } else {
      return defaultValue;
    }
  }
  
  public static bool HasKey(string key) {
    return saveData.ContainsKey(key);
  }
  
  public static void SetFloat(string key, float setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue.ToString();
    } else {
      saveData.Add(key, setValue.ToString());
    }
    Save();
  }
  
  public static void SetInt(string key, int setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue.ToString();
    } else {
      saveData.Add(key, setValue.ToString());
    }
    Save();
  }
  
  public static void SetString(string key, string setValue) {
    if (saveData.ContainsKey(key)) {
      saveData[key] = setValue;
    } else {
      saveData.Add(key, setValue);
    }
    Save();
  }
}

Now the TL,DR

It looks like a vast majority of Unity programmers use PlayerPrefs to store save game data since it’s easy. But when they do, if they ever need to update their game, everyone ends up having their savegame data wiped out.


That’s because Unity saves PlayerPrefs in IndexedDB, and when it does it creates a directory for the save files with the format /idbfs/[some huge hex string]/filename. The [some huge hex string] is based on the URL of the game’s files. If you upload an update to NewGrounds (or pretty much any hosting site) it might look like it’s still being hosted at the same URL in your browser, but will actually be stored at another URL and get a new hex string. So the update won’t know which directory to look in for the previous save files.


That’s kinda discussed in this thread

https://forum.unity.com/threads/persistentdatapath-returns-different-paths-for-differet-builds.526776/

And they talk about a solution of saving data not to /idbfs/[some huge hex string]/filename but instead to /idbfs/[a constant name]/filename. But they don’t explain how to do it.


It is possible to save files to arbitrary locations like that if you plan things out from the beginning, but if you’re like most Unity newbies like me and wrote a ton of code to store data in PlayerPrefs, then going back and redesigning your game to store info differently would be suck city. If you can just use PlayerPrefs instead of building your own save system then that's way easier. But there’s no good way to change where PlayerPrefs saves because Unity forces its behavior, and there’s no good way of pulling the data from PlayerPrefs out and putting it into a variable to save because PlayerPrefs doesn’t have a Keys() function to list all its keys so you can pull out all the data without worrying about whether you’re forgetting anything.


So I wrote my own class to emulate PlayerPrefs behavior, so you could literally just find/replace PlayerPrefs everywhere in your code and replace it with NGData and have all the PlayerPrefs functions work like they should, but you can specify what directory in /idbfs it saves into. It’s a static class so can’t be instantiated (and therefore can’t just be attached to a GameObject), but it can be embedded in any other code that’s placed on a GameObject, like the NewGrounds.io API. I imagine it would be possible to make the save directory be a public variable that could be set from the NewGrounds.io GameObject to be more user-friendly than making people edit the C# code since the App ID and encryption key can be set from the GameObject. The savePathName variable within the NGData class is static, so it can’t be a public variable attached to an object, but I imagine you could make a public variable that can be set from a GameObject and then make NGData read that instance variable into the static savePathName variable when it calls its constructor.


It has crossed my mind that you could use this approach to make a game series and have each game look in the same place for save data so you can see what players have done in different games in the series. Or you could have a collab of Unity devs create a mega-meta-game where each game in the collab interacts with the others through this save data.


10

Comments

Thank you for the update! This is really a great and thorough guide. I hope I get to try it soon.

About the data being saved when you test locally, I'd probably simply split the behaviour if you're not in WebGL, i.e.:

if (Application.platform != RuntimePlatform.WebGLPlayer) SaveNormally();
https://docs.unity3d.com/ScriptReference/Application-platform.html

or platform-dependent compilation
https://docs.unity3d.com/Manual/PlatformDependentCompilation.html

#if UNITY_EDITOR
SaveNormally();
#elif UNITY_WEBGL
Save()
#else
// whatever
#endif

Oh, that looks like it might work nicely by wrapping everything in the class definition in

[code]
public static class NGData {
#if UNITY_WEBGL
the current code
#else
// for each PlayerPrefs.FunctionName
public static void FunctionName(parameters) {
PlayerPrefs.FunctionName(parameters);
}
#endif
}
[/code]

Thank you for your post! I also discovered that you need to call sync function in javascript to ensure saved data is written into indexedDB.

Old Unity you can call this after you saved the data.
Application.ExternalEval("FS.syncfs(false, function (err) {})");

In newer Unity you need to create a .jslib and expose the function to Unity:
https://docs.unity3d.com/Manual/webgl-interactingwithbrowserscripting.html

And I wrote something like this in .jslib:

mergeInto(LibraryManager.library, {
WebIdbfsSync: function () {
FS.syncfs(false, function (err) {});
},
});

Then you can call WebIdbfsSync() in Unity. :D

This code has two issues.
One mentioned by puetsua.
Another one is that it completely breaks once you have a string with any new line characters.

Here is code that fixes both of them. I use it in my game.

File: NewgroundsPlayerPrefs.cs

#if UNITY_WEBGL && !UNITY_EDITOR
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;

public static class PlayerPrefs
{
public static string savePathName = "12345";
public static string DataPath => "/idbfs/" + savePathName + "/NGSave.dat";

[Serializable]
private class PlayerPrefsData
{
public List<KeyValue> data;
}

[Serializable]
private class KeyValue
{
public string Key;
public string Value;
}

static Dictionary<string, string> saveData = new Dictionary<string, string>();

// This is the static constructor for the class
// When invoked, it looks for a savegame file
// and reads the keys and values
static PlayerPrefs()
{
// Open the savegame file and read all of the lines
// into fileContents
// First make sure the directory and save file exist,
// and make them if they don't already

if (File.Exists(DataPath))
{
// Read the file if it already existed
string data = System.Text.Encoding.UTF8.GetString(File.ReadAllBytes(DataPath));

// If you want to use encryption/decryption, add your
// code for decrypting here
// ******* decryption algorithm ********

PlayerPrefsData ppd = JsonUtility.FromJson<PlayerPrefsData>(data);
if (ppd != null)
{
saveData = ppd.data.ToDictionary(t => t.Key, t => t.Value);
}
else Debug.LogError("WebGL PlayerPrefs wrong format!");
}
}

[DllImport("__Internal")]
private static extern void FlushIDBFS();

// This saves the saveData to the player's IndexedDB
public static void Save()
{
PlayerPrefsData ppd = new PlayerPrefsData
{data = saveData.Keys.Select(t => new KeyValue {Key = t, Value = saveData[t]}).ToList()};

// If you want to use encryption/decryption, add your
// code for encrypting here
// ******* encryption algorithm ********

// Write fileContents to the save file
string data = JsonUtility.ToJson(ppd);
byte[] bytedata = Encoding.UTF8.GetBytes(data);

File.WriteAllBytes(DataPath, bytedata);

FlushIDBFS();
}

// The following methods emulate what PlayerPrefs does
public static void DeleteAll()
{
saveData.Clear();
}

public static void DeleteKey(string key)
{
saveData.Remove(key);
}

public static float GetFloat(string key)
{
return float.Parse(saveData[key]);
}

public static float GetFloat(string key, float defaultValue)
{
if (saveData.ContainsKey(key))
{
return float.Parse(saveData[key]);
}
else
{
return defaultValue;
}
}

public static int GetInt(string key)
{
return int.Parse(saveData[key]);
}

public static int GetInt(string key, int defaultValue)
{
if (saveData.ContainsKey(key))
{
return int.Parse(saveData[key]);
}
else
{
return defaultValue;
}
}

public static string GetString(string key)
{
return saveData[key];
}

public static string GetString(string key, string defaultValue)
{
if (saveData.ContainsKey(key))
{
return saveData[key];
}
else
{
return defaultValue;
}
}

public static bool HasKey(string key)
{
return saveData.ContainsKey(key);
}

public static void SetFloat(string key, float setValue)
{
if (saveData.ContainsKey(key))
{
saveData[key] = setValue.ToString();
}
else
{
saveData.Add(key, setValue.ToString());
}
}

public static void SetInt(string key, int setValue)
{
if (saveData.ContainsKey(key))
{
saveData[key] = setValue.ToString();
}
else
{
saveData.Add(key, setValue.ToString());
}
}

public static void SetString(string key, string setValue)
{
if (saveData.ContainsKey(key))
{
saveData[key] = setValue;
}
else
{
saveData.Add(key, setValue);
}
}
}
#endif

File: FlushIDBFS.jslib

var FlushIDBFS = {
FlushIDBFS : function()
{
FS.syncfs(false, function (err) {});
},
};
mergeInto(LibraryManager.library, FlushIDBFS);

@IncontinentCell You forgot to initialize the Directory / File in your version!

@Frenchie14 Now that I look at it, it seems to be the case, but it appears to be working fine. Haven't gotten any complaints about saves disappearing. Let me quickly test it.
Just tested it by deleting all the files related to the game in IDBFS and it saved anyway. At least in chrome. I suppose that's because IDBFS is using a key value database and only emulates directories, which means it doesn't check if a directory exists. You can see what I mean by clicking "F12" -> "Application" -> "Indexed database"

@IncontinentCell Currently, without the directory initialization a brand new build *will* fail to do any operations. I have added the code from the original that checks the directory/file's existence and creates it if it doesn't exist and it now works. (Unity 2022 LTS)