doc 5 of 5

Part 04 — Unplug your mouse

You will build: A menu you can complete end to end — main menu, into settings, change a value, back out, start the game — using nothing but a keyboard and then nothing but a gamepad. Focus will be visible, the path between controls will be one you chose rather than one the engine guessed, and moving focus will make a sound.

Start with the test, because it is the only one that catches this#

Before any code, here is the definition of done for this part.

Physically unplug your mouse.

Not "avoid touching the mouse". Not "try the arrow keys for a minute". Unplug it, or turn the trackpad off in your operating system's settings. This is the only version of the test that works, because the failure mode here is not that the menu is hard to navigate without a mouse. It is that you never notice the mouse doing the work. Your hand moves, the button highlights, the click registers, and you file the menu as finished. The device that is compensating for the bug is the same device you are using to test for it.

Every console game has to pass this test. So does every PC game a person plays from a couch, and every player who cannot use a mouse. It costs about twenty minutes to pass and about zero minutes to fail silently.

The menu you already have#

Part 03 left you with a settings screen that survives a restart. This part uses the same scene, plus the main menu it hangs off. If your node names differ, keep yours and rename in the snippets.

MainMenu               (Control)      main_menu.gd
├── Buttons            (VBoxContainer)
│   ├── StartButton    (Button)
│   ├── SettingsButton (Button)
│   └── QuitButton     (Button)
├── SettingsPanel      (Panel)        visible = false
│   └── Grid           (GridContainer, columns = 2)
│       ├── VolumeLabel     (Label)
│       ├── VolumeSlider    (HSlider)
│       ├── FullscreenLabel (Label)
│       ├── FullscreenCheck (CheckButton)
│       ├── Spacer          (Control)
│       └── BackButton      (Button)
└── FocusSound         (AudioStreamPlayer)

Every node the script reaches for is marked Access as Unique Name in the Scene dock (right-click the node), which is what lets %StartButton work regardless of how deep the containers get. If you would rather use $Buttons/StartButton, that works too — it breaks the day you reparent something, which on a menu is most days.

The script, at the point where it looks finished:

extends Control

@onready var start_button: Button = %StartButton
@onready var settings_button: Button = %SettingsButton
@onready var quit_button: Button = %QuitButton
@onready var back_button: Button = %BackButton
@onready var volume_slider: HSlider = %VolumeSlider
@onready var fullscreen_check: CheckButton = %FullscreenCheck
@onready var settings_panel: Panel = %SettingsPanel

func _ready() -> void:
	start_button.pressed.connect(_on_start_pressed)
	settings_button.pressed.connect(_on_settings_pressed)
	quit_button.pressed.connect(_on_quit_pressed)
	back_button.pressed.connect(_on_back_pressed)
	settings_panel.visible = false

func _on_start_pressed() -> void:
	get_tree().change_scene_to_file("res://scenes/game.tscn")

func _on_settings_pressed() -> void:
	settings_panel.visible = true

func _on_back_pressed() -> void:
	settings_panel.visible = false

func _on_quit_pressed() -> void:
	get_tree().quit()

Run it with the mouse. Everything works. Start starts, Settings opens, Back closes, Quit quits.

Now unplug the mouse and press the down arrow.

Nothing happens, and nothing tells you why#

No highlight. No movement. Press every arrow key, press Tab, press Enter, plug in a gamepad and mash the D-pad. The window is alive — it repaints, it accepts Alt+F4 — but the menu is inert.

Check the console. It is clean. Not one warning.

This is what makes the bug expensive. A crash gives you a stack trace. A missing signal connection gives you a "nonexistent function" error. This gives you nothing, because from the engine's point of view nothing went wrong. Ask it directly:

func _ready() -> void:
	print(get_viewport().gui_get_focus_owner())

It prints <null>.

Keyboard and gamepad navigation is not a search for the nearest button. It is a move from the currently focused control to a neighbouring one. There is no currently focused control. ui_down fires, the engine asks "move down from what", the answer is nothing, and nothing is where it moves to. The input map is fine. The buttons are fine. The starting position is missing.

