--== 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
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.
Ralix
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
3p0ch
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]