doc 4 of 5

Part 03 — Settings that survive a restart

You will build: A settings screen with a master volume slider and a fullscreen toggle, written to a ConfigFile in user:// and applied on boot — so the game starts in the state the player left it, not the state the scene file remembers.

The pause menu from Part 02 can already stop the world. Now it needs a second door: a screen where the player changes something about the game itself. Two controls is enough to hit every problem this part exists for.

Start with the volume slider, and start by getting it wrong on purpose.

The slider that does nothing#

Make a new scene, root Control, name it SettingsMenu, save it as res://ui/settings_menu.tscn. Inside it put a PanelContainer, a VBoxContainer, and three children: an HSlider named VolumeSlider, a CheckButton named FullscreenCheck, and a Button named BackButton.

Select VolumeSlider and set, in the inspector, Min Value 0, Max Value 1, Step 0.01. Two of those three are the interesting ones and both come back later.

Attach this script to the root:

extends Control

@onready var volume_slider: HSlider = $PanelContainer/VBoxContainer/VolumeSlider

func _ready() -> void:
	volume_slider.value_changed.connect(_on_volume_slider_value_changed)

func _on_volume_slider_value_changed(value: float) -> void:
	AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), value)

That reads like exactly what you want. There is a bus called Master, you look it up, you set its volume from the slider. Add an AudioStreamPlayer with any looping music on it, run the scene, and drag the slider from one end to the other.

The music does not change. Not a little — not at all.

Nothing errored. The signal fired (put a print(value) in the handler and watch the numbers march from 0 to 1). The bus exists. The audio is playing. Every part of the system is working, and the result is silence-shaped nothing.

Decibels, and why the whole slider was one of them#

set_bus_volume_db takes decibels. Your slider hands it numbers between 0.0 and 1.0. So dragging the slider across its entire travel changed the bus volume by one decibel.

One decibel is, for most people on most speakers, inaudible. You built a slider whose full range is a rounding error.

Decibels are not a linear scale of loudness, they are a logarithmic scale of power ratio. 0 dB means "unchanged". -6 dB is roughly half the amplitude. -80 dB is effectively off. The useful range for a volume control is something like -60 to 0 — which is why a control that outputs 0 to 1 and a setter that expects -60 to 0 produce a result that looks broken but is arithmetically perfect.

The engine cannot warn you about this. float is float. Units live in your head, not in the type system, and this is the single most common way that goes wrong in Godot audio.

The fix is a conversion function:

func _on_volume_slider_value_changed(value: float) -> void:
	AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), linear_to_db(value))

linear_to_db is a global function, so there is no prefix on it. The class reference describes it as converting linear energy to decibels, and specifically says this is how you implement volume sliders that behave as expected. The documented example is a slider ranging 0.0 to 1.0 fed through linear_to_db — the slider range is part of the contract, not a detail. A slider from 0 to 100 through linear_to_db gives you a control that is at full volume for its top nine tenths.

Run it again. Now the slider does what a volume slider does.

There is also AudioServer.set_bus_volume_linear, which takes the 0..1 value directly and does the conversion internally — the docs state it is equivalent to calling set_bus_volume_db with linear_to_db applied. It is newer than most tutorials, and it is the shorter line.

Learn the conversion anyway, and here is the honest reason: if set_bus_volume_linear is the first thing you meet, you have a magic word instead of a model. The next time you set a volume on an AudioStreamPlayer — where the property is volume_db and there is no linear variant to rescue you — you will feed it a 0..1 number and spend an hour on it. The unit mismatch is the lesson. The convenience function is a shortcut through a door you now know how to open.

The bus name is a string that nothing checks#

Look at the lookup again:

AudioServer.get_bus_index("Master")

The docs are blunt about the failure case: it returns the index of the bus with that name, and -1 if no bus with that name exists. Not an error. Not a null. A number, which then goes straight into set_bus_volume_db(-1, ...) and does nothing.

Three ways to land there:

  • You typed "master". Bus names are case-sensitive.
  • You renamed the bus in the Audio bottom panel to something like "Music", and the string in your script still says the old name. Renaming a bus does not touch your code, because your code is holding a string, and a string is not a reference.
  • You typed the name of a bus you meant to add and never did.

