Button + Signal Connection¶
What you'll learn
- What the
Buttonnode is and what itspressedsignal carries (and does not carry). - The publisher–subscriber model behind every Godot signal — why a click handler does not need to know the button exists.
- Two ways to connect a signal: the editor's Node tab dialog versus a
connect()call inside a script. - The role of
_ready()— the lifecycle hook where almost all code-based wiring lives. - The
extendskeyword and what attaching a script to a node actually does.
How it applies
- Decoupled input handling. The button does not know what happens when it is pressed — it announces "I was pressed" and any number of listeners can react. A second listener (analytics, sound effect, achievement counter) can be added later in another script without touching the button or the original handler.
- Accessibility for free.
Buttonis keyboard-focusable, controller-navigable, and screen-reader-tagged by default. A player who cannot use a mouse canTabto the button and pressEnteror the controllerAbutton — the samepressedsignal fires. ReplacingButtonwith a hand-rolled clickablePanelwould silently lose all of this. - Hot path performance. An idle game's primary button gets pressed thousands of times per session. The
pressedsignal is dispatched in C++ and resolves to a direct method call; there is no reflection, no string lookup, no per-emit allocation. A signal handler that does only cheap work (an integer increment, a label assignment) does not measurably affect frame time even at high click rates. - QA testability. Because the press handler is a normal method on a script, QA can call it directly from a test scene or the Remote tab in the running game —
_on_train_pressed()can be invoked without simulating a click. Bugs in the handler are reproducible without bothering with input simulation. - Touch and controller parity.
Buttonaccepts mouse clicks, touch taps (Steam Deck, mobile), and gamepad button presses (when focused) through one signal. Adding controller support to a screen ofButtons is zero extra wiring. Adding it to custom-drawn click areas is a multi-day refactor.
Concepts¶
The Button node¶
A Button is a Control subclass that draws itself as a clickable region with optional text and icon, and emits a small set of input signals — pressed, button_down, button_up, toggled (if it is a toggle button), and a few others. For our purposes only pressed matters: it fires once per complete click (press + release while the cursor is still on the button).
Button inherits from BaseButton, which is the abstract source of every clickable thing in Godot — CheckBox, OptionButton, MenuButton, LinkButton, TextureButton. They all share the same input-handling code; they differ only in how they draw.
Example
You hold the mouse button down on a Button, drag the cursor off the button, and release. pressed does not fire — release happened outside the button's rectangle, so the click is treated as cancelled. button_down fired on press, button_up did not (it only fires on release-while-still-inside). This matches platform-standard "you can change your mind by dragging away" behavior, the same as every desktop OS button.
Signals — the publisher-subscriber pattern¶
A signal is a named broadcast a node can fire, carrying optional arguments. The node firing the signal — the publisher — does not know who is listening. Other nodes — subscribers — register interest by connecting a Callable (a method reference) to the signal. When the publisher emits, every connected subscriber's method is invoked.
The button does not call any handler directly. It calls pressed.emit(), which the engine fans out to whoever connected. If nothing connected, emit() silently does nothing.
This pattern is everywhere in Godot. The engine emits signals you have not connected (tree_entered, visibility_changed, resized) at all times; a tree of connected handlers springs to life only for the events your code subscribes to.
Example
Two scripts both connect to train_button.pressed. One increments a click counter; the other plays a click sound. Neither script knows about the other, neither imports the other, and the order in which the engine calls them is the order they were connected. Adding a third subscriber later (achievement system: "1000 clicks unlocked") is one connect() call in a separate file. None of the existing wiring changes.
Connecting a signal — Inspector vs code¶
There are two ways to attach a Callable to a signal.
Editor route (Inspector → Node tab). Select the publishing node in the Scene dock, switch to the Node tab in the right panel (next to Inspector), pick the signal, click Connect…. A dialog asks which node should receive the signal and offers to generate an empty handler method on that node's script. The connection is saved into the scene file (.tscn) — open the scene in a text editor and you will see a [connection signal="pressed" from="TrainButton" to="." method="_on_train_pressed"] entry.
Code route (signal.connect(callable)). Inside a script's _ready() (or any method that runs after the node tree exists), call train_button.pressed.connect(_on_train_pressed). The connection is saved nowhere — it lives in memory while the scene is loaded and rebuilds every time _ready() runs. Visible in the Remote tab in the running game.
Editor connections are visible to anyone reading the .tscn, survive script recompilation, and break loudly (parser error) if the handler is renamed without updating the scene. Code connections are invisible to the scene file, robust to scene reorganization (the script targets a node by @onready reference, not by an absolute path baked into the connection), and easier to grep for.
The pedagogical preference in this textbook is code connections, because the wiring is in the same file as the handler — one place to read.
Example
You rename _on_train_pressed to _on_click_registered in the script. With an editor connection, the next time you run the scene Godot raises "method not found" because the .tscn still names the old method — you must update the scene file (or right-click the connection in the Node tab and rebind). With a code connection, the rename is two edits in one file: the method name and the connect() call. No scene-file edit needed.
_ready() — the canonical wiring hook¶
_ready() is a virtual method the engine calls on a node exactly once, after the node and its children have entered the SceneTree and are fully initialized. By the time _ready() runs, every child referenced by @onready var has been resolved; sibling nodes named in the scene exist; signals can be connected.
_ready() is the canonical place for code that depends on the node tree being complete. Connecting a signal in the constructor (_init()) is too early — the children referenced by the connection do not yet exist. Connecting in _process() is too late — it would re-connect every frame, accumulating duplicate connections.
Example
You write train_button.pressed.connect(_on_train_pressed) inside _init() of a script attached to TopBar. The script runs at construction time, before the editor's saved children of TopBar are added — train_button is null. The connect() call raises "attempt to call function 'connect' on a null instance." Moving the same line into _ready() resolves it: by _ready(), TopBar's children (including TrainButton) are in the tree and @onready references are valid.
extends and what a script attachment buys you¶
A .gd file's first non-comment line is normally extends NodeType. This declares: the class defined by this script is a subclass of the named type. Attaching the script to a node of that type (or any subtype) means the node's runtime class is the script's class — calls to _ready(), _process(), signal handlers, and any custom methods all resolve through the script.
Without extends, a script defaults to extends RefCounted and cannot be attached to a node. With extends Node, the script can attach to any Node (including Button, since Button is a Node). With extends Button, the script can only attach to Button nodes — but inside the script you can call any Button method (grab_focus(), get_button_index()) without going through a reference, because self is the button.
For this chapter we will attach a script to TopBar (an HBoxContainer) using extends HBoxContainer. The script does not need to be the button; it needs to live near the button, fetch a reference, and connect to its signal.
Example
You write extends Node and attach the script to TrainButton. The script attaches without complaint — Button is a Node. But inside the script, self.text = "Train" raises an error because Node has no text property; the editor sees the script's declared type as Node, not Button. Either change the line to extends Button (now self.text is fine) or write (self as Button).text = "Train" (cast at the call site). The script's declared extends governs what self looks like to the type checker.
Walkthrough¶
You will perform these in your own Godot editor with scenes/main.tscn open from M1.4. The tree should still show CanvasLayer → MainLayout (VBoxContainer) → {TopBar, ContentArea, StatusBar}.
- In the Scene dock, right-click
TopBar→ Add Child Node → searchButton→ create. Rename it toTrainButton. - With
TrainButtonselected, find theTextproperty in the Inspector (top of the property list). TypeTrain. The button in the viewport now shows the text. (In the finished game the player is training an acolyte of the blood-knight order — but the flavor concretizes in M5 when the building tiers Initiate / Aspirant / Adept clarify what "training" produces. For now treat "Train" as a placeholder verb.) - Right-click
TopBarin the Scene dock → Attach Script. The Attach Node Script dialog opens. The default path proposesres://TopBar.gd; change it tores://scripts/top_bar.gdso it lands in thescripts/folder from M1.2. Click Create. The script editor opens with a generated stub. - Replace the generated body with five logical lines: an
extends HBoxContainerdeclaration, an@onready varfor the button reference, an@onready varfor the status-bar reference (you will use it in step 6), a_ready()method that callstrain_button.pressed.connect(_on_train_pressed), and an empty_on_train_pressed()method body. The opening and closing braces of GDScript are indentation, not curly braces — match the editor's two-space indent style if it auto-applied, four-space if not (the textbook uses four-space examples). - The first illustrative fragment for the
@onreadypattern: The$NodeNamesyntax is shorthand forget_node("NodeName")— a child lookup by name relative to the script's own node. Because the script lives onTopBar,$TrainButtonresolves to the child you just added. - Add a counter variable and a status-bar update inside the handler. The variable is a plain
int, not aResource— the resource pattern arrives in M2.2. The handler increments the counter and setsStatusBar.textdirectly. (Direct text assignment is the "before" state; M2.3 refactors it to signal-driven.) The fragment:Thevar click_count: int = 0 func _on_train_pressed() -> void: click_count += 1 status_bar.text = "Trained %d times" % click_count%disprintf-style integer substitution; GDScript's%operator on a string accepts a single value or an array of values. The@onready var status_bar: Label = ...you wrote in step 4 needs the path$"../StatusBar"—..walks up toMainLayout, thenStatusBaris the sibling. (TheStatusBarfrom M1.4 sits next toTopBar, not inside it.) - Press
Ctrl+Sto save the script. The scene auto-saves any property edits but not script files; this is the moment to save. - Press
F5to launch. The button is visible in the top bar with the text "Train". The status bar at the bottom still says "Status: ready" (from M1.4) until the first click. Click the button. The status bar updates to "Trained 1 times" — accept the un-pluralized text for now; pluralization is a localization concern that M8 will not solve and is out of scope here. Click again: "Trained 2 times". Close the window. - Open the Godot Output panel (bottom of the editor,
Outputtab). It is empty — your handler does notprint(). If you would like to confirm the handler runs, addprint("clicked")as the first line of_on_train_pressed(), save, re-launch, click. The Output panel logsclickedonce per click. Remove theprintonce satisfied.
Optional: editor-route comparison. Re-do step 4's connect via the editor: select
TrainButton, switch to the Node tab, double-clickpressed. The Connect a Signal dialog asks which node receives the signal — pickTopBar(the script-bearing node). Method name defaults to_on_train_pressed— leave it. Click Connect. The dialog generates_on_train_pressed()if the method does not exist, or connects to your existing method if it does. Openmain.tscnin a text editor and find the[connection signal="pressed" ...]line. Now you have both connections — the same handler will fire twice per click. Delete the editor connection (right-click in Node tab → Disconnect) before re-running, or you'll see "Trained 2 times" after one click.
Self-check quiz¶
Q1 — You hold the mouse button down on TrainButton, drag the cursor off the button while still holding, then release outside the button's rectangle. Which signals fire?
A. pressed only.
B. button_down only.
C. button_down and button_up only.
D. button_down, button_up, and pressed.
Reveal answer
B — button_down only. button_down fires the moment the mouse button is pressed while the cursor is on the Button. button_up only fires if the release is also inside the button's rectangle (or, more precisely, if the click is not cancelled) — releasing outside cancels the click. pressed only fires when both press and release happen on the button. A is wrong because pressed is gated on a complete click. C is wrong because button_up did not fire (the release was outside). D combines all the wrong assumptions. This drag-to-cancel behavior is built into BaseButton for parity with desktop OS conventions.
Q2 — You write train_button.pressed.connect(_on_train_pressed) inside _init() of the script attached to TopBar. The game crashes on launch with 'attempt to call function 'connect' on a null instance.' Why?
A. _init() runs before the node's children exist, so train_button is null at the moment connect() is called.
B. _init() is the wrong method — it is reserved for the engine and cannot contain user code.
C. pressed cannot be connected in code; only the editor's Node tab can connect built-in signals.
D. connect() returns an error code instead of raising — the message is misleading; the real issue is a missing await.
Reveal answer
A — _init() runs before children are added. _init() is the script's constructor, called when the object is constructed in memory. At that point the node exists but its children have not been added — the editor adds children to the in-memory node only as part of the scene-loading sequence, which finishes by _ready(). @onready var is an explicit syntax for "fetch this in the post-children-loaded phase, not in the constructor." Move the connect() call into _ready(). B is false: _init() is yours to use. C is false: pressed.connect() works fine in code, this textbook prefers it. D fabricates an await requirement — connect() is synchronous.
Q3 — Your script has extends Node and is attached to TrainButton. Inside _ready() you write text = 'Train'. The editor highlights text in red with 'Identifier not declared in the current scope.' Why?
A. Node has no text property; the script's declared base class governs what properties self exposes, even though the runtime node is a Button.
B. Button.text is theme_override_*-namespaced in Godot 4.6; the bare assignment must use the override syntax.
C. text is a Control property, not a Button property — the script needs extends Control instead of extends Node.
D. The assignment requires explicit self.text because top-level identifiers in _ready resolve against locals first.
Reveal answer
A — extends governs the type-checker's view of self. The runtime node is a Button, but the script declares its base as Node, so the GDScript type-checker treats self as a Node. Node has no text property; the assignment fails to type-check. Either change extends Node to extends Button (now self.text is fine) or cast: (self as Button).text = "Train". B is wrong: Button.text is a regular property, not a theme override — theme overrides are for visual styling like font color, not content. C is close but wrong: text is a Button property specifically — Control doesn't have it (Labels do, via Label.text, which is a different property on a sibling class). D is wrong: top-level identifiers in _ready resolve against the script's own scope including inherited properties; self.text and bare text resolve identically. The lesson: pick extends to match the most-specific node type whose properties you need to access.
Q4 — Parsons (ordering)¶
Below are ten lines of GDScript, scrambled. Two are distractors (they look plausible but should not appear in the body). Identify the distractors and arrange the remaining lines in correct order with correct indentation:
A. extends HBoxContainer
B. status_bar.text = "Trained %d times" % click_count
C. var click_count: int = 0
D. pressed.connect(_on_pressed) # distractor candidate
E. @onready var train_button: Button = $TrainButton
F. train_button.pressed.connect(_on_train_pressed)
G. func _ready() -> void:
H. func _on_train_pressed() -> void:
I. click_count += 1
J. extends Button # distractor candidate
Reveal answer
Distractors: D (the bare pressed.connect would resolve to self.pressed.connect — but self is the HBoxContainer, which has no pressed signal) and J (the script attaches to TopBar, an HBoxContainer, not a Button — the wrong extends breaks every member resolution).
Expected order:
extends HBoxContainer
@onready var train_button: Button = $TrainButton
var click_count: int = 0
func _ready() -> void:
train_button.pressed.connect(_on_train_pressed)
func _on_train_pressed() -> void:
click_count += 1
status_bar.text = "Trained %d times" % click_count
(@onready var status_bar is omitted from the puzzle for brevity; assume it exists.)
The shape: declare base class, declare @onready references, declare member state, define _ready for wiring, define handlers for behavior. Wrong base class (J) breaks extends; wrong signal target (D) breaks the connection.
Integration question¶
Q5 — open
In M1.4 you placed StatusBar (a Label) as a sibling of TopBar, not a child. In this chapter's walkthrough, the script on TopBar references StatusBar via $"../StatusBar". Trace why the path is what it is — what does .. mean in this context, and why is it required instead of $StatusBar?
Reveal expected answer
$NodeName is shorthand for get_node("NodeName"), which resolves relative to the node the script is attached to — in this case, TopBar. StatusBar is not a child of TopBar; it is a child of MainLayout (the parent of both TopBar and StatusBar). To reach a sibling, the path must walk up to the common parent and back down: .. means "this node's parent" (MainLayout), and StatusBar is then a child of that parent. Hence $"../StatusBar". The double-quotes are required because $ followed by an unquoted token treats the token as a single-name NodePath literal — and .. is not a valid single-name token in a NodePath. Quoting the path tells the parser to treat the whole string as a multi-segment NodePath literal, which can contain / and ... The deeper architectural point: scripts whose handlers must touch many other parts of the tree end up with brittle paths. M2.3 will introduce a signal-driven alternative — the resource itself emits, and the label subscribes — which avoids the script ever knowing where the label lives in the tree.
Glossary¶
Glossary
Button- A
Controlsubclass that displays clickable text/icon and emits thepressedsignal when activated by mouse, touch, keyboard (Enter/Space when focused), or gamepad face button. Inherits keyboard focus, theme styling, and disabled state fromBaseButton. - signal
- A Godot mechanism for one node to broadcast that something happened, without knowing who is listening. Declared on a node or class, emitted via
signal_name.emit(args), connected viasignal_name.connect(callable). Decouples publishers from subscribers. Callable- A reference to a method, including its bound object. Usually written as
node.method_name(no parentheses). Can be passed around and invoked later. The thing you connect to a signal is always aCallable. _ready()- A virtual method on every
Node, called once when the node and all its children have entered theSceneTreeand are fully initialized. Canonical place for one-time setup: connecting signals, populating UI, looking up sibling/child nodes. SceneTree- The runtime container of all active nodes during a running game. Entry happens via
add_childor scene instantiation; the engine emitstree_entered,_enter_tree,_readyand begins_processcalls. Inactive scenes (loaded but not added) are not in the tree. extends- The first line of every GDScript file, declaring which class this script extends. The script becomes a subclass of the named class — gains all its methods, properties, and signals. Governs the type-checker's view of
self.