The GUI navigation page in the official docs states this without ceremony: for keyboard and controller navigation to work, some node must be focused by code when the scene starts. Not by the scene file, not by being first in the tree. By code.

The fix, and the part of the fix people skip#

func _ready() -> void:
	start_button.pressed.connect(_on_start_pressed)
	settings_button.pressed.connect(_on_settings_pressed)
	quit_button.pressed.connect(_on_quit_pressed)
	back_button.pressed.connect(_on_back_pressed)
	settings_panel.visible = false
	start_button.grab_focus.call_deferred()

Run it, mouse still unplugged. Start Game has an outline. Down moves to Settings, down again to Quit, Enter presses. The menu is alive.

Two things about that last line.

grab_focus() steals focus from whatever had it and gives it to this control. That is the whole method. The interesting half is .call_deferred(), and the docs say why in a note attached to the method itself: using grab_focus() together with Callable.call_deferred() makes it more reliable, especially inside _ready().

"More reliable" is doing a lot of work in that sentence. What it means in practice is that _ready() runs while the UI is still assembling itself. Containers have not laid out their children, sizes are provisional, and other nodes' _ready() callbacks are still to come — including ones that might grab focus themselves. A bare grab_focus() in _ready() works most of the time, and then stops working the day you wrap your buttons in one more container or add an autoloaded scene-transition node. Deferring pushes the call to the end of the current frame, when the tree has settled. The cost is nothing. Write it deferred every time and never think about it again.

Note the syntax: start_button.grab_focus.call_deferred(), not start_button.call_deferred("grab_focus"). grab_focus without parentheses is a Callable, and call_deferred is a method on it. The string form still works, but the typed form is checked at parse time — a typo in "grab_focuss" is a runtime silence, and a typo in grab_focuss is a red squiggle.

focus_mode is the gate, and the number 3 is a trap#

grab_focus() only works on a control that is allowed to be focused. That permission is focus_mode, a property every Control has.

The defaults are worth memorising because they explain most surprises:

  • Button and its subclasses ship with All.
  • Label, Panel, and plain Control ship with None.

So buttons are focusable out of the box, and everything you might be tempted to point focus at is not.

The enum has four values in current Godot:

ConstantValueMeaning
FOCUS_NONE0Cannot grab focus.
FOCUS_CLICK1Can grab focus on mouse clicks only.
FOCUS_ALL2Mouse click, arrows and Tab, or gamepad D-pad.
FOCUS_ACCESSIBILITY3Can grab focus only when a screen reader is active.

FOCUS_ACCESSIBILITY is recent. Older tutorials describe this enum as having three values, and a few of them tell you to write focus_mode = 3. In a 4.0-era project that meant nothing at all, since 3 was out of range. In 4.7 it means accessibility-only, which behaves exactly like None on a machine with no screen reader running. A learner following that instruction gets a control that is unfocusable for them, focusable for a different user, and correct according to the tutorial.

Write the constant:

volume_slider.focus_mode = Control.FOCUS_ALL

If a screenshot of the inspector's focus dropdown in a tutorial does not match the dropdown in front of you, the tutorial predates this change and you should assume the rest of its focus advice is the same vintage.

When every button says "All" and none of them can be focused#

Here is the one that has no equivalent in any older tutorial, because the property did not exist.

Control has a second focus property: focus_behavior_recursive. It takes three values — FOCUS_BEHAVIOR_INHERITED, FOCUS_BEHAVIOR_DISABLED, FOCUS_BEHAVIOR_ENABLED — and it applies to a control and everything under it. Set a Panel to Disabled and every button inside it becomes unfocusable.

The symptom is a menu that behaves exactly like the dead menu at the top of this page, except you have already added grab_focus() and it is doing nothing. You click each button, check the inspector, and each one clearly reads Focus Mode: All. The inspector is telling you the truth about that property. That property is no longer the whole answer.

