Dynamic Settings System in Unity

Socially Distant is my upcoming Unity-based hacking game. This article isn’t going to go into detail about what Socially Distant is, but one thing I’ll talk about right now is its settings system. Almost every game you play has a settings screen that lets you adjust graphics quality, adjust volume, etc. With Socially Distant, I needed to build one that’s resilient to change. Here’s how I did it.

Traditional ways of doing settings (in Unity)

The Unity engine has a few built-in APIs for dealing with game settings. I’m not using them, but here’s what they are and here’s why I’m not using them.

PlayerPrefs

PlayerPrefs is an API inside Unity that’s built for, well, storing and managing player preferences. It’s convenient because you, as a developer, don’t need to worry about how the player preferences are stored. You do not need to load, parse, or save a configuration file since the engine does it for you. On Windows, for example, player preferences are saved to the Windows Registry.

PlayerPrefs is incredibly limited though. You are only limited to storing strings, integers, and floating-point (fractional) numbers. For some games, this may be all you need. But for a larger game like Restitched or even Socially Distant, that’s just not going to fly.

Let’s consider a simple SettingsManager class, like the one in Socially Distant. It’ll use PlayerPrefs to load and store settings, and will just contain properties for accessing these settings. It will have a method that lets you load, save, apply, and reset the settings.

using UnityEngine;

namespace Settings
{
    public class SettingsManager : MonoBehaviour
    {
        private bool _hardMode = false;
        private bool _colorblind = true;
        private bool _enableExcessiveBloom = true;
        private int _numRestitchedLeaks = 0;
        private string _resolution = "1920x1080";

        public void ResetSettings()
        {
            PlayerPrefs.DeleteAll();
            LoadSettings();
            ApplySettings();
        }

        public void SaveSettings()
        {
            PlayerPrefs.DeleteAll();
            PlayerPrefs.SetString("Resolution", _resolution);
            PlayerPrefs.SetInt("TrixelCreativeIsCool", _numRestitchedLeaks);

            if (_hardMode)
                PlayerPrefs.SetInt("HardMode", 1);
            if (_colorblind)
                PlayerPrefs.SetInt("Colorblind", 1);
            if (_enableExcessiveBloom)
                PlayerPrefs.SetInt("Bloom", 1);
        }
        
        public void LoadSettings()
        {
            _resolution = PlayerPrefs.GetString("Resolution", "1920x1080");
            _numRestitchedLeaks = PlayerPrefs.GetInt("TrixelCreativeIsCool", 1);
            _enableExcessiveBloom = PlayerPrefs.HasKey("Bloom");
            _hardMode = PlayerPrefs.HasKey("HardMode");
            _colorblind = PlayerPrefs.HasKey("Colorblind");
        }

        public void ApplySettings()
        {
            GameplayManager.Instance.HardMode = _hardMode;
            GameplayManager.Instance.ColorblindMode = _colorblind;

            if (_numRestitchedLeaks > 0)
            {
                GameplayManager.Instance.Halston.Panic();
            }
            else
            {
                GameplayManager.Instance.Halston.DoNotPanic();
            }

            DisplayManager.ConvolutedResolutionParsingMethodThatSetsTheUnityResolutionFromAString(_resolution);

            PostProcessing.Instance.SetBloomAmount(_enableExcessiveBloom ? float.PositiveInfinity : 0);
        }
        
        private void Awake()
        {
            LoadSettings();
        }

        private void Start()
        {
            ApplySettings();
        }
    }
}

In this code, we have 5 settings. A mix of graphics settings, difficulty settings, and me giving Trixel Creative a heart attack. …That aside, there are a few major problems with this:

  • You can’t store booleans. So, for the Bloom and difficulty settings, I can only check if a PlayerPrefs key is present. This means I need to delete all keys when saving settings.
  • Every setting needs to be loaded and saved, one by one. There’s no way to automate this. If you add a new setting, you now have to write code to load and save it.
  • Referencing data by a string. This is generally not a great thing to do, since it can be really hard to debug a typo.
  • You can’t store complex data structures. I had to store the screen resolution as a string because I cannot store Unity’s built-in Resolution type, without saving EVEN MORE keys.

