Skip to content

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 delta so behavior is frame-rate independent.
  • Free nodes safely with queue_free instead of free.

How it applies

  • Movement without delta runs 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 that delta (and the right callback) eliminate.
  • Touching children too early crashes. Code in _init or _enter_tree runs 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_free defers the deletion to a safe point.
  • Per-frame work is a budget. Anything in _process runs 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:

  1. _init() — the node is created in memory (L2.1). No children, not in the tree.
  2. _enter_tree() — the node is added to the tree. Called parent before children (top-down).
  3. _ready() — called once, after the node and all its children are in the tree. Called children before parent (bottom-up), so a parent's _ready runs only after every child is ready.
  4. _process(delta) and _physics_process(delta) — called repeatedly, every frame, for as long as the node is in the tree.
  5. _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. delta is the variable time since the last frame.
  • _physics_process(delta) runs at a fixed rate (default 60 Hz), decoupled from the frame rate. delta here 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():

enemy.queue_free()      # deleted safely at the end of the current frame

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

  1. 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 _ready prints before the parent's.
  2. Reproduce the frame-rate bug: in _process, do position.x += 5 on a Node2D with a visible sprite. Run, then (in Project Settings, or by capping/uncapping vsync) change the frame rate and observe the speed change.
  3. Fix it: move the line to _physics_process(delta) and change it to position.x += 300.0 * delta. Confirm the speed is now stable.
  4. Demonstrate queue_free: await get_tree().create_timer(1.0).timeout then queue_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.