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 play → input 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):
scene create --path res://ui/main_menu.tscn --root-type Controlscene open --path res://ui/main_menu.tscnui add-container --type CenterContainer --name Center --parent-path .node set-anchor --node-path Center --preset full_rect # track the windowui add-container --type VBoxContainer --name Buttons --parent-path Center --separation 14ui add-control --type Button --name PlayButton --parent-path Center/Buttons --text "Play" # + Settings, Quitui 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_playscene savegrab_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 codechange_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:
node add --type CanvasLayer --name PauseUI --parent-path .node set --node-path PauseUI --properties '{"layer":10,"process_mode":3,"visible":false}' # 3 = ALWAYSui add-container --type CenterContainer --name Center --parent-path PauseUInode set-anchor --node-path PauseUI/Center --preset full_rectfunc _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:
| Setting | Widget | Signal → read | Notes |
|---|---|---|---|
| Resolution / Quality | OptionButton | item_selected(index) → get_item_id(index) | add_item(label, id), select(idx) |
| Bus volume | HSlider (Range) | value_changed(value) → .value | set min_value/max_value/step |
| VSync / Fullscreen | CheckButton | toggled(on) → .button_pressed | toggle button, label beside it |
| Numeric (FOV, …) | SpinBox (Range) | value_changed(value) → .value | .suffix = "°", .prefix |
| Credits / web link | LinkButton | pressed | set .uri to auto-open on click |
| Extra actions | MenuButton / MenuBar | popup’s id_pressed(id) | see below |
Build (a Video tab: dropdown, slider, toggle):
ui add-container --type TabContainer --name Tabs --parent-path Centerui add-container --type VBoxContainer --name Video --parent-path Center/Tabsui add-control --type OptionButton --name ResOption --parent-path Center/Tabs/Videoui add-control --type HSlider --name MusicSlider --parent-path Center/Tabs/Videonode 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_musicadd_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).
project add-autoload --name Settings --path res://autoload/settings.gdextends Nodeconst 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:
- 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. - 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.
- On confirm, validate, fold the scratch back into the store, then save once.
- 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 editvar 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 oncefor key in scratch: cfg.set_value("audio", key, scratch[key])cfg.save(PATH)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)takesWINDOW_MODE_WINDOWED,WINDOW_MODE_FULLSCREEN(borderless), orWINDOW_MODE_EXCLUSIVE_FULLSCREEN. - VSync:
DisplayServer.window_set_vsync_mode(mode)takesVSYNC_ENABLED,VSYNC_DISABLED,VSYNC_ADAPTIVE, orVSYNC_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:
A live per-user accessibility knob is
Terminal window project set-setting --name display/window/stretch/mode --value canvas_itemsproject set-setting --name display/window/stretch/aspect --value keepget_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_filterGamepad & 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(orset_focus_neighbor(SIDE_BOTTOM, path)) andfocus_next/focus_previous. - Verify by driving.
scene play, theninput action --action ui_down/ui_accept, andruntime getthe 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):
NinePatchRectis a node, a standalone stretchable image (a framed panel you place). Margins arepatch_margin_left/top/right/bottom(int, px);axis_stretch_horizontal/verticalpick tile vs stretch on the edges.StyleBoxTextureis a resource fed to a Control’s theme (a Button’snormal/hover/pressed, a Panel’spanel), so the widget draws itself skinned with correct content padding. Margins aretexture_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 controlmenu_theme.default_font = f # every Control under this Thememenu_theme.default_font_size = 20SystemFont 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 (.ogv → VideoStreamTheora), 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 loopVirtualJoystick, 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+ aPROCESS_MODE_ALWAYSCanvasLayeroverlay; Quit behind aConfirmationDialog. - Right widget per setting, read off its signal; volumes through
linear_to_db. - Persist to
user://settings.cfgviaConfigFile; 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
InputMapat runtime and persistphysical_keycodeints;input_mapships defaults. - Display via
DisplayServer(mode/vsync/size); content scale viaproject set-setting display/window/stretch/*. - Couch test: reachable + triggerable by
ui_*actions alone (verify viainput action+runtime get). - 9-slice:
NinePatchRect.patch_margin_*(int) vsStyleBoxTexture.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.