Simplifying User Settings in Godot

If I'm playing with an audio slider, I want to hear the feedback immediately so I can pick the right level quickly. So I set out to make my settings apply immediately.

Settings screen showing various graphical settings

I was working on the settings screen of my game. What I had already built required the user to separately apply the settings rather than have them immediately reflected. I often find this annoying in games. If I'm playing with an audio slider, I want to hear the feedback immediately so I can pick the right level quickly. So I set out to make my settings apply immediately.

Settings screen showing various graphical settings

I got a little sidetracked when I realized user might want to reset to defaults or revert their setting without saving. A whole 8 hours later I had built out a nice little base class called BaseSettings that would handle automatically saving and loading settings properties from files.

Features:

  • Defined properties of script can be automatically loaded from and saved to config files.
  • Signal support
  • Automatic ConfigFile section naming
  • Extensible

BaseSettings

The core of this functionality is this BaseSettings class. When you extend it in a new class, for example class_name AudioSettings extends BaseSettings , you get the ability to automatically save the properties of AudioSettings to a file. All the required methods are contained in this base class.

The files saved are of type .cfg by using Godot built in ConfigFile support.

🔗
Find the latest versions of my Godot scripts on my Github
## BaseSettings is an extendable settings manager.
## It automatically handles saving and loading user-defined script variables to/from config files.
## To use it, simply extend this class and define properties with getters/setters as needed.
##
## Only script-defined variables (not internal or default-prefixed ones) are serialized.
## Emits a signal when settings are updated from a file.
class_name BaseSettings extends RefCounted

## Emitted when the settings have been updated (e.g., loaded from file).
signal settings_updated(settings:BaseSettings)

const DEFAULT_SETTINGS_FILE_PATH:String = "user://default_settings.cfg"
const SETTINGS_FILE_PATH:String = "user://user_settings.cfg"

var section_name:String

## Constructor. Initializes the section name based on the class name of the script.
func _init():
    var name_of_class = get_script().get_global_name()
    if name_of_class:
        section_name = name_of_class

## Saves the current settings to the given configuration file path.
func save_to_file(settings_file_path:String) -> BaseSettings:
    var config_file:ConfigFile = ConfigFile.new()
    if FileAccess.file_exists(settings_file_path):
        var err := config_file.load(settings_file_path)
        if err != OK:
            push_error("Failed to load config file: %s (Error code: %d)" % [settings_file_path, err])
            return
    _write_to_config(config_file)
    config_file.save(settings_file_path)
    return self

## Loads settings from the given configuration file path.
func load_from_file(settings_file_path:String) -> bool:
    var config_file:ConfigFile = ConfigFile.new()
    if !FileAccess.file_exists(settings_file_path):
        push_warning("BaseSettings: file does not exist - " + settings_file_path)
        return false
    var err := config_file.load(settings_file_path)
    if err != OK:
        push_error("Failed to load config file: %s (Error code: %d)" % [settings_file_path, err])
        return false
    _update_from_config(config_file)
    return true

## Saves the current settings to the user-specific settings file.
func save_to_user_settings_file():
    save_to_file(SETTINGS_FILE_PATH)

## Loads settings from the user-specific settings file.
func load_from_user_settings_file():
    load_from_file(SETTINGS_FILE_PATH)

## Saves the current settings to the default settings file.
func save_to_defaults_file():
    save_to_file(DEFAULT_SETTINGS_FILE_PATH)

## Loads settings from the default settings file.
func load_from_defaults_file():
    load_from_file(DEFAULT_SETTINGS_FILE_PATH)

## Emits the settings_updated signal with this instance as an argument.
func emit_update_signal():
    settings_updated.emit(self)

## Internal method to write settings to a given ConfigFile.
## Only valid script-defined variables are written.
func _write_to_config(config:ConfigFile):
    for property_name in _get_valid_property_list():
        config.set_value(section_name, property_name, self[property_name])

## Internal method to update properties from a ConfigFile.
## Only properties that exist and are script-defined are updated.
func _update_from_config(config:ConfigFile):
    #Cancel if section doesn't exist
    if !config.has_section(section_name):
        return
    var valid_properties := _get_valid_property_list()
    for property_name in config.get_section_keys(section_name):
        if valid_properties.has(property_name):
            self[property_name] = config.get_value(section_name, property_name)

