DEV Community

Cover image for GDScript's await Keyword Is the Underused Way to Kill Callback Hell in Godot
Ziva
Ziva

Posted on

GDScript's await Keyword Is the Underused Way to Kill Callback Hell in Godot

In Godot 4, await is the single GDScript feature that flattens the messiest part of your codebase. It replaces signal-callback chains with linear top-to-bottom code, and it's still the first thing most tutorials skip past on their way to the next node-tree screenshot.

This is a quick tour of where await actually pays off, with code you can paste into a 2D project today.

The shape of await in GDScript

The official GDScript reference calls await exactly once in the keyword list: "Waits for a signal or a coroutine to finish." That's the whole API. Two forms:

# Form 1: await a signal directly
await get_tree().create_timer(1.0).timeout

# Form 2: await another function that itself awaits
await play_intro_sequence()
Enter fullscreen mode Exit fullscreen mode

Form 1 yields control until the signal emits. Form 2 turns any function with an await inside it into a coroutine that the caller can also await, which is how you compose multi-step animations and transitions without nesting callbacks.

yield, the Godot 3 ancestor, is still in the keyword list "for transition." Do not use it in new 4.x code; every modern API expects await.

Five places await saves real code

1. Timed delays without a separate Timer node

The single most common pattern:

func flash_warning():
    label.text = "Watch out!"
    await get_tree().create_timer(0.5).timeout
    label.text = ""
Enter fullscreen mode Exit fullscreen mode

This replaces a 12-line state-machine that other tutorials show. The timer is created, awaited once, and garbage-collected when the function returns. The Godot timer docs call this exact pattern out as the supported way to do "fire and forget" delays.

2. Sequencing animations

func play_intro():
    await $AnimationPlayer.animation_finished
    $AnimationPlayer.play("zoom_in")
    await $AnimationPlayer.animation_finished
    $AnimationPlayer.play("fade_out")
    await $AnimationPlayer.animation_finished
    queue_free()
Enter fullscreen mode Exit fullscreen mode

Three animations in sequence, in eight lines, with no nested callbacks. The pre-await version of this code is what most "how to chain animations in Godot" tutorials ship, and it is twice as long with three times the bug surface.

3. Waiting on player input inside a coroutine

func wait_for_jump():
    while true:
        var event = await Input.input_event
        if event.is_action_pressed("jump"):
            return
Enter fullscreen mode Exit fullscreen mode

This is what tutorial games and dialogue systems actually need: pause execution until the player presses a specific key, then resume. Without await, this is a _input handler plus a state flag plus a polling check.

4. Turn-based game flow

Reddit user heyitsdoodler filed godot-proposals#13597 describing the use case better than I can: "I'm waiting for multiple characters to finish tasks of variable lengths before continuing turn order succession." Their working solution is a sequence of awaits on each character's task_finished signal. The proposal asks for built-in all() and any() helpers, which would close the last gap. Until those land, write a tiny utility:

func await_all(signals: Array) -> void:
    for s in signals:
        await s
Enter fullscreen mode Exit fullscreen mode

The order is arbitrary but the function returns only after every signal has fired.

5. Async HTTP requests without a callback

func fetch_high_scores() -> Array:
    var http = HTTPRequest.new()
    add_child(http)
    http.request("https://api.example.com/scores")
    var result = await http.request_completed
    http.queue_free()
    return JSON.parse_string(result[3].get_string_from_utf8())
Enter fullscreen mode Exit fullscreen mode

Two yields, one return value, no class-level state machine. HTTPRequest emits request_completed with a four-element array; awaiting on the signal gets you exactly that array back.

The gotchas every tutorial leaves out

Awaiting a signal on a freed object hangs forever. If you await some_node.signal_name and then queue_free() the node before the signal fires, your coroutine never resumes. Wrap critical awaits in a timeout pattern using any()-style helpers, or check is_instance_valid() after the await returns.

Coroutines do not catch exceptions across the yield. A push_error() before the await fires normally, but a runtime crash in the resumed half is reported with a partial stack trace. Profile suspicious sequences with the Godot Profiler tab before you trust the line numbers.

Awaiting inside _ready() works, but the parent finishes loading before you do. If another script reads state your _ready is still building, the values it reads are pre-await. Set defaults before the first await in _ready.

Why AI assistants keep writing callback hell instead

If you ask a generic AI assistant to "wait three seconds and then play an animation in Godot," most produce a Timer node, a timeout signal connection, and a callback function. Correct, verbose, and ten years out of date.

The pattern goes deeper than that single example. AI tools trained on web tutorials tend to reach for callback patterns from JavaScript, Python's asyncio.create_task, or C# events. GDScript's await is closer to Python's await and C# await, but with Godot-specific signal types that web examples do not cover. Domain-specific tools like Ziva that live inside the Godot editor see your actual signals and emit await-based code; generic tools fall back to whatever pattern is most common in their training set.

When to skip await

Two cases:

  • Hot per-frame code. Use _process or _physics_process for things that need to run every frame. await is for sequenced one-shot logic, not animation curves.
  • Cross-scene communication. A signal connected through connect() is still the right answer when two unrelated nodes need to talk to each other reactively. await works inside one coroutine; connect is the pub-sub between systems.

The mental model that works: await is for code that reads top-to-bottom but needs to wait. connect is for code that reacts whenever something happens.

The short version

Use await for timers, animations, input prompts, turn order, and HTTP. Wrap critical awaits in timeout helpers. Watch out for freed nodes. Set defaults before awaiting in _ready. And if your AI assistant still emits connect("pressed", _on_pressed) chains for these patterns, it's reading from a 2021 tutorial.

Top comments (0)