esc
navigate openSearch by Pagefind

Menus and settings

The meta-game screens: pause done right, the settings widget family, ConfigFile persistence, input remapping.

How to build the screens around the game (title, pause, settings, remap, dialogs) with the CLI. This doc owns structure and the widget family; ui-polish-2d.md owns the look (Design tokens, drawn controls, juice, transitions). Build the skeleton here, polish there. Every widget, signal, and enum below was verified against the live build. A menu only tells the truth when you play it (scene playinput click / drive focus with a gamepad action → read back); ground anything unfamiliar with engine class-info --class OptionButton before wiring it.

The skeleton every menu shares#

A menu is a Control scene laid out by containers, not absolute coordinates. Containers re-flow on any resolution, offsets don’t. Three commands do it: ui.add_container (auto-arranging parent), ui.add_control (a leaf widget), ui.set_sizing (how a child fills its slot). Anchor the outer container full_rect; a CenterContainer then centers the button column at any window size.

Build (a title screen):

Terminal window
scene create --path res://ui/main_menu.tscn --root-type Control
scene open --path res://ui/main_menu.tscn
ui add-container --type CenterContainer --name Center --parent-path .
node set-anchor --node-path Center --preset full_rect # track the window
ui add-container --type VBoxContainer --name Buttons --parent-path Center --separation 14
ui add-control --type Button --name PlayButton --parent-path Center/Buttons --text "Play" # + Settings, Quit
ui set-sizing --node-path Center/Buttons/PlayButton --h fill --custom-min-size "Vector2(260,56)"
node connect --source-path Center/Buttons/PlayButton --signal-name pressed --target-path . --method-name _on_play
scene save

grab_focus() the first button on open (a controller/keyboard needs a focused control), then change scenes on Play:

func _on_play() -> void:
get_tree().change_scene_to_file("res://scenes/level_01.tscn") # returns an Error code

change_scene_to_file frees the current scene and loads the new one next frame; change_scene_to_packed(preloaded) avoids the load hitch, reload_current_scene() restarts.

Pause menu: an overlay that lives while the tree is frozen#

Pause is two facts: get_tree().paused = true stops every node whose process_mode is the default PROCESS_MODE_INHERIT/PROCESS_MODE_PAUSABLE, and the pause UI must keep running to un-pause. Put the UI on its own CanvasLayer (draws above the world, ignores the game camera) set to PROCESS_MODE_ALWAYS (3) so its buttons still receive input.

Build:

Terminal window
node add --type CanvasLayer --name PauseUI --parent-path .
node set --node-path PauseUI --properties '{"layer":10,"process_mode":3,"visible":false}' # 3 = ALWAYS
ui add-container --type CenterContainer --name Center --parent-path PauseUI
node set-anchor --node-path PauseUI/Center --preset full_rect
func _unhandled_input(e: InputEvent) -> void:
if e.is_action_pressed("ui_cancel"): # Esc / gamepad Start
var p := not get_tree().paused
get_tree().paused = p
$PauseUI.visible = p
if p: $PauseUI/Center/Buttons/Resume.grab_focus()

Process-mode ladder: INHERIT(0) follows the parent, PAUSABLE(1) stops on pause, WHEN_PAUSED(2) runs only while paused, ALWAYS(3) never stops, DISABLED(4) never runs. Gate Quit behind a ConfirmationDialog (below) so a mis-click is recoverable.

The settings screen and its full widget family#

Group settings into TabContainer pages, whose direct-child Controls become the tabs (title each with set_tab_title(idx, title)); a bare TabBar is the strip alone when you page content yourself. Each row pairs a Label with the right widget. The family, each with the signal you bind:

SettingWidgetSignal → readNotes
Resolution / QualityOptionButtonitem_selected(index)get_item_id(index)add_item(label, id), select(idx)
Bus volumeHSlider (Range)value_changed(value).valueset min_value/max_value/step
VSync / FullscreenCheckButtontoggled(on).button_pressedtoggle button, label beside it
Numeric (FOV, …)SpinBox (Range)value_changed(value).value.suffix = "°", .prefix
Credits / web linkLinkButtonpressedset .uri to auto-open on click
Extra actionsMenuButton / MenuBarpopup’s id_pressed(id)see below

Build (a Video tab: dropdown, slider, toggle):