It’s obvious to see that PlayerPrefs does not scale up very well with the amount of data you might want to store with it. So I’m not using it.

JsonUtility

Your next option for storing your game’s settings is using dear JSON. The downside is you now need to manage a configuration file on your own, but at least you don’t need to parse and write JSON on your own. This is because Unity ships with a built-in JSON library, called JsonUtility.

Let’s rewrite that above SettingsManager, but using the JsonUtility. I’ll start by creating a class that’ll contain all of my settings, and their default values.

    [Serializable]
    public class GameSettings
    {
        public int RestitchedLeaksCount = 1;
        public bool HardMode = false;
        public bool Colorblind = false;
        public bool Bloom = false;
        public string Resolution = "1920x1080";
    }

This class is marked as Serializable because JsonUtility requires you to do that. More on that later.

Now, I’ll rewrite the SettingsManager class to use the new API. I’ll store my configuration in the AppData folder. Before I begin though, please watch this quick video.

…Anyway…

using System;
using System.Diagnostics;
using System.IO;
using UnityEngine;

namespace Settings
{
    [Serializable]
    public class GameSettings
    {
        public int RestitchedLeaksCount = 1;
        public bool HardMode = false;
        public bool Colorblind = false;
        public bool Bloom = false;
        public string Resolution = "1920x1080";
    }
    
    public class SettingsManager : MonoBehaviour
    {
        // THOU SHALL NOT HARDCODE PATHS.
        private readonly string _settingFolder
            = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
                "dynamic_recompilation", "Socially Distant");

        // Will be created in Awake.
        private string _settingFile;
        
        private GameSettings _gameSettings = new GameSettings();

        public event Action<GameSettings> SettingsChanged; 

        public void ResetSettings()
        {
            _gameSettings = new GameSettings();
            ApplySettings();
        }

        public void SaveSettings()
        {
            var json = JsonUtility.ToJson(_gameSettings, true);
            File.WriteAllText(_settingFile, json);
        }
        
        public void LoadSettings()
        {
            if (File.Exists(_settingFile))
            {
                var json = File.ReadAllText(_settingFile);

                try
                {
                    _gameSettings = JsonUtility.FromJson(json);
                }
                catch (Exception ex)
                {
                    Debug.LogWarning(ex.ToString());
                    _gameSettings = new GameSettings();
                }
            }
            else
            {
                // Just create a new settings object and save.
                _gameSettings = new GameSettings();
            }
        }

        public void ApplySettings()
        {
            SettingsChanged?.Invoke(_gameSettings);
            SaveSettings();
        }
        
        private void Awake()
        {
            _settingFile = Path.Combine(_settingFolder, "settings.json");
            
            // Create the settings folder if it doesn't exist.
            if (!Directory.Exists(_settingFolder))
                Directory.CreateDirectory(_settingFolder);
            
            LoadSettings();
        }

        private void Start()
        {
            ApplySettings();
        }
    }
}

In this code, we’ve moved all settings to a separate class, GameSettings. We have written some code to create a folder for our game’s settings, and we have adjusted our loading and saving routines to use JSON and a file instead of PlayerPrefs. We also now fire off an event when settings are applied, so that our other gameplay systems can consume the event. They can do as they wish with the new settings. There are obvious benefits to these changes.

  • You can very easily add new settings. Just add them to the GameSettings class. Nothing else needs to be done. (…for the most part.)
  • You can edit the file. You might not have a user interface for editing the game’s settings yet, and that’s okay. Because your operating system does. You wrote your code with it.
  • You’re not using the Windows Registry. Controversial, I know, but I personally think the Registry (and various Linux equivalents) is the most ugly way to store data ever. Good luck moving that between computers or different OSes.
  • You don’t need singletons. You don’t need any direct reference to any other gameplay system, you can just let the entire game know that settings have been reloaded. This is tricky with PlayerPrefs since you either need to expose each setting publically (more code) or the gameplay system needs to load the setting from PlayerPrefs (more code + more hellish debugging).

But JsonUtility isn’t without its issues. From the Unity docs:

The JSON Serializer does not currently support working with unstructured JSON. That is, navigating and editing the JSON as an arbitrary tree of key-value pairs. If you need to do this, you should look for a more fully-featured JSON library.

In other words, if your settings don’t have a defined structure (for example, if someone writes a mod for the game that has its own settings you don’t know about), then there’s no way to load or save those settings. In the case of a mod, the mod author now needs to write their own settings system.

You are also limited to only saving information that can be saved as a Unity asset and edited by the Inspector. You therefore need to mark all of your custom types with [Serializable] which gets messy, and you have very little control over how the data’s serialized. For a larger, moddable game like Socially Distant, this isn’t good enough for me.

Doing This Properly

The proper way to write a settings manager is to do exactly what the Unity docs say. Use JSON, but use a third-party library. In my case, I’ll be using Newtonsoft.Json because it supports both structured JSON and unstructured JSON, and is very popular, tried and true. It should be noted that I’m using Unity 2020.3.4 (because it’s also what Restitched uses and I don’t want to deal with multiple editors), so I do not have access to System.Text.Json.

Settings Providers

Essentially, we need to create a system that lets any script register itself as a settings provider. A settings provider can be seen as the code behind a settings category in the menu, for example, the Graphics screen will have a Graphics Settings Provider. This settings provider will get an opportunity to load and save its settings to JSON, and apply the loaded settings to the game or engine state.

I’ll set this up as an interface that anyone can implement.

using Newtonsoft.Json.Linq;

namespace Settings
{
    public interface ISettings
    {
        JObject Serialize();
        void RestoreSettings(JObject settings);
        
        bool IsDirty { get; }
        void ApplySettings();
    }
}

There is a better way to do this, but I haven’t gotten that far in Socially Distant. Either way:

  • Serialize() and RestoreSettings() allow the settings provider to save and load its settings to and from the config file, respectively.
  • IsDirty tells the settings manager if any settings have been modified since the last time they were applied.
  • If the settings are dirty, ApplySettings() will be called by the settings manager to tell the provider to apply its new settings.

Here’s an example of Socially Distant’s graphics settings provider:

using System;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace Settings
{
    public class GraphicsSettings : ISettings
    {
        private GraphicsSettingsData _gfxSettings = new GraphicsSettingsData();
        private bool _isDirty = false;

        public bool IsDirty => _isDirty;

        public FullScreenMode FullscreenMode
        {
            get => _gfxSettings.FullscreenMode;
            set
            {
                if (_gfxSettings.FullscreenMode != value)
                {
                    _gfxSettings.FullscreenMode = value;
                    _isDirty = true;
                }
            }
        }
        
        public Resolution Resolution
        {
            get => _gfxSettings.Resolution;
            set
            {
                if (_gfxSettings.Resolution.ToString() != value.ToString())
                {
                    _gfxSettings.Resolution = value;
                    _isDirty = true;
                }
            }
        }
        
        public JObject Serialize()
        {
            return JObject.FromObject(_gfxSettings);
        }

        public void RestoreSettings(JObject settings)
        {
            _gfxSettings = settings.ToObject<GraphicsSettingsData>();
            _isDirty = true;
        }
        
        public void ApplySettings()
        {
            Screen.SetResolution(_gfxSettings.Resolution.width, _gfxSettings.Resolution.height,
                _gfxSettings.FullscreenMode, _gfxSettings.Resolution.refreshRate);
            _isDirty = false;
        }
        
        private class GraphicsSettingsData
        {
            public bool Bloom { get; set; } = true;
            public bool FrostedGlass { get; set; } = true;
            public bool Shadowmask { get; set; } = true;
            public FullScreenMode FullscreenMode { get; set; } = FullScreenMode.ExclusiveFullScreen;
            public bool VSync { get; set; } = true;
            public Resolution Resolution { get; set; }

            public GraphicsSettingsData()
            {
                Resolution = Screen.currentResolution;
            }
        }
    }
}

The Revamped SettingsManager

From here we can rebuild our settings manager. It’s going to be similar to our JsonUtility one, but we’ll have more fine-grain control over how the settings are loaded and saved.

We’ll start by having our settings manager store a list of all registered providers, and then allow other scripts to register new providers and get access to existing ones.

