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:
- Tick 1:
_physics_processis called. It runs to theawaitand suspends. - Tick 2: the engine calls
_physics_processagain — a fresh entry on a new stack — while the previous coroutine is still suspended. - Now two coroutines are racing to mutate
velocity,state, and the sameposition.
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.