Terminal window
ui add-container --type TabContainer --name Tabs --parent-path Center
ui add-container --type VBoxContainer --name Video --parent-path Center/Tabs
ui add-control --type OptionButton --name ResOption --parent-path Center/Tabs/Video
ui add-control --type HSlider --name MusicSlider --parent-path Center/Tabs/Video
node set --node-path Center/Tabs/Video/MusicSlider --properties '{"min_value":0.0,"max_value":1.0,"step":0.01,"value":1.0}'
ui add-control --type CheckButton --name VSyncToggle --parent-path Center/Tabs/Video --text "VSync"
node connect --source-path Center/Tabs/Video/MusicSlider --signal-name value_changed --target-path . --method-name _on_music

add_item/select/item_selected are runtime calls; populate and react in script:

func _ready() -> void:
for r in ["1280x720", "1920x1080", "2560x1440"]:
$Center/Tabs/Video/ResOption.add_item(r)
$Center/Tabs/Video/ResOption.item_selected.connect(_on_res)
func _on_music(value: float) -> void: # HSlider (linear) → audio bus (dB)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), linear_to_db(value))

linear_to_db(0) is -inf (silence), which is correct. Godot also has AudioServer.set_bus_volume_linear(idx, value) to skip the conversion. Reach for MenuBar only for a desktop-app top strip; each child PopupMenu is one menu, wired via get_menu_popup(i).id_pressed, and a MenuButton’s dropdown is get_popup().id_pressed.

Persist settings with ConfigFile + apply-on-boot#

Save to user://settings.cfg with ConfigFile (INI-style, human-readable, no schema). One autoload loads it and applies every value before the first scene draws, so the game starts in the user’s state. save/load return an Error (OK == 0).

Terminal window
project add-autoload --name Settings --path res://autoload/settings.gd
extends Node
const PATH := "user://settings.cfg"
func _ready() -> void: # apply-on-boot
var cfg := ConfigFile.new()
if cfg.load(PATH) != OK: return # first run → defaults stand
var m := AudioServer.get_bus_index("Master")
AudioServer.set_bus_volume_db(m, linear_to_db(cfg.get_value("audio", "master", 1.0)))
# …apply video/input the same way
func put(section: String, key: String, value: Variant) -> void:
var cfg := ConfigFile.new()
cfg.load(PATH) # merge, don't clobber sibling keys
cfg.set_value(section, key, value)
cfg.save(PATH)

get_value(section, key, default) returns the default when absent, so fresh installs and partial configs need no special-casing.

Edit then commit or discard: staged screens#

A settings screen or a loadout editor has a Cancel button, and Cancel has to mean something. Wiring each widget straight to the store makes every drag of a volume slider a write, so Cancel can only undo what it remembers to undo. Stage the edit instead:

  1. On open, copy the persisted values into a scratch dictionary. Dictionary.duplicate(true) for nested data, so an edit cannot reach the original through a shared sub-dictionary.
  2. While editing, every widget signal writes the scratch. Live preview (applying a volume as the slider moves) is fine and separate: preview the audio bus, store nothing.
  3. On confirm, validate, fold the scratch back into the store, then save once.
  4. On cancel, drop the scratch and re-apply the stored values to undo any preview. The store was never touched, so there is nothing to roll back.

The same three steps cover a deck or loadout editor, where the scratch is a deep copy of the deck record plus a mirror of any currency being spent. Cancel discards the copy and the spend together, which is why a partial purchase cannot leak out of a cancelled screen.

Build (verified in a running game). The store is the ConfigFile from the previous section:

# open: scratch copy of just the section under edit
var scratch := {}
for key in cfg.get_section_keys("audio"):
scratch[key] = cfg.get_value("audio", key)
scratch["music"] = 0.2 # a widget edits the scratch, never the store
# confirm: fold the scratch back and save once
for key in scratch:
cfg.set_value("audio", key, scratch[key])
cfg.save(PATH)
Terminal window
godot-mcp runtime eval --code '...' # the sequence above, reading the file back after each step
# { "music_after_cancel": 0.8, "music_after_confirm": 0.2 }

Read the file back, not the widget. A screen whose Cancel path is checked by looking at the slider position passes even when the write already happened.

Input remapping: rebind actions at runtime#