## Returns a list of property names that are considered valid for saving/loading.
## These include script-defined variables excluding internal, default-prefixed, and ignored ones.
func _get_valid_property_list() -> Array[String]:
    var property_names:Array[String]
    for property in get_property_list():
        if property["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE:
            var property_name:String = property["name"]
            if property_name != "section_name":
                property_names.push_back(property_name)
    return property_names

base_settings.gd

Example using AudioSettings

In this example I have implemented an AudioSettings class. The most important part are the property names. Let's say my script looks like this.

class_name AudioSettings extends BaseSettings

var main_volume:float
var music_volume:float
var sound_fx_volume:float

Now let's say I run the following code.

var audio_settings = AudioSettings.new()
audio_settings.main_volume = 0.5
audio_settings.music_volume = 0.5
audio_settings.sound_fx_volume = 0.5

audio_settings.save_to_user_settings_file()

If I find the file that was created in ~\AppData\Roaming\Godot\app_userdata\CursorIncremental I can see the the properties were written directly into config file.

[AudioSettings]

main_volume=0.5
music_volume=0.5
sound_fx_volume=0.5

Separate Setting script can share the same settings file, as they will be separated by the section name, which is automatically set by the class_name.

[AudioSettings]

main_volume=0.9
music_volume=0.25
sound_fx_volume=0.58

[GraphicsSettings]

vsync_mode=0
water_enabled=false
display_mode=4

Real Time Settings

Like I mentioned, I originally set out to make real time settings. And I solved it by defining the getters and setter directly read and write to the target underlying Godot settings. In this example you can se I'm reading and writing directly to the audio bus settings.

The AudioSettings class fully encapsulates the control of managing the settings with the ability to save and load to config files. Because the settings are setup in this fashion, on running audio_settings.load_from_user_settings_file(), the setting are automatically applied to the underlying Godot resources.

class_name AudioSettings extends BaseSettings

const BUS_MAIN_NAME = "Master"
const BUS_MUSIC_NAME = "Music"
const BUS_SOUND_FX_NAME = "SoundFx"

var main_volume:float:
    get():
        return _get_volume_linear(BUS_MAIN_NAME)
    set(value):
        _set_volume_linear(BUS_MAIN_NAME, value)
var music_volume:float:
    get():
        return _get_volume_linear(BUS_MUSIC_NAME)
    set(value):
        _set_volume_linear(BUS_MUSIC_NAME, value)
var sound_fx_volume:float:
    get():
        return _get_volume_linear(BUS_SOUND_FX_NAME)
    set(value):
        _set_volume_linear(BUS_SOUND_FX_NAME, value)

func _set_bus_volume_linear(bus_name:String, value:float):
    clamp(value, 0.0, 1.0)
    _set_volume_linear(bus_name, value)
    SignalBus.emit_settings_updated(self.get_script())

func _get_volume_linear(bus_name: String) -> float:
    var index := AudioServer.get_bus_index(bus_name)
    return AudioServer.get_bus_volume_linear(index)

func _set_volume_linear(bus_name: String, linear_volume:float):
    linear_volume = clamp(linear_volume, 0.0, 1.0)
    var index := AudioServer.get_bus_index(bus_name)
    AudioServer.set_bus_volume_linear(index, linear_volume)
    emit_update_signal()

audio_settings.gd

Singleton Access and Defaults

I tried to use static variables and methods to create simplified singleton access to these settings but failed. I had to fall back to Godot's Singleton Autoloads.

I created a SettingsManager singleton class that allows any part of the game to access these settings. I don't want to allow more than one AudioSettings to exist at once as they will both be able to manage the underlying Godot audio settings.

The other nice thing thing that can be done, is that on startup the settings are read from the current project settings and dynamically stored into a default config file. This way I don't need to manage a separate set of defaults, I can simply update the project settings, and the user can always reset to the default by simply calling SettingsManager.audio_settings.load_from_default_settings_file()

extends Node

var audio_settings = AudioSettings.new()
var graphics_settings = GraphicsSettings.new()

func _ready():
    # Update default with the current default settings defined in editor
    audio_settings.save_to_defaults_file()
    graphics_settings.save_to_defaults_file()

    # Load up the current saved user settings
    audio_settings.load_from_user_settings_file()
    graphics_settings.load_from_user_settings_file()

settings_manager.gd

Hooking up to the UI

The UI is mostly a matter of connecting UI elements to specific setting and buttons to enable saving and loading config.

The key functionality in order is

  • getting a reference to the audio_settings and graphics_settings variables from the SettingsManager.
  • Update the UI to match the settings.
  • Connect the UI elements to the settings properties via signals.
  • Button actions for Save, Revert, and Defaults
extends CanvasLayer

var config_file:ConfigFile = ConfigFile.new()

@onready var audio_settings = SettingsManager.audio_settings
@onready var graphics_settings = SettingsManager.graphics_settings

# Called when the node enters the scene tree for the first time.
func _ready():

    %SaveAndCloseButton.pressed.connect(on_save_and_close_button_pressed)
    %RevertButton.pressed.connect(on_revert_button_pressed)
    %DefaultsButton.pressed.connect(on_defaults_button_pressed)

    update_visual_ui_from_settings()
    update_audio_ui_from_settings()

    connect_visual_ui_to_settings()
    connect_audio_ui_to_settings()

func update_visual_ui_from_settings():
    var display_mode_index = %DiplayModeDropdown.get_item_index(graphics_settings.display_mode)
    %DiplayModeDropdown.select(display_mode_index)

    %BorderlessButton.set_pressed(graphics_settings.borderless_enabled)

    if RenderingServer.get_current_rendering_method() == "gl_compatibility":
        %Vsync.visible = false
    else:
        var mode_index = %VsyncDropdown.get_item_index(graphics_settings.vsync_mode)
        %VsyncDropdown.select(mode_index)

    %WaterEffectButton.set_pressed(graphics_settings.water_enabled)

func connect_visual_ui_to_settings():
    %DiplayModeDropdown.item_selected.connect(
        func(val:int): graphics_settings.display_mode = %DiplayModeDropdown.get_selected_id()
    )
    %BorderlessButton.toggled.connect(
        func(val:bool): graphics_settings.borderless_enabled = val
    )
    %VsyncDropdown.item_selected.connect(
        func(val:int): graphics_settings.vsync_mode = %VsyncDropdown.get_selected_id()
    )
    %WaterEffectButton.toggled.connect(
        func(val:bool): graphics_settings.water_enabled = val
    )

func update_audio_ui_from_settings():
    %MainAudioSlider.value = audio_settings.main_volume
    %MusicAudioSlider.value = audio_settings.music_volume
    %SoundFxAudioSlider.value = audio_settings.sound_fx_volume
    %AmbientAudioSlider.value = audio_settings.ambient_volume

func connect_audio_ui_to_settings():
    %MainAudioSlider.value_changed.connect(func(val:float): audio_settings.main_volume = val)
    %MusicAudioSlider.value_changed.connect(func(val:float): audio_settings.music_volume = val)
    %SoundFxAudioSlider.value_changed.connect(func(val:float): audio_settings.sound_fx_volume = val)
    %AmbientAudioSlider.value_changed.connect(func(val:float): audio_settings.ambient_volume = val)

func save_setting():
    audio_settings.save_to_user_settings_file()
    graphics_settings.save_to_user_settings_file()

func revert_settings():
    graphics_settings.load_from_user_settings_file()
    update_visual_ui_from_settings()
    audio_settings.load_from_user_settings_file()
    update_audio_ui_from_settings()

func reset_to_default_settings():
    graphics_settings.load_from_defaults_file()
    update_visual_ui_from_settings()

    audio_settings.load_from_defaults_file()
    update_audio_ui_from_settings()

func close_settings_screen():
    queue_free()

func on_save_and_close_button_pressed():
    save_setting()
    close_settings_screen()

func on_revert_button_pressed():
    revert_settings()

func on_defaults_button_pressed():
    reset_to_default_settings()

And that's it! Once it's all setup an in place it's really easy to iterate on more settings.

Settings screen showing various graphical settings