using System.Linq;

namespace Settings
{
    public class SettingsManager
    {
        private List<ISettings> _providers = new List<ISettings>();

        public T RegisterSettings<T>() where T : ISettings, new()
        {
            var provider = new T();
            _providers.Add(provider);
            return provider;
        }

        public void RegisterSettings<T>(T provider) where T : ISettings
        {
            _providers.Add(provider);
        }

        public T GetSettings<T>() where T : ISettings
            => _providers.OfType<T>().First();
    }
}

From here, we can delete our GameSettings class. It’s up to the provider to create their own JSON object to serialize and deserialize. Because we’ve deleted GameSettings, we can modify our SettingsChanged event:

public event Action SettingsChanged;

Since it’s expected that anyone consuming that event can read settings from the relevant provider.

We will also need to remove the ability to restore default settings, because I haven’t figured that out yet. But it’s something I’ll work on.

We’ll also need to rewrite our LoadSettings() and SAveSettings() methods, to look something like these.

        public void LoadSettings()
        {
            Debug.Log("Reloading the configuration...");
            if (File.Exists(_settingFile))
            {
                var json = File.ReadAllText(_settingFile);

                var dict = JObject.Parse(json);

                foreach (var settingsProvider in _providers)
                {
                    var name = settingsProvider.GetType().Name;
                    if (dict.ContainsKey(name))
                    {
                        if (dict[name] is JObject data)
                            settingsProvider.RestoreSettings(data);
                    }
                }
            }
            else
            {
                Debug.Log("Config doesn't exist. Saving the defaults.");
                SaveSettings();
            }

            ApplySettings();
        }

        public void SaveSettings()
        {
            Debug.Log("Saving configuration...");

            // this is where the JSON comes from.
            var jsonObject = new JObject();

            // Collect settings objects.
            foreach (var settingsProvider in _providers)
            {
                var settingsObject = settingsProvider.Serialize();
                jsonObject.Add(settingsProvider.GetType().Name, settingsObject);
            }

            // Save it to JSON!
            var json = jsonObject.ToString(Formatting.Indented);

            // And save it to the file.
            File.WriteAllText(_settingFile, json);
        }

With this code, each provider is given its chance to serialize/restore its JSON. When saving, we collect each provider’s JSON into a root JSON object where each property is named after the provider’s type and the value of the property is the provider’s settings. This gets saved to the config file and will look like the JSON written below. When loading settings, we do the same thing – but in reverse.

{
  "GraphicsSettings": {
    "Bloom": true,
    "FrostedGlass": true,
    "Shadowmask": true,
    "FullscreenMode": 3,
    "VSync": true,
    "Resolution": {
      "width": 3440,
      "height": 1440,
      "refreshRate": 75
    }
  },
  "SoundSettings": null,
  "GameplaySettings": null,
  "KeybindSettings": null
}

This technique gets you LOTS of benefits.

  • Mods don’t have to store settings on their own. They can register their own settings providers and go through the exact same settings API as your game itself.
  • It’s resiliant to change. A provider may fundamentally change the way it handles its config data, but you don’t need to worry about that. Likewise, you can change how the settings manager works without needing to touch any of the providers unless you modify the ISettings interface.
  • You don’t need to care about the settings themselves inside the settings manager. The actual configuration data stored inside the settings file is now an implementation detail for the game, and you can completely re-use this settings manager in any game you make. It’s not even tied directly to Unity, so you can port it to other engines.
  • You can store almost any C# type. Lists. Dictionaries. Classes. Structs. Complex objects. You are no longer limited by Unity’s asset serializer.

And you don’t need to do any work!

I’m a nice developer, so, here’s the entire Socially Distant source code for its settings system. You can plop it into your Unity project, and as long as you have Newtonsoft.Json, it’s as easy as creating the Settings Manager Holder scriptable object and then adding the Settings Manager Bootstrap script to your scene and making sure it references the holder object.

You’ll need to provide your own settings providers, but use the example above. Make sure each class is in its own file.

// ISettings.cs
using Newtonsoft.Json.Linq;