Remapping edits the live InputMap: action_erase_events(action) clears an action’s bindings, action_add_event(action, event) adds the captured one. The UI enters a listening state, grabs the next key, rebinds, persists. Serialize InputEventKey.physical_keycode (an int, layout- independent), never the event object. The edit-time default bindings a project ships with come from the input_map command group (input_map set-action); this is the runtime layer on top.

var listening := "" # empty = not capturing
func begin_listen(action: String, prompt: Button) -> void:
listening = action
prompt.text = "Press a key…"
func _unhandled_input(e: InputEvent) -> void:
var key := e as InputEventKey
if listening == "" or key == null or not key.pressed: return
InputMap.action_erase_events(listening)
InputMap.action_add_event(listening, key)
Settings.put("keys", listening, key.physical_keycode) # persist the int
listening = ""

On boot, rebuild each action from the saved int: var ev := InputEventKey.new(); ev.physical_keycode = scancode, then action_erase_events + action_add_event. key.as_text_physical_keycode() gives the label string (“Space”, “A”).

Window & display#

Runtime changes go through DisplayServer; layout-time scaling through project settings. Never write project.godot by hand. Use project set-setting.

  • Fullscreen / windowed: DisplayServer.window_set_mode(mode) takes WINDOW_MODE_WINDOWED, WINDOW_MODE_FULLSCREEN (borderless), or WINDOW_MODE_EXCLUSIVE_FULLSCREEN.
  • VSync: DisplayServer.window_set_vsync_mode(mode) takes VSYNC_ENABLED, VSYNC_DISABLED, VSYNC_ADAPTIVE, or VSYNC_MAILBOX.
  • Resolution: DisplayServer.window_set_size(Vector2i(w, h)) (windowed only).
  • Content scale (UI crispness across resolutions): set the stretch policy once as project settings, and the engine scales the UI for you:
    Terminal window
    project set-setting --name display/window/stretch/mode --value canvas_items
    project set-setting --name display/window/stretch/aspect --value keep
    A live per-user accessibility knob is get_window().content_scale_factor.

Dialogs & popups#

