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.

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.

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.
## 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
andgraphics_settings
variables from theSettingsManager
. - 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.
