Skip to content

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 signal with typed parameters and fire it with emit — 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 == 2 tells a reader nothing and invites comparing against the wrong number; if state == State.ATTACK documents 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_changed signal 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.
  • await on 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. Knowing await parks execution until the awaited event explains a class of silent stalls.

Concepts

Enums

An enum defines a set of named integer constants:

enum State { IDLE, MOVE, ATTACK }    # IDLE = 0, MOVE = 1, ATTACK = 2

var current: State = State.IDLE

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:

enum { NORTH, EAST, SOUTH, WEST }    # four bare constants, no enclosing type name

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.

  1. Define enum State { IDLE, MOVE, ATTACK } and var s := State.MOVE. Print s (you will see 1), then match s: with named arms and a _ default; confirm the MOVE arm fires. Note how the named arms read versus comparing against 1.
  2. 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 use pinged.connect(func(v): print("got ", v))), then pinged.emit(42). Confirm got 42 prints.
  3. Change the emit to pass the wrong type (pinged.emit("x")) and observe the complaint about the typed parameter. Restore it.
  4. Add an await: in _ready, write await get_tree().create_timer(1.0).timeout then a print. Confirm the print appears one second after the scene starts — the function parked at the await and 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.