AcceptDialog is a Window with an OK button (signal confirmed); ConfirmationDialog adds Cancel (signal canceled). Show either with popup_centered(Vector2i(w, h)), never visible = true (which skips placement). FileDialog (a ConfirmationDialog) picks files: set file_mode (FILE_MODE_OPEN_FILE, FILE_MODE_SAVE_FILE, …) and access (ACCESS_RESOURCES for res://, ACCESS_USERDATA for user://, ACCESS_FILESYSTEM for the disk), add *.ext filters, read file_selected(path).

func confirm_quit(d: ConfirmationDialog) -> void:
d.dialog_text = "Quit to desktop?"
d.confirmed.connect(get_tree().quit) # canceled just closes
d.popup_centered(Vector2i(320, 140)) # a FileDialog: same popup_centered, + add_filter

Gamepad & keyboard navigation (the couch-compat checklist)#

A mouse-only menu is broken on a controller. Exactly one Control holds focus; ui_up/ui_down/ ui_left/ui_right/ui_accept/ui_cancel (built-in actions) move and activate it. On every screen:

  • Grab focus on open. first_button.grab_focus() when it shows; re-grab after a sub-dialog closes. No focus → a controller does nothing.
  • Every interactive Control is focusable. Buttons default to focus_mode = FOCUS_ALL(2); custom Controls must set it (FOCUS_NONE=0, FOCUS_CLICK=1).
  • Fix auto-routing where it’s wrong with focus_neighbor_top/bottom/left/right (or set_focus_neighbor(SIDE_BOTTOM, path)) and focus_next/focus_previous.
  • Verify by driving. scene play, then input action --action ui_down / ui_accept, and runtime get the focused path: reach and trigger mouse-free.
  • Touch builds may want an on-screen stick. See VirtualJoystick below.

Skinning with 9-slice: NinePatchRect vs StyleBoxTexture#

Both stretch a bordered texture without distorting corners; their margin properties differ, and mixing them up silently does nothing (verified live):

  • NinePatchRect is a node, a standalone stretchable image (a framed panel you place). Margins are patch_margin_left/top/right/bottom (int, px); axis_stretch_horizontal/vertical pick tile vs stretch on the edges.
  • StyleBoxTexture is a resource fed to a Control’s theme (a Button’s normal/hover/ pressed, a Panel’s panel), so the widget draws itself skinned with correct content padding. Margins are texture_margin_left/… (float); set_texture_margin_all(n) sets all four.

Use NinePatchRect for a decorative frame; StyleBoxTexture to skin an interactive widget. theme.set_stylebox only builds a flat StyleBoxFlat, so apply a textured one in script:

var sb := StyleBoxTexture.new()
sb.texture = load("res://ui/panel.png")
sb.set_texture_margin_all(12.0) # the 9-slice border, px
$Panel.add_theme_stylebox_override("panel", sb) # or a Button's "normal"/"hover"/"pressed"

Font pipeline#

A .ttf/.otf dropped in the project auto-imports as a FontFile, so reference its res:// path directly. Set it as a Theme’s default_font (+ default_font_size), or per control:

var f := load("res://ui/Inter.ttf") # FontFile
$Title.add_theme_font_override("font", f) # one control
menu_theme.default_font = f # every Control under this Theme
menu_theme.default_font_size = 20

SystemFont pulls from the OS with a fallback chain (font_names = ["Segoe UI", "Arial"], allow_system_fallback = true) for glyphs a bundled font lacks. For weight/spacing/italic off one base file, reach for FontVariation. Its axis-pinning and letter-spacing traps are in ui-polish-2d.md (“Typography facts”); don’t re-solve them. Bulk theme sizing: theme create --default-font-size 18 / theme set-font-size --node-path X --name font_size --size 24.

GraphEdit family (when a game needs a node editor)#

GraphEdit + GraphNode build in-game/tool node graphs: a skill-tree builder, dialogue debugger, visual crafting bench. Use it only when the game edits a graph; a static skill tree is better as TextureButtons + Line2Ds. GraphEdit doesn’t connect wires itself. It emits connection_request(from, from_port, to, to_port) and you decide, calling connect_node(from, from_port, to, to_port) to accept the wire (disconnection_request is the tear-down half). Each GraphNode’s ports come from slots: set_slot(idx, enable_left, type_left, color_left, enable_right, type_right, color_right) turns a child row into an input (left) / output (right) port.

VideoStreamPlayer for cutscenes & attract screens#

VideoStreamPlayer is a Control that plays a VideoStream: set stream, call play(), react to finished. The caveat: Godot ships only the Theora codec (.ogvVideoStreamTheora), with no MP4/H.264 out of the box, so transcode to .ogv (or add a GDExtension decoder).

$Intro.finished.connect(func(): get_tree().change_scene_to_file("res://ui/main_menu.tscn"))
$Intro.play() # set autoplay + loop for an attract loop

VirtualJoystick, the on-screen touch stick#

VirtualJoystick is a Control in the running build’s ClassDB, engine-level rather than a project addon (verified: no plugin registers it here). It’s newer than most training data and builds can differ, so confirm with engine class-info --class VirtualJoystick before relying on it; if absent, fall back to the Control-stick pattern in mobile-touch.md. It drives four InputMap actions, so gameplay reads it as ordinary Input.get_vector(...):

  • Actions (StringName, point at your movement actions): action_left/right/up/down.
  • Shape/feel: joystick_mode, visibility_mode, joystick_size, tip_size, deadzone_ratio, clampzone_ratio, initial_offset_ratio.
  • Signals: pressed, tapped, released(input_vector), flicked(input_vector), flick_canceled.

Checklist#

  • Layout by containers anchored full_rect, never offsets; grab_focus() a control on open.
  • Pause = get_tree().paused + a PROCESS_MODE_ALWAYS CanvasLayer overlay; Quit behind a ConfirmationDialog.
  • Right widget per setting, read off its signal; volumes through linear_to_db.
  • Persist to user://settings.cfg via ConfigFile; one autoload applies on boot before first draw.
  • Staged screens edit a scratch copy: confirm folds it back and saves, cancel drops it untouched.
  • Remaps edit InputMap at runtime and persist physical_keycode ints; input_map ships defaults.
  • Display via DisplayServer (mode/vsync/size); content scale via project set-setting display/window/stretch/*.
  • Couch test: reachable + triggerable by ui_* actions alone (verify via input action + runtime get).
  • 9-slice: NinePatchRect.patch_margin_* (int) vs StyleBoxTexture.texture_margin_* (float). Don’t cross them.
  • Motion/transitions → ui-polish-2d.md; product-shell (boot/splash, settings-as-registration, a11y tab) → narrative-game-patterns.md.
Built with the help of godot-mcp. MIT licensed.