namespace Settings
{
    public interface ISettings
    {
        JObject Serialize();
        void RestoreSettings(JObject settings);
        
        bool IsDirty { get; }
        void ApplySettings();
    }
}

// SettingsManager.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;

namespace Settings
{
    public class SettingsManager
    {
        private readonly string _configPath;
        private List<ISettings> _settings = new List<ISettings>();

        public string GameDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "Michael VanOverbeek", "SOCIALLY DISTANT");

        internal SettingsManager()
        {
            Debug.Log($"Game data directory is {GameDataPath}.");
            
            if (!Directory.Exists(GameDataPath))
            {
                Directory.CreateDirectory(GameDataPath);
                Debug.Log($"Game data directory has been created.");
            }

            _configPath = Path.Combine(GameDataPath, "config.json");
            Debug.Log($"Configuration path is {_configPath}");
            
            // TODO: Register core settings. Uncomment these as you add them.
            // RegisterSettings<GraphicsSettings>();
            // RegisterSettings<SoundSettings>();
            // RegisterSettings<GameplaySettings>();
            // RegisterSettings<KeybindSettings>();
        }

        internal void ReloadSettings()
        {
            Debug.Log("Reloading the configuration...");
            if (File.Exists(_configPath))
            {
                var json = File.ReadAllText(_configPath);

                var dict = JObject.Parse(json);

                foreach (var settingsProvider in _settings)
                {
                    var name = settingsProvider.GetType().Name;
                    if (dict.ContainsKey(name))
                    {
                        if (dict[name] is JObject data)
                            settingsProvider.RestoreSettings(data);
                    }
                }
            }
            else
            {
                Debug.Log("Config doesn't exist. Saving the defaults.");
                SaveSettings();
            }

            ApplyChanges();
        }

        public void SaveSettings()
        {
            Debug.Log("Saving configuration...");

            // this is where the JSON comes from.
            var jsonObject = new JObject();
            
            // Collect settings objects.
            foreach (var settingsProvider in _settings)
            {
                var settingsObject = settingsProvider.Serialize();
                jsonObject.Add(settingsProvider.GetType().Name, settingsObject);
            }
            
            // Save it to JSON!
            var json = jsonObject.ToString(Formatting.Indented);
            
            // And save it to the file.
            File.WriteAllText(_configPath, json);
        }

        public T RegisterSettings<T>() where T : ISettings, new()
        {
            Debug.Log($"Registering settings provider: {typeof(T).FullName}");

            var settings = new T();
            _settings.Add(settings);

            return settings;
        }

        public T GetSettings<T>() where T : ISettings, new()
            => _settings.OfType<T>().First();

        public void ApplyChanges()
        {
            Debug.Log("Applying settings...");
            foreach (var settings in _settings)
            {
                if (settings.IsDirty)
                    settings.ApplySettings();
            }
        }
    }
}

// ScriptableHolder.cs
using UnityEngine;

namespace Core
{
    public abstract class ScriptableHolder<T> : ScriptableObject
    {
        private T _value;

        public T Value
        {
            get => _value;
            set => _value = value;
        }
        
    }
}

// SettingsManagerHolder.cs
using Core;
using UnityEngine;

namespace Settings
{
    [CreateAssetMenu(menuName = "Holders/Settings Manager")]
    public class SettingsManagerHolder : ScriptableHolder<SettingsManager>
    {
        
    }
}

// SettingsManagerBootstrap.cs
using System;
using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.Assertions;

namespace Settings
{
    public class SettingsManagerBootstrap : MonoBehaviour
    {
        [SerializeField]
        [NotNull]
        private SettingsManagerHolder settingsHolder;

        private void Awake()
        {
            Assert.IsNotNull(settingsHolder);
            settingsHolder.Value = new SettingsManager();
        }

        private void Start()
        {
            settingsHolder.Value.ReloadSettings();
        }

        private void OnDestroy()
        {
            settingsHolder.Value.SaveSettings();
        }
    }
}

If you’re wondering why I’m doing all of this Scriptable Object nonsense instead of using a singleton, it’s a code design trick I learned from Restitched development – that maybe I’ll blog about some other time.

Leave a Reply