G2.1 — The Node Lifecycle & the Game Loop¶
What you'll learn
- Order the lifecycle callbacks:
_init→_enter_tree→_ready→ per-frame →_exit_tree. - Choose
_process(per rendered frame) versus_physics_process(fixed step) for the right work. - Multiply per-frame change by
deltaso behavior is frame-rate independent. - Free nodes safely with
queue_freeinstead offree.
How it applies
- Movement without
deltaruns at the wrong speed on other hardware. Add a fixed amount to a position every frame and the character moves twice as fast at 144 Hz as at 72 Hz. It feels right on the developer's monitor and wrong on the player's — a hardware-spread defect thatdelta(and the right callback) eliminate. - Touching children too early crashes. Code in
_initor_enter_treeruns before the whole subtree is assembled; child references are not safe until_ready. This is why signals are connected in_ready, the question Part A kept deferring. free()mid-frame leaves dangling references. Deleting a node immediately, while a signal or loop is still using it this frame, crashes on the now-invalid reference.queue_freedefers the deletion to a safe point.- Per-frame work is a budget. Anything in
_processruns every rendered frame — tens to hundreds of times a second. Heavy work there is a performance defect; the callback you choose and what you put in it is a cost decision.
Try it first
You move a character with position.x += 5 inside _process. On your 60 Hz monitor it feels right;
on a friend's 144 Hz monitor it sprints across the screen. Before reading on: why does the frame
rate change the speed, which callback should this go in, and what factor makes the speed identical
on any monitor?
Concepts¶
The lifecycle, in order¶
Godot calls a sequence of virtual callbacks over a node's life. The order is the thing to memorize:
_init()— the node is created in memory (L2.1). No children, not in the tree._enter_tree()— the node is added to the tree. Called parent before children (top-down)._ready()— called once, after the node and all its children are in the tree. Called children before parent (bottom-up), so a parent's_readyruns only after every child is ready._process(delta)and_physics_process(delta)— called repeatedly, every frame, for as long as the node is in the tree._exit_tree()— the node is removed from the tree (children before parent).
The bottom-up ordering of _ready is exactly why it is the safe place to grab child references and
connect signals: by the time a parent's _ready runs, all its children have already entered the tree
and run their own _ready. _init and _enter_tree are too early — the subtree is not complete.
_process versus _physics_process¶
Two per-frame callbacks, for two different clocks:
_process(delta)runs once per rendered frame, at whatever the display frame rate happens to be — 60, 144, or a stuttering 50.deltais the variable time since the last frame._physics_process(delta)runs at a fixed rate (default 60 Hz), decoupled from the frame rate.deltahere is the constant physics step (~0.0167 s at 60 Hz).
Put movement and physics in _physics_process so they tick at a steady, frame-rate-independent
rate; put per-frame visual updates (reading input for a UI, animating a non-physical value) in
_process. Mixing this up gives physics that jitters with the frame rate.
delta and frame-rate independence¶
Whichever callback you use, scale per-frame change by delta:
func _physics_process(delta: float) -> void:
position.x += speed * delta # speed is pixels per SECOND, not per frame
speed * delta converts "pixels per second" into "pixels this frame," so the character covers the
same distance per second no matter how many frames that second was split into. That is the answer to
the "Try it first" prompt: position.x += 5 adds 5 per frame, so more frames means more movement;
position.x += speed * delta adds a per-second amount, identical on any monitor.
Example
The bug and the fix, side by side:
# WRONG: 5 pixels per FRAME → faster at higher frame rates
func _process(_delta: float) -> void:
position.x += 5
# RIGHT: speed pixels per SECOND, in the physics step
func _physics_process(delta: float) -> void:
position.x += 300.0 * delta
Two changes: move to _physics_process (steady step) and multiply by delta (per-second rate).
The character now moves at 300 px/s whether the monitor runs at 30 Hz or 240 Hz. (Naming the
parameter _delta with a leading underscore signals "intentionally unused" — the wrong version
does not use it, which is itself the smell.)
Freeing nodes: queue_free versus free¶
To remove a node, call queue_free():
queue_free defers deletion to the end of the frame, after current callbacks and signals have
finished using the node. free() deletes immediately, which is dangerous: if anything still
references the node later this frame (a signal mid-emit, a loop mid-iteration), it now points at freed
memory and crashes. Use queue_free for nodes during gameplay; free is for rare cases where you know
nothing else touches the node this frame.
Walkthrough¶
- Attach a script to a node and implement all of
_init,_enter_tree,_ready, each printing its name. Add a child node with the same script. Run with F6 and read the Output to confirm the order — note the child's_readyprints before the parent's. - Reproduce the frame-rate bug: in
_process, doposition.x += 5on aNode2Dwith a visible sprite. Run, then (in Project Settings, or by capping/uncapping vsync) change the frame rate and observe the speed change. - Fix it: move the line to
_physics_process(delta)and change it toposition.x += 300.0 * delta. Confirm the speed is now stable. - Demonstrate
queue_free:await get_tree().create_timer(1.0).timeoutthenqueue_free()on a node, and confirm it vanishes after one second.
Optional sanity check
In _init, try to access a child with $Child. It fails — the child is not in the tree yet. Move
the same access to _ready and it works. That contrast is the lifecycle ordering made concrete, and
the direct answer to "why connect signals in _ready."
Self-check quiz¶
Q1 — position.x += 5 in _process moves a character faster on a 144 Hz monitor than a 60 Hz one. Why, and the fix?
A. Higher-Hz monitors render larger; no code fix needed.
B. _process runs once per frame, so more frames per second means more += 5 per second; fix by moving to _physics_process and scaling by delta (+= speed * delta).
C. _process is broken; always use _ready for movement.
D. Multiply by the frame rate instead.
Reveal answer
B. The addition happens per frame, so a faster frame rate accumulates more movement per
second. Scaling by delta converts a per-second speed into the correct per-frame step, and
_physics_process gives a steady tick for movement. A is false. C misuses _ready (it runs
once, not per frame). D is backwards — you multiply by delta (seconds per frame), not by the
rate.
Q2 — Why is _ready the safe place to access a child node, but _init is not?
A. _init cannot run code.
B. _init runs at object creation, before the node and its children are in the tree; _ready runs once after the whole subtree (children first) has entered the tree.
C. $ only works in _ready.
D. _ready creates the children.
Reveal answer
B. The lifecycle order makes _init too early — children do not exist yet — while _ready
fires only after all children have entered the tree (bottom-up), so references are valid. A is
false (_init runs code fine). C is false syntactically. D is wrong about who creates children.
Q3 — Why prefer queue_free() over free() for removing a node during gameplay?
A. queue_free is faster.
B. queue_free defers deletion to the end of the frame, after current callbacks/signals finish using the node; free deletes immediately and can leave dangling references that crash.
C. free does not exist in Godot 4.
D. They are identical.
Reveal answer
B. Immediate deletion mid-frame can invalidate references still in use this frame;
queue_free waits for a safe point. A is not the reason (safety is). C is false — free exists
but is for cases where nothing else touches the node. D ignores the timing difference that is the
whole point.
Integration question¶
Q4 — open
An enemy script connects a signal to a child node in _init, moves with velocity = dir * speed
applied via position += velocity in _process, and on death calls free() from inside the
died signal handler. Three lifecycle/loop mistakes are present. Name each, the rule it violates,
and the corrected approach.
Reveal expected answer
Mistake 1 — connecting in _init. _init runs before children are in the tree, so the child
node is not available to connect to; this belongs in _ready, which fires after the whole
subtree (children first) is ready. Mistake 2 — frame-rate-dependent movement. position +=
velocity in _process adds a per-frame amount at a variable frame rate, so speed scales with
the monitor; movement should be in _physics_process and scaled by delta (position +=
velocity * delta, with velocity expressed in pixels per second). Mistake 3 — free()
mid-signal. Calling free() from inside the died handler deletes the node immediately while
the signal is still being processed, leaving the emitter and any other connected handlers holding
a dangling reference — a crash risk; use queue_free() so the deletion happens at the end of the
frame, after the signal finishes. The unifying idea: the lifecycle defines when a node's
children and references are valid and when it is safe to remove it, and the loop callbacks
define how often code runs and therefore why per-second work must be scaled by delta.