The docs handle this by redirecting you away from the raw value. The description of focus_mode now ends with: use get_focus_mode_with_override() to determine if a control can grab focus, since focus_behavior_recursive also affects it.

That is your diagnostic:

func _debug_focus_report(root: Control) -> void:
	for child: Node in root.find_children("*", "Control", true, false):
		var control: Control = child as Control
		var declared: int = control.focus_mode
		var actual: int = control.get_focus_mode_with_override()
		if declared != actual:
			print("%s declares %d but resolves to %d" % [control.name, declared, actual])

Call it once from _ready() while you are hunting. A control that declares 2 and resolves to 0 has an ancestor set to Disabled, and now you know to walk up the tree instead of down.

FOCUS_BEHAVIOR_ENABLED is the counterpart: it lets a control ignore a disabled ancestor. That combination — a whole branch off, one control inside it back on — is the actual use case, and it is a good one for a disabled-looking panel with a single live "Close" button. It is also the reason the property gets left switched on by accident after an afternoon of experimenting.

Now the second half: focus moves, but to the wrong place#

Open the settings panel with the keyboard. Press Down.

Watch where focus lands. On the three-button main menu it was obviously right, because a vertical column of three buttons has only one sensible answer. The settings panel is a two-column grid, and now the answers get strange: Down from the volume slider might land on the fullscreen checkbox, or it might land on the Back button, depending on how tall your rows are and where the containers put things. Left from the slider goes nowhere at all, because the thing to its left is a Label, and labels are not focusable.

Then press Tab a few times. Tab visits everything in a sane order.

Two different behaviours from the same scene is the clue. They come from two different fallbacks:

  • Directional movement (ui_up / ui_down / ui_left / ui_right) reads focus_neighbor_top, _bottom, _left, _right. If the relevant one is empty, the docs say Godot gives focus to the closest Control in that direction — a geometric guess, made from on-screen positions.
  • Tab (ui_focus_next / ui_focus_prev) reads focus_next and focus_previous. If those are empty, Godot makes a "best guess" based on surrounding nodes in the scene tree — a structural guess, made from node order.

Neither is broken. They are answering different questions. Your grid happens to be ordered sensibly in the tree, which is why Tab looks correct, and laid out in two columns, which is why the geometric guess starts making choices you did not intend. In a plain vertical list the two agree and you never learn this. Add a column and they diverge.

Stop reasoning about the layout#

The temptation at this point is to stare at the scene and work out what the closest control should be. Do not. Ask:

func _ready() -> void:
	print(volume_slider.find_valid_focus_neighbor(SIDE_BOTTOM))

find_valid_focus_neighbor(side) returns the Control that would actually receive focus for that direction, resolving either your explicit path or the automatic guess. It is the difference between a theory about your layout and a fact about it.

SIDE_TOP, SIDE_BOTTOM, SIDE_LEFT and SIDE_RIGHT are global constants of the Side enum, the same one used for margins and stylebox edges.

Set the neighbours yourself#

Four properties, one setter. The setter takes a Side and a NodePath:

volume_slider.set_focus_neighbor(SIDE_BOTTOM, volume_slider.get_path_to(fullscreen_check))

There is no set_focus_neighbor_bottom(). If a tutorial shows you set_focus_neighbour(MARGIN_BOTTOM, ...), that is Godot 3 — British spelling, Margin enum, both gone. In 4.7 it is set_focus_neighbor with the Side enum.

get_path_to() gives you a relative NodePath from one node to the other, which is what the property wants. You can also type the paths into the inspector's Focus section, and for a menu that will never change again that is fine. Doing it in code has one advantage that matters: it makes the intended order readable in one place, instead of spread across six inspector fields you have to click through to audit.

Here is the whole navigation layout for the panel as a single function:

func _link_vertically(controls: Array[Control], wrap: bool) -> void:
	var count: int = controls.size()
	if count < 2:
		return
	for i: int in range(count):
		var current: Control = controls[i]
		var next_index: int = i + 1
		var previous_index: int = i - 1
		if wrap:
			next_index = (i + 1) % count
			previous_index = (i - 1 + count) % count
		if next_index < count:
			current.set_focus_neighbor(SIDE_BOTTOM, current.get_path_to(controls[next_index]))
		if previous_index >= 0:
			current.set_focus_neighbor(SIDE_TOP, current.get_path_to(controls[previous_index]))

And the call sites:

func _ready() -> void:
	_link_vertically([start_button, settings_button, quit_button], true)
	_link_vertically([volume_slider, fullscreen_check, back_button], true)

wrap set to true means Down from the last item returns to the first. The geometric guess will never do that for you — there is nothing below the last button, so focus stops dead. Wrapping is a small thing that makes a menu feel considered, and it is two lines of modulo.

The typed parameter Array[Control] is worth keeping. Passing an array containing a Label by mistake is then a parse error rather than a neighbour that silently does nothing, which brings up the next failure.

A neighbour that is not focusable is ignored, quietly#

Point focus_neighbor_left at the VolumeLabel and nothing breaks. The path is stored, the inspector shows it, and pressing Left does nothing. The docs say the node must be a Control — true, and a Label is one — but it must also be able to take focus, and a Label defaults to FOCUS_NONE.

You have two honest options. Give the label FOCUS_ALL and accept that focus now stops on text a player cannot interact with, or leave the label alone and design the row so Left and Right mean something. For a settings grid the second is nearly always right: bind Left and Right to the value of the focused row instead. An HSlider already does this — with the slider focused, Left and Right change the volume. That is the reason a settings screen reads as a vertical list even though it is drawn as a grid. Rows move with Up and Down; values change with Left and Right.

This is a real fork, not a rule. Deciding question: does the player ever need to move focus horizontally on this screen? If the answer is no — one column of rows, values edited in place — link only top and bottom and leave left and right empty deliberately. If the answer is yes, because you have a genuine two-column layout like a key-rebinding table, link all four sides explicitly and expect the geometric guess to be wrong until you do.

The focus you cannot see is still focus#

At some point you will click a button with the mouse and notice there is no outline. Then you will conclude your theme is broken, or focus_mode is not sticking, and go looking for a StyleBox problem.

It is not a bug. From the Control docs: focus will not be represented visually if gained via mouse or touch input, and appears only with keyboard or gamepad input, or via grab_focus().

The reasoning is that a focus ring is feedback for people navigating by keys. A mouse user already knows where they are — the cursor is right there — and a ring left behind on the last button they clicked is visual noise. So the engine tracks the focus and hides the drawing of it.

The control genuinely has focus. Confirm it rather than trusting your eyes:

print(get_viewport().gui_get_focus_owner())

If you want the ring always drawn, including after mouse clicks, there is a project setting for it: gui/common/show_focus_state_on_pointer_event, with options including "Text Input Controls" and "Always". Changing it is a legitimate choice — plenty of games want a consistent highlight because their menus are also played with a controller and switching input devices mid-session should not change the visual language. Changing it to hide a confusion you have not diagnosed is not.

Related: has_focus() takes an optional ignore_hidden_focus parameter. Passing true makes it return false for a control whose focus is hidden — the mouse-click case above, or a grab_focus(true) call. Reach for it when you are asking "should I draw something here", not when you are asking "who has focus".

A sound when focus moves#

Menus feel dead without it, and it is one signal.

@onready var focus_sound: AudioStreamPlayer = %FocusSound

func _wire_focus_sound(controls: Array[Control]) -> void:
	for control: Control in controls:
		control.focus_entered.connect(_on_any_focus_entered)

func _on_any_focus_entered() -> void:
	focus_sound.play()

Set the FocusSound node's bus to whatever your settings screen controls — if Part 03 gave you a "UI" or "SFX" bus, use it, so the menu's own sounds obey the menu's own volume slider. A menu-move blip that ignores the volume setting is a specific and irritating kind of wrong.

