L2.3 — Enums, Constants & Signals as a Language Feature¶
What you'll learn
- Define named and anonymous
enums and use an enum as a variable's type. - Group fixed values as named constants, including constant arrays and dictionaries.
- Declare a
signalwith typed parameters and fire it withemit— the language half of signals. - Suspend a function until a signal fires with
await, and know the hang it can cause.
How it applies
- Magic numbers for states are unreadable and error-prone.
if state == 2tells a reader nothing and invites comparing against the wrong number;if state == State.ATTACKdocuments itself and lets the compiler catch a typo. Enums turn a bag of integers into named, checkable cases. - A signal with the wrong payload misinforms every listener. If a
health_changedsignal emits the damage instead of the new total, every UI element wired to it shows wrong numbers. Typed signal parameters make the contract explicit and catch a mismatched emit. - A declared-but-never-emitted signal is dead wiring. Listeners connect and wait forever;
nothing fires. Recognizing that a signal does nothing until something
emits it prevents the "I connected it but the handler never runs" confusion. awaiton a signal that never fires hangs the function. A coroutine suspended on a signal that no code path emits simply never resumes — the routine "just stops," with no error. Knowingawaitparks execution until the awaited event explains a class of silent stalls.
Concepts¶
Enums¶
An enum defines a set of named integer constants:
The names read far better than the underlying integers, and using the enum as a type
(var current: State) signals intent. You can assign explicit values
(enum Tier { BRONZE = 1, SILVER = 5, GOLD = 10 }) when the numbers matter. An anonymous enum
omits the name and just introduces constants into the scope:
Enums pair naturally with match (L1.3): dispatching on State with a match and a _ arm is the
idiomatic state-handling shape, and the one the M2.4 state machine builds on.
Named constants, including collections¶
const (L1.2) is not only for single numbers. A constant can hold an array or dictionary fixed at
compile time, useful for lookup tables:
const RARITY_COLORS := {
"common": Color("ffffff"),
"rare": Color("4a90d9"),
"epic": Color("a335ee"),
}
This keeps a table in one named place. In Godot 4 a const collection is read-only: you can
neither reassign RARITY_COLORS nor mutate its contents — RARITY_COLORS["common"] = ... or an
.append(...) errors at runtime as a write to a read-only value. (This is a change from Godot 3.x,
where a const collection's binding was fixed but its contents could still be edited.) When you need
an editable version, copy it into a var first with .duplicate() (L1.5), which yields an
independent, mutable collection.
Signals — the language half¶
A signal is a message a node can broadcast. There are two halves: declaring/emitting it (this
chapter, the language part) and connecting listeners to it (G2.6, the engine part). You declare a
signal with the signal keyword and typed parameters, and fire it with .emit(...):
signal health_changed(current: int, max: int)
signal died
func take_damage(amount: int) -> void:
current = max(current - amount, 0)
health_changed.emit(current, max_health) # broadcast the new totals
if current == 0:
died.emit()
The typed parameters are a contract on the payload: a listener knows it will receive a current and a
max, both int. Emitting is all the language requires — the emitting code does not know or care who is
listening (that decoupling is the whole point, and the discipline you build in G2.6). A signal that is
declared but never emitted does nothing; a signal whose emit passes the wrong values misinforms
every listener.
Example
The state machine's transition signal, declared and emitted (the mechanic; the machine pattern is ARPG M2.4):
signal transitioned(next_state: StringName)
func request_attack() -> void:
transitioned.emit(&"Attack") # &"Attack" is a StringName (L1.2)
The emitting code announces "I am transitioning to Attack" and moves on. What responds to that — the machine swapping active states — is wired elsewhere. Keeping declare/emit separate from connect/respond is exactly what lets the two sides be developed and tested independently.
await — suspend until a signal fires¶
await pauses the current function until a signal emits (or a coroutine returns), then resumes with
the result:
func attack() -> void:
animation.play("swing")
await animation.animation_finished # park here until the anim signal fires
hitbox.disable() # resumes after the animation completes
A common form waits on a timer: await get_tree().create_timer(1.0).timeout pauses one second. The
hazard: if the awaited signal never fires (the animation is missing, the timer is never created),
the function never resumes — no error, just a routine that silently stops at the await. When you use
await, make sure the awaited event is guaranteed to happen.
Walkthrough¶
Use a fresh script on a Node scene, run with F6.
- Define
enum State { IDLE, MOVE, ATTACK }andvar s := State.MOVE. Prints(you will see1), thenmatch s:with named arms and a_default; confirm theMOVEarm fires. Note how the named arms read versus comparing against1. - Declare
signal pinged(value: int)at the top of the script. In_ready, connect it to a tiny handler (you will learn connection properly in G2.6; for now usepinged.connect(func(v): print("got ", v))), thenpinged.emit(42). Confirmgot 42prints. - Change the
emitto pass the wrong type (pinged.emit("x")) and observe the complaint about the typed parameter. Restore it. - Add an
await: in_ready, writeawait get_tree().create_timer(1.0).timeoutthen aprint. Confirm the print appears one second after the scene starts — the function parked at theawaitand resumed.
Optional sanity check
Comment out the pinged.emit(42) line but keep the connect. Run: nothing prints. The handler is
connected and waiting, but with no emit the signal is dead wiring. Uncomment the emit and it
fires. That is the "declared/connected but never emitted" state, seen directly.
Self-check quiz¶
Q1 — Given enum State { IDLE, MOVE, ATTACK }, what is the value of State.ATTACK, and why prefer the enum over the integer?
A. 3; no real advantage over using 3.
B. 2; the name documents intent and lets the compiler catch a misspelled member, where a bare 2 does neither.
C. "ATTACK"; enums are strings.
D. Undefined until you assign it.
Reveal answer
B. Enum members count from 0, so IDLE=0, MOVE=1, ATTACK=2. The benefit is readability and
checking: State.ATTACK says what it means and a typo like State.ATTCK is a compile error,
whereas 2 is opaque and 3 would silently be wrong. A dismisses that benefit. C is false —
enum members are integers. D is wrong; they have defined values immediately.
Q2 — What does health_changed.emit(current, max_health) do, and what does it assume about listeners?
A. It calls every connected listener with those two values; it assumes nothing about who is listening, including that anyone is. B. It directly updates the health bar. C. It connects the signal to a handler. D. It errors if no listener is connected.
Reveal answer
A. emit broadcasts to whatever is connected, passing the arguments; the emitting code does
not know or depend on who listens (or whether anyone does — emitting to zero listeners is a
valid no-op). B is what a listener might do, not what emit does. C confuses emitting with
connecting (G2.6). D is false — emitting with no listeners is fine, not an error.
Q3 — A function does await some_signal and never resumes past that line. Most likely cause?
A. await is invalid outside _ready.
B. some_signal is never emitted on any reachable path, so the suspended function has nothing to resume it.
C. await always waits exactly one frame and then errors.
D. You cannot await a custom signal.
Reveal answer
B. await parks the function until the awaited signal fires; if nothing ever emits it, the
routine never resumes — silently. A is false (await works in many functions). C invents
behavior — await waits for the actual event, not a fixed frame. D is false — custom signals
are awaitable.
Integration question¶
Q4 — open
A boss script declares enum Phase { ONE, TWO, DEAD }, a signal phase_changed(new_phase: Phase),
and an attack routine that does await animation.animation_finished mid-combo. In testing: the
phase UI sometimes shows the wrong phase, and occasionally the boss freezes mid-attack and never
acts again. Using enums, signal payloads, and await, propose the most likely cause of each and
how you would confirm it.
Reveal expected answer
Wrong phase in the UI most likely comes from the phase_changed payload: if the emit
passes the wrong value (e.g. emitting Phase.ONE after moving to Phase.TWO, or emitting a
raw integer that does not match), every listener wired to the signal renders the stale/incorrect
phase. Because the parameter is typed Phase, a flatly wrong type would be caught, but a wrong
value of the right type will not be — so confirm by printing the argument at the emit site and
at the handler and comparing to the actual phase variable. The freeze is the await hazard:
if animation.animation_finished never fires on some path — a missing or non-looping animation
name, an animation that was interrupted, a node freed before it completes — the attack routine
parks at the await and never resumes, so the boss stops acting with no error. Confirm by adding
a print immediately after the await: if it never prints, the awaited signal never fired.
Both bugs are silent (wrong-but-valid data; a coroutine that simply stops), which is why
reasoning about the signal's payload and the guaranteed-firing of the awaited event is how you
find them.