Quick answer: Never call await directly inside _physics_process or _process. Use a Timer node, a tick-counter, or move the await into a separate coroutine called from _ready or a signal handler.

An enemy is supposed to telegraph its attack with a half-second windup, then strike. The first version of the code looked clean — await get_tree().create_timer(0.5).timeout in the middle of _physics_process. The result on screen: the enemy stutters, attacks fire twice, the player’s collision response feels laggy, and the physics step occasionally skips a frame entirely.

What Await Does to a Per-Tick Callback

When GDScript encounters await, it transforms the surrounding function into a coroutine. Execution suspends at the await; control returns to the engine; the engine resumes the coroutine when the awaited signal fires.

For _physics_process, this is catastrophic:

  1. Tick 1: _physics_process is called. It runs to the await and suspends.
  2. Tick 2: the engine calls _physics_process again — a fresh entry on a new stack — while the previous coroutine is still suspended.
  3. Now two coroutines are racing to mutate velocity, state, and the same position.

The visible result is non-deterministic: jitter, duplicated attacks, state machine corruption, occasional silent state freezes when one coroutine clobbers the other’s changes.

The Wrong Code

# anti-pattern
func _physics_process(delta):
    if state == ATTACKING:
        await get_tree().create_timer(0.5).timeout   # suspends
        do_strike()
        state = IDLE

Fix 1: Manual Tick Counter

Replace the timer with a counter you decrement each tick:

var windup_timer: float = 0.0
var state = IDLE

func _physics_process(delta):
    match state:
        WINDUP:
            windup_timer -= delta
            if windup_timer <= 0.0:
                do_strike()
                state = RECOVER

func start_attack():
    state = WINDUP
    windup_timer = 0.5

This is the standard finite-state machine approach for physics-driven entities. No coroutines, no allocations per tick, fully deterministic.

Fix 2: Timer Node with Signal

For a one-shot delay that triggers an action, a Timer node and signal connection is also fine — the callback is dispatched between physics ticks, not inside one:

@onready var windup_timer: Timer = $WindupTimer

func _ready():
    windup_timer.timeout.connect(_on_windup_done)

func start_attack():
    state = WINDUP
    windup_timer.start(0.5)

func _on_windup_done():
    do_strike()
    state = IDLE

Fix 3: Await in a Coroutine Off the Per-Tick Path

If you really want async syntax, isolate the await in a function called from a signal handler — never from _physics_process. The coroutine runs once per attack rather than re-entering on every tick:

func _ready():
    self.attack_requested.connect(_run_attack)

func _run_attack():
    state = WINDUP
    await get_tree().create_timer(0.5).timeout
    do_strike()
    state = IDLE

Because _run_attack is called once per attack — not 60 times per second — the await suspends one coroutine, not a flood of them.

Diagnosing Existing Code

Grep your scripts for await inside callback bodies:

grep -rn -B 5 "await" --include="*.gd" | grep -E "_physics_process|_process|_input"

Any match is a candidate for one of the three fixes above. If the await is for RenderingServer.frame_post_draw, that’s the rare legitimate use; otherwise refactor.

Verifying

Add a print(Engine.get_physics_frames(), state) at the top of _physics_process. Before the fix, you’ll see overlapping states or skipped frames during action windups. After the fix, state transitions are clean and tied to the counter or signal.

“Await is a coroutine in disguise. Don’t spawn one every tick — spawn one per action and let the rest of physics stay synchronous.”

If a coworker says “we’re using await for a delay,” check whether the await is in a per-tick callback. 9 times out of 10 it is.