Two things about focus_entered. It carries no arguments. You cannot ask it which control gained focus, and there is no matching "what lost it" — focus_exited is equally argument-free. If you need to know, either connect a separate bound callback per control, or track it yourself. The viewport also emits gui_focus_changed(node: Control), which does carry the control, and connecting to that once is often less code than connecting to twelve buttons:

func _ready() -> void:
	get_viewport().gui_focus_changed.connect(_on_gui_focus_changed)

func _on_gui_focus_changed(node: Control) -> void:
	focus_sound.play()
	print("focus moved to %s" % node.name)

Deciding question: does the sound belong to the menu, or to the whole game's UI? Per-control focus_entered keeps the wiring local to the scene that owns the buttons. The viewport signal is one connection and covers everything, including controls you have not written yet — which is either the feature or the bug, depending on whether you have a second UI layer that should sound different.

The other thing: the very first grab_focus() in _ready() fires focus_entered too, so the menu blips the moment it opens. Whether that is right is a taste call. If it is not, connect the sound after the initial grab, using the same deferred trick.

Back to the pause menu#

Part 02's pause menu has this bug and does not show it, because you tested it with a mouse.

Open the pause menu with Esc, mouse unplugged. Nothing has focus. The reason is worth stating precisely: hiding a control that has focus takes the focus away, and it does not come back when you show the control again. A grab_focus() in the pause menu's _ready() runs exactly once, when the scene loads and the menu is hidden. Focus was granted, then immediately dropped, and every subsequent open starts from null.

So the grab has to happen on every open, not on load. Wire it to the visibility change:

extends Control

@onready var resume_button: Button = %ResumeButton

func _ready() -> void:
	visibility_changed.connect(_on_visibility_changed)

func _on_visibility_changed() -> void:
	if is_visible_in_tree():
		resume_button.grab_focus.call_deferred()

visibility_changed fires for this node's own visible property and for visibility inherited from ancestors, which is why the check uses is_visible_in_tree() rather than visible — if the whole HUD above the pause menu gets hidden, the menu is not on screen no matter what its own flag says.

You could equally put the grab_focus() inside the function that opens the menu. That is fine, and it is one fewer signal. Deciding question: is there exactly one place that opens this menu? If there is, put it there. If the menu can be opened from the pause action, from a "menu" button on a gamepad, and from a game-over handler, the visibility signal is the one place all three routes pass through.

Same fix, same shape, for the settings panel on the main menu: grabbing focus once in _ready() covers the main menu's first button and nothing else. Opening the panel needs its own grab, and closing it needs a grab back onto SettingsButton — otherwise Back closes the panel and drops focus into nothing, which feels identical to the crash you started this part with.

func _on_settings_pressed() -> void:
	settings_panel.visible = true
	volume_slider.grab_focus.call_deferred()

func _on_back_pressed() -> void:
	settings_panel.visible = false
	settings_button.grab_focus.call_deferred()

Returning focus to the control the player left from is the detail that separates a menu that feels navigable from one that feels like it resets. It is one line per exit.

One rule about the ui_ actions#

ui_up, ui_down, ui_left, ui_right, ui_accept, ui_cancel, ui_focus_next, ui_focus_prev are the engine's menu vocabulary. They are already mapped to arrows, Tab, Enter, Space, Escape and the gamepad's D-pad and face buttons, which is why your menu started working the moment focus existed.

Keep them out of gameplay code. The docs say this outright, and the reason is concrete: ui_accept is bound to both Enter and Space by default. Bind your player's jump to ui_accept and the day you add a pause menu, the jump button also confirms menu items — and the menu's Enter also jumps. Define jump, attack, interact as your own actions in the Input Map. The ui_ set belongs to the UI.

Checkpoint — definition of done#