So check it. Any place you look up a bus by name, the lookup and the check belong together:

var bus_index: int = AudioServer.get_bus_index("Master")
if bus_index == -1:
	push_error("No audio bus named 'Master'. Check the Audio panel.")
	return

That turns a silent nothing into a line in the Debugger's Errors tab with the actual cause in it. Three lines to convert a mystery into a message.

Zero should be silence#

Drag the slider all the way down. Listen closely. On quiet music you will hear it; on a loud track you may not.

linear_to_db(0.0) returns a very large negative number — the mathematical limit of "no energy". In practice the mixer treats it as inaudible, and for most games that is fine. But "inaudible" and "off" are different claims, and a player who set volume to zero means off.

The honest version mutes the bus at zero:

AudioServer.set_bus_mute(bus_index, is_zero_approx(value))

is_zero_approx rather than value == 0.0, because the slider's value is a float that arrives via step arithmetic and dragging. Comparing floats with == is a habit that works right up until it silently doesn't.

Now make it survive quitting#

Everything so far dies when the game closes. Time to write it down.

ConfigFile is Godot's built-in reader/writer for INI-style files: sections, keys, and values. A settings file with two settings in it looks like this on disk:

[audio]
master_volume=0.75

[video]
fullscreen=true

Sections are yours to invent — they are folders for keys, and their only job is stopping volume in the audio settings from colliding with volume in some other subsystem later. Values are Variants, so floats, bools, ints, Strings, and Vector2s all round-trip without you serialising anything.

The API you need is four calls: set_value, get_value, save, load. The interesting part is not the calls. It is the path you hand to save.

user:// versus res://, and the bug that only exists after export#

Write your settings to res://settings.cfg and it will work. In the editor. Every time. For as long as you develop the game.

Then you export a build, the player changes the volume, quits, reopens, and the volume is back to default. Nothing in your code changed. Nothing errored on their machine either.

The docs state the reason plainly: when the game is running, the project's file system will likely be read-only. res:// in an exported build is not a folder — it is the inside of your packed game data. There is nowhere to write. Meanwhile user:// is a real directory that is created automatically and is guaranteed writable, even in an exported project.

On Windows it lands in %APPDATA%\Godot\app_userdata\[project name] by default. You can move it to %APPDATA%\[project name] with the application/config/use_custom_user_dir project setting, which is worth doing before you ship anything, and worth ignoring today.

Rule with no exceptions: anything the player creates goes in user://. Settings, saves, screenshots, logs. res:// is for things you made.

This is the worst class of bug in this whole shelf — one that cannot reproduce in the environment where you develop. The only defence is knowing the rule before you need it. The same rule governs save files, which the save/load shelf works through in more depth.

Reading defensively#

Two ConfigFile behaviours will bite you on the read path, and both look like bugs when you meet them cold.

First launch has no file. load() returns an Error. On the very first run the file does not exist, so it returns something other than OK, and that is the normal path — not a crash, not something to fix. Check it and return:

var err: Error = config.load(CONFIG_PATH)
if err != OK:
	return

If you skip that check you go on to read from an empty ConfigFile, which walks you straight into the second one.

A missing key with no default is an engine error. get_value(section, key, default) returns the fallback when the section or key is absent — but the docs continue: if default is not specified or set to null, an error is also raised. So a missing key does not quietly hand back null. It complains.

Which means every read supplies its default inline:

master_volume = float(config.get_value("audio", "master_volume", DEFAULT_MASTER_VOLUME))

And that gives you something for free. You were going to need first-run defaults anyway. Written this way, the default is the fallback — one constant, one place, no separate "if this is the first launch" branch anywhere in your code. The wrapping float(...) is there because get_value returns a Variant; the conversion is what lets you assign it to a typed variable without the compiler objecting.

The silent delete#

One more ConfigFile behaviour, quoted because the wording matters: passing a null value deletes the specified key if it exists, and deletes the section if it ends up empty once the key has been removed.

So this:

config.set_value("audio", "master_volume", some_variable_that_is_null)

does not save null. It erases the player's saved volume, and if that was the only key in [audio], it erases the section too. No error. No warning. The file quietly gets smaller.

You will not write null on purpose. You will write it by accident, from a variable that was never assigned, or a get_node that returned nothing, or a function whose early return had no value. Which is the argument for the structure in the next section: settings live as typed variables with typed defaults on one object, and that object is the only thing that ever calls set_value. A typed float cannot be null.

The fullscreen toggle, and the sting that follows it#

Window mode is reachable two ways:

DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
get_window().mode = Window.MODE_FULLSCREEN

Both are correct. DisplayServer is the low-level service — it takes a window_id that defaults to 0, the main window, which is why single-window games call it with one argument. get_window() is the node-graph handle to that same window. The enums have the same five values in the same order. Pick one and be consistent; there is no hidden difference to worry about, and the fact that tutorials use both is why this paragraph exists.

The values you care about are WINDOW_MODE_WINDOWED and WINDOW_MODE_FULLSCREEN. There is also WINDOW_MODE_EXCLUSIVE_FULLSCREEN, which has less overhead but allows only one window on a screen at a time, and which the docs note may not work with screen recording software. For a settings menu, plain fullscreen is the safer default.

Now the sting. From the window_set_mode docs: setting the window to full screen forcibly sets the borderless flag to true, so make sure to set it back to false when not wanted.

Read that again in terms of what you will actually experience. Player goes fullscreen. Player comes back to windowed. The window returns to its old size, but with no title bar, no close button, and no way to drag it. The window looks broken, and the cause is a flag set by a call you made two clicks ago, that the reverse call does not unset.

So the windowed branch has two lines in it:

DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
get_window().borderless = false

One more thing you may hit here: going fullscreen changes the window size to match the monitor, and if your project has no stretch mode configured, the UI either sits tiny in a corner or gets clipped. That is a Project Settings → Display → Window → Stretch problem wearing a menu bug's clothing. Setting Stretch Mode to canvas_items and Aspect to expand is a reasonable starting point for a 2D game.

The structural point: apply on boot, not only on change#

Here is the version of this feature that almost works, and that a lot of shipped hobby games actually contain.

All the applying lives in the settings menu's _on_..._changed handlers. Save on exit, load on entering the menu, put the values back into the controls. Test it: change the volume, quit, relaunch, open settings — the slider is where you left it. Correct.

Except the game was loud from the moment it booted, and only became quiet when you opened the menu.

The settings loaded fine. Nothing applied them, because the only code that applies anything is a signal handler that fires when a control changes, and on boot no control changed — the settings menu is not even in the tree yet.

So the applying cannot live in the menu. It has to live somewhere that exists at startup and outlives every scene change. That is an autoload.

Create res://autoload/settings_manager.gd:

extends Node

const CONFIG_PATH: String = "user://settings.cfg"

const SECTION_AUDIO: String = "audio"
const SECTION_VIDEO: String = "video"

const DEFAULT_MASTER_VOLUME: float = 0.8
const DEFAULT_FULLSCREEN: bool = false

var master_volume: float = DEFAULT_MASTER_VOLUME
var fullscreen: bool = DEFAULT_FULLSCREEN


func _ready() -> void:
	load_settings()
	apply_all()


func load_settings() -> void:
	var config: ConfigFile = ConfigFile.new()
	var err: Error = config.load(CONFIG_PATH)
	if err != OK:
		# No file yet. First launch. The defaults above are already correct.
		return
	master_volume = float(config.get_value(SECTION_AUDIO, "master_volume", DEFAULT_MASTER_VOLUME))
	fullscreen = bool(config.get_value(SECTION_VIDEO, "fullscreen", DEFAULT_FULLSCREEN))


func save_settings() -> void:
	var config: ConfigFile = ConfigFile.new()
	config.set_value(SECTION_AUDIO, "master_volume", master_volume)
	config.set_value(SECTION_VIDEO, "fullscreen", fullscreen)
	var err: Error = config.save(CONFIG_PATH)
	if err != OK:
		push_error("Could not save settings to %s (error %d)" % [CONFIG_PATH, err])


func apply_all() -> void:
	set_master_volume(master_volume)
	set_fullscreen(fullscreen)


func set_master_volume(value: float) -> void:
	master_volume = clampf(value, 0.0, 1.0)
	var bus_index: int = AudioServer.get_bus_index("Master")
	if bus_index == -1:
		push_error("No audio bus named 'Master'. Check the Audio panel.")
		return
	AudioServer.set_bus_volume_db(bus_index, linear_to_db(master_volume))
	AudioServer.set_bus_mute(bus_index, is_zero_approx(master_volume))


func set_fullscreen(enabled: bool) -> void:
	fullscreen = enabled
	if enabled:
		DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
	else:
		DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
		get_window().borderless = false

Register it: Project → Project Settings → Globals → Autoload, path res://autoload/settings_manager.gd, node name Settings.

Note what the autoload does not do. It has no _process, no input handling, no signals it listens to. It is a box of state with functions on it. The menu calls those functions directly, and a direct call is not processing — so the pause state from Part 02 has no bearing on any of this, and there is nothing to configure about its process mode.

The shape to hold on to: one apply path, two callers. _ready calls it at boot with the loaded values. The menu calls it when the player moves a control. There is exactly one function that knows how to turn master_volume into an actual bus volume, and if you change how volume is applied you change it in one place and both callers get it.

If a stale value ever shows up on boot, you know the load didn't happen. If it shows up only until you open the menu, you know the apply didn't happen. Two symptoms, two different lines. That separation is the whole reason for the structure.

The menu, now that the autoload owns the state#

The settings menu becomes thin. It reads current values into its controls, and it pushes changes back out.

extends Control

@onready var volume_slider: HSlider = $PanelContainer/VBoxContainer/VolumeSlider
@onready var fullscreen_check: CheckButton = $PanelContainer/VBoxContainer/FullscreenCheck
@onready var back_button: Button = $PanelContainer/VBoxContainer/BackButton


func _ready() -> void:
	volume_slider.min_value = 0.0
	volume_slider.max_value = 1.0
	volume_slider.step = 0.01

	# Seed the controls from current settings WITHOUT firing their signals,
	# then connect. Otherwise setting up the UI counts as the player
	# changing it, and you re-apply and re-save on every menu open.
	volume_slider.set_value_no_signal(Settings.master_volume)
	fullscreen_check.set_pressed_no_signal(Settings.fullscreen)

	volume_slider.value_changed.connect(_on_volume_slider_value_changed)
	fullscreen_check.toggled.connect(_on_fullscreen_check_toggled)
	back_button.pressed.connect(_on_back_button_pressed)

	volume_slider.grab_focus.call_deferred()


func _on_volume_slider_value_changed(value: float) -> void:
	Settings.set_master_volume(value)


func _on_fullscreen_check_toggled(toggled_on: bool) -> void:
	Settings.set_fullscreen(toggled_on)
	Settings.save_settings()


func _on_back_button_pressed() -> void:
	Settings.save_settings()
	hide()

Three things in there are deliberate.

set_value_no_signal and set_pressed_no_signal. Assigning value normally emits value_changed, so seeding the UI would run your handler, which applies and possibly saves. Harmless today; a source of very confusing bugs the moment a handler does anything with side effects. The docs describe set_pressed_no_signal as exactly this — for when you are initialising a scene.

Saving on Back, not on every slider frame. value_changed on a Slider fires continuously while dragging, potentially every frame. Saving there means a file write per frame of a drag. Applying every frame is right — the player wants to hear the volume as they drag. Persisting every frame is not. Back is one clear moment. If you would rather not depend on the player pressing Back, Slider also emits drag_ended(value_changed: bool) when the grabber is released, which is the natural per-control save point.

grab_focus.call_deferred() so the menu is usable without a mouse. That is Part 04's whole subject; the deferred call is there because the docs recommend it specifically when grabbing focus inside _ready.

Now hook the menu up to the pause menu's Settings button — instance it, keep it hidden, show() it on press — and test the actual thing you built this for: change the volume, quit the game entirely, relaunch. The first frame of audio should already be quiet.

The fork: which unit do you persist?#

Two defensible designs, and this is a real fork rather than a right answer.

Persist the slider's 0..1 value. Restoring is a direct assignment: the number you saved is the number the slider wants. The dB conversion happens only at the moment of application and never touches disk. The file is human-readable — master_volume=0.75 means something to anyone who opens it.

Persist decibels. The file holds the value the audio server actually receives, which is arguably the truth, and it survives a change of slider range. Restoring costs a db_to_linear(saved_db) to place the slider back where it was.

The deciding question: is the saved file describing the player's UI choice or the mixer's state? If your settings screen is the only thing that ever changes volume, save the UI choice — it is simpler and it debugs by eye. If volume is also driven by something else (a mixing pass, a cutscene ducking system, per-bus offsets), the mixer state is the more honest record.

What you cannot do is mix them. Save dB and restore it into the slider without converting, and the slider snaps to 0 or 1 on launch. Save 0..1 and run db_to_linear over it on load, and it drifts. Both bugs share a signature that makes them nasty: they are invisible while you test, because the value in memory is fine for the whole session. They only appear after a restart, which is exactly the thing you do least often while working.

If you take one habit from this part: when a number crosses a boundary — into a file, into a server, into a control — say its unit out loud. The code cannot.

Checkpoint — definition of done#

  • Dragging the volume slider from 0 to 1 produces an obvious, continuous change in loudness — not a barely-audible one.
  • The slider at 0 is true silence, not very-quiet.
  • Deliberately misspelling the bus name ("master") produces a message in the Debugger's Errors tab instead of doing nothing.
  • Changing the volume, quitting, and relaunching starts the game at the saved volume from the very first note — with the settings menu never opened.
  • Deleting settings.cfg from the user:// folder and relaunching gives you the defaults with no errors in the output.
  • The fullscreen toggle goes fullscreen and back, and the restored window has its title bar and borders.
  • Opening the settings menu shows the slider and the check button already matching the current settings, without writing the file.
  • You can explain: why a settings screen that only applies values in its own signal handlers produces a game that boots with the wrong settings, even though loading and saving both work correctly.

Stretch (no instructions)#

  • Split the single master slider into Master, Music, and SFX by adding buses in the Audio panel. Notice what the bus-name lookup and its -1 check cost you now that there are three of them.
  • Add a "Reset to defaults" button that restores every value, applies it, and updates the controls — without the controls' signals firing a second round of applies.
  • Add a resolution dropdown for windowed mode, and decide what it should do while the game is fullscreen.
  • Move the settings keys into a dictionary of defaults so load_settings and save_settings iterate instead of listing each key twice. Then judge honestly whether that is clearer or only shorter.

If you get stuck#

  • Slider moves, nothing gets louder or quieter → the 0..1 value is going into set_bus_volume_db without linear_to_db. Print the value you are passing to the setter; if it is between 0 and 1, that is the bug.
  • Slider has only two positions, off and onSlider overrides step to 1.0, unlike Range's 0.01. Set Step to 0.01 in the inspector or in _ready.
  • No error anywhere and no volume change at allget_bus_index returned -1. Print it. Check the exact spelling and capitalisation against the Audio bottom panel.
  • Settings save in the editor, reset in the exported build → the path is res://. It must be user://.
  • "Couldn't get property" / missing-key errors in the output on first launch → a get_value call without its third argument, or a missing if err != OK: return after load, letting an empty ConfigFile be read.
  • A setting that used to work now resets every launch → something wrote null into it, which deletes the key. Open settings.cfg in a text editor and see which key vanished, then trace what supplies that value to set_value.
  • Windowed mode has no title bar after coming back from fullscreenborderless was forced to true by the fullscreen call and nothing set it back. That is the two-line windowed branch.
  • UI is tiny or clipped in fullscreen → not a menu bug. Project Settings → Display → Window → Stretch.
  • Opening the settings menu re-saves the file or re-applies values → the controls were seeded with plain assignment, which emits their change signals. Use set_value_no_signal and set_pressed_no_signal.
  • Slider is in the right place but the game boots at the wrong volume → loading works, applying does not. Confirm apply_all() is actually called from the autoload's _ready, and that the autoload is registered in Project Settings → Globals → Autoload.
  • Slider jumps to a wrong position on every launch, but only after a restart → units. You saved one and restored the other. Open settings.cfg and look at the number: 0.75 and -2.5 do not look alike.

Next: Part 04 — Unplug your mouse. Read it once this settings screen works, because the settings screen is where mouse-only navigation hurts most — a slider is the one control a keyboard-first player cannot fake with a click.