Do all of these with the mouse physically disconnected, then repeat with a gamepad and the keyboard disconnected.

  • The main menu shows a focus outline on Start Game the instant the scene runs, without any input.
  • Down and Up move through all three buttons, and Down from the last button wraps to the first.
  • Enter on Settings opens the panel, and focus lands on the volume slider — visibly, without pressing anything else.
  • Left and Right on the focused volume slider change the volume, and the change is audible.
  • Down from the volume slider reaches the fullscreen toggle, not the Back button and not nothing.
  • Back closes the panel and focus returns to the Settings button, not to nothing.
  • Every focus move plays a sound, and that sound obeys the volume slider you configured in Part 03.
  • Enter on Start Game loads the game scene.
  • Opening the pause menu mid-game focuses Resume, every time, including the fifth time.
  • print(get_viewport().gui_get_focus_owner()) never prints <null> while a menu is on screen.
  • You can explain: why does pressing Down give different results than pressing Tab on the same screen, when neither control has an explicit neighbour set?

Stretch (no instructions)#

  • Add a second column to the settings grid — a "Reset to default" button per row — and make all four directions navigate correctly. Do it once with explicit neighbours and once by restructuring the layout so the geometric guess happens to be right, then decide which you would maintain.
  • Make the focus sound pitch up slightly as focus moves down the list and down as it moves up. gui_focus_changed gives you the destination; the direction is yours to track.
  • Add a "Press any button to start" splash that detects which device the player used and grabs focus only for keyboard and gamepad, leaving mouse users with no ring.
  • Give the focused button a scale or colour animation on focus_entered and reverse it on focus_exited. Then check what happens on the frame the panel closes.

If you get stuck#

  • Arrow keys do nothing, console is clean → nothing has focus. Print get_viewport().gui_get_focus_owner(). If it is <null>, no grab_focus() has run, or the one that ran was on a control that has since been hidden.
  • grab_focus() runs but focus is still null → the target's get_focus_mode_with_override() is returning FOCUS_NONE. Check the control's own focus_mode first, then walk up its ancestors looking for focus_behavior_recursive set to Disabled.
  • Focus works in a test scene and not in the real one → the real scene has a deeper container hierarchy and your grab_focus() is not deferred. Change it to button.grab_focus.call_deferred().
  • Focus works on start but not after opening a panel → the grab is in _ready() only. Every show needs its own grab; wire it to visibility_changed or to the function that opens the panel.
  • No outline after clicking with the mouse, outline appears with arrows → correct behaviour, not a theme bug. Focus gained by pointer is deliberately not drawn. Change gui/common/show_focus_state_on_pointer_event if you want it always visible.
  • Down goes somewhere unexpected → print control.find_valid_focus_neighbor(SIDE_BOTTOM) rather than inspecting the layout. If it prints the wrong node, set the neighbour explicitly; if it prints <null>, there is nothing focusable in that direction.
  • A neighbour path is set and ignored → the target is not focusable. Labels, Panels and plain Controls default to FOCUS_NONE. Point at a Button, or give the target FOCUS_ALL on purpose.
  • Tab order is right, D-pad order is wrong → you are seeing the two fallbacks disagree. Tab guesses from scene-tree order, directional movement guesses from screen geometry. Set the directional neighbours.
  • A control refuses focus and its inspector reads Focus Mode: All → an ancestor's focus_behavior_recursive is Disabled. The per-node property is not lying, it is only half the answer; get_focus_mode_with_override() is the whole one.
  • focus_mode = 3 from a tutorial makes the control unfocusable → 3 is now FOCUS_ACCESSIBILITY, not FOCUS_ALL. Use Control.FOCUS_ALL.
  • The focus sound fires twice on open → both a per-control focus_entered and the viewport's gui_focus_changed are connected. Pick one.
  • The menu blips the moment it appears → the initial grab_focus() emits focus_entered like any other. Connect the sound after the first grab, deferred, if you do not want that.
  • Pressing the jump key confirms a menu item → gameplay is bound to ui_accept. Make a project-specific action in the Input Map and rebind.

Next: the Precision Platformer curriculum. This shelf built the frame; that one builds the game that goes inside it — eight stages of movement, collision and game feel, with a pause menu and a settings screen it now expects you to already know how to wire.