Part 03 — Instantiate and free
You will build: a pool — the fixed set of long-lived workers, the dispatch, the reset
— and you'll break it on purpose to see what a pool is for.
You'll learn: instantiate/free as the allocation spike part 01 named · the reset as
the cost people forget · containment as the crash fix · queue_free vs free · and
configure-before-parent, the ordering that's quiet when wrong.
Why this exists#
Part 01's spike — the clump-death's 30 allocations in one frame — is this part's client. The spike's shape is create many, use briefly, destroy many, and the pool is the shape's answer: stop creating and destroying; create a fixed set once, and reuse. The survivors stage 08 pool (the float text) is the worked example; this part is the general form, because the same shape is the projectile (the survivor's stage 09 stretch — a crossbow minting projectiles fifty a minute), the particle, the UI toast, and every "spawn on event, die on timeout" object in a game.
Build it#
A — The cost the pool removes#
instantiate() walks the scene, allocates the object tree, runs every _ready; free (or
the end-of-frame queue_free) tears it down. Neither is expensive in the absolute — a
float text is a Node2D and a Label — but a burst of thirty of them in one frame is a
thirty-fold allocation spike on the frame the player is watching (the clump-death, the
screen full of damage numbers), and the spike is the dip part 01 measured. The pool moves the
allocation out of the burst frame and into the steady state: twenty-four texts allocated at
boot (spread over the load, invisible), and the burst is twenty-four resets — a property
set, a position set, a tween restarted — which is orders of magnitude cheaper than a tree
walk.
The pool's arithmetic is part 01's steady-vs-spike, made concrete: the pool size is the
steady-state population, and the burst is allowed to exceed it. The survivors stage 08's
pool is 24 (the steady population of live float texts), and a 30-death burst mints the
extra 6 past the floor — the if _free.is_empty(): instantiate() branch, the pool as a
floor, not a wall. A pool sized to the burst (30) is a pool that's idle half the time; a
pool sized to the steady state (24) absorbs the burst's common case and mints the rare
overflow. The size is the steady population, measured, not the peak, guessed.
B — The pool, and the borrower-is-the-librarian flow#
class_name FloatTextPool
extends Node
const POOL_SIZE := 24
const SCENE: PackedScene = preload("res://entities/float_text.tscn")
var _free: Array[Node2D] = []
func _ready() -> void:
for i in POOL_SIZE:
var t: Node2D = SCENE.instantiate()
t.setup(self)
t.visible = false
add_child(t)
_free.append(t)
## Take a text out (or mint one past the floor), reset it, point it.
func spawn(at: Vector2, value: String) -> void:
var t: Node2D
if _free.is_empty():
t = SCENE.instantiate()
t.setup(self)
add_child(t)
else:
t = _free.pop_back()
t.reset(at, value)
## A finished text returns itself.
func retire(t: Node2D) -> void:
_free.append(t)
The flow is the design: spawn takes a node out and never puts it back; the node
re-enters the pool from its own ending (retire, called when its tween finishes). The
borrower is the librarian — the book checks itself in. The alternative (the pool tracking
which nodes are busy, with a timer to reclaim them) is a second clock on the node's
lifetime, and a second clock is where the "the pool thinks it's free but the text is still
flying" bug lives. One owner of the lifetime (the node, via its tween's await) is the shape
that can't double-spawn.
C — The reset: the cost people forget#
A pooled node comes back with its last life's state — the float text's final scale (1.6),
final alpha (0.0), final position. The reset restores all three before the next life's
tween:
func reset(at: Vector2, value: String) -> void:
scale = Vector2.ONE
modulate = Color.WHITE
position = at
label.text = value
visible = true
_play()
The reset is the pool's entire correctness surface — the dispatch is three lines, the
retire is one, and the reset is the part that's wrong when the pool is wrong. The survivors
stage 08's broken-pool drill is the test: comment the scale/modulate lines out and
watch what a pool is for. The texts arrive pre-shrunk (the last life's 1.6 scale, never
reset to 1.0 before the pop) and invisible (the last life's 0.0 alpha, never reset to 1.0
before the fade) — a pool that's broken is a pool that's leaking its last life into its
next, and the leak is the reset's absence. A pool you haven't broken once, you don't
understand yet (stage 08's checkpoint, verbatim).
The general rule, from the audio shelf's part 03 (the sound pool's fader): a pooled worker must end every job in a state the next job can start from. The reset is the start state; the retire is the end state; and the end state must equal the start state's prerequisites (alpha 1.0 before a fade-out, scale 1.0 before a pop). The worker carries no memory between jobs — the memory is the leak.
D — Containment: the crash fix that's a parent chain#
The float text's await tween.finished is the line that crashes a naive pool: the text
awaits its tween, and if the scene reloads (the survivor's stage 09) while twenty texts are
flying, the pool frees its children — and a freed node's await resumes on a dead object the
next frame, the "resuming function on freed object" error. The fix is containment, not a
guard:
- The float text is a child of the pool; the pool is a child of
Entities;Entitiesis freed on the scene reload. WhenEntitiesfrees, the pool frees, the texts free, and the tweens die with their targets — acreate_tween()bound to a node is freed when the node is, and anawaiton a freed tween never resumes. The parent chain is the crash fix: the text's lifetime is contained in the pool's, the pool's in the world's, and the world's end is the one place every lifetime agrees to stop.
The ownership argument is this, drawn: a value dies
when its holder dies, so a lifetime longer than its holder's is a lifetime the holder
didn't agree to. A float text that set_as_top_level'd itself under root (outliving
Entities, the survivor's stage 09 stuck-list "ghost pool") is a value whose holder died
while it lived — the containment broken, and the ghost is the symptom. The pool's children
are contained; a text that escapes the container is a text that outlives the world.
E — queue_free vs free, and configure-before-parent#
Two orderings, both quiet when wrong:
queue_freedefers to end of frame;freeis now.queue_freeis the safe default — you can call it from inside a signal, a physics callback, anarea_entered, and the deletion happens after the frame settles, so no mid-frame code touches the freed node.freeis immediate: the node is gone this line, and any code later in the frame that holds a reference (a signal handler still on the stack, an overlap list not yet flushed) touches a dead object — the "attempt to call on freed object" error. The rule:queue_freefrom any callback;freeonly when you've verified nothing else is mid-call — which is rarely true, which is whyqueue_freeis the default andfreeis the exception you justify. (The survivor's stage 04 death isqueue_freefrom thehealth_depletedsignal — the safe call, in the right place.)- Configure, then commit to the tree. The survivor's stage 06 drop:
instantiate(), setexp_valueandposition, thenadd_child. Parenting runs_readyand starts the node's processing; a node parented before configured runs its first frame with defaults (the drop worth 1.0 instead of its species' 4.0, the text at the origin instead of the hit). The ordering is the same as part 03's reset — the node's state is whole before its lifetime starts — and the pool'sspawn(reset, then the node is already in the tree) is the inverted case: the node's lifetime outlives its configurations, so the reset is the per-life "configure before commit."
F — The projectile: the pool at the count it earns#
The survivor's stage 09 stretch is the pool's stress case: a crossbow at 0.5 s cadence mints
a projectile fifty a minute, and each lives until it hits (a second or two). The steady
population is rate × lifetime — 50/60 × 1.5 ≈ 1.25 projectiles alive on average — but the
burst is the volley (a spread shot, ten at once), and the pool sizes to the burst's common
case (ten) with the mint-past-the-floor for the rare overflow. The projectile is the object
where the pool stops being insurance: at one alive on average, instantiate/queue_free
per shot is cheap and the pool is complexity for no measured gain (part 01's refused claim);
at ten per volley, the burst is a real spike and the pool is the fix. The pool earns at the
count where the burst is a dip you measured — the crossbow's ten is that count; the
great-long-sword's zero (a melee weapon mints nothing) is the count where the pool is the
refused claim.
G — Commit#
git add . && git commit -m "perf 03: the pool — reset, containment, the ordering"
Checkpoint — definition of done#
- The pool is built (the steady-size workers, the dispatch, the self-retire), and the burst past the floor mints (the floor-not-wall branch) — the overflow works, not just the common case
- The reset is complete (the start state, every property the tween touches), and the broken-pool drill ran: you commented the reset lines, saw the pre-shrunk/invisible texts, and restored them — the leak met, not just described
- The containment is the parent chain (text → pool → world), and a scene reload mid-burst
doesn't resume a freed
await— the crash met by the chain, not a guard - You can say
queue_freevsfreeand when each is right (the callback default, the justified exception), and configure-before-parent (the drop's value, the text's position) - The pool's size is
steady population, measured— and you can say what count would make the pool the refused claim (the projectile at one alive on average, part F) - Zero warnings; committed
Stretch (no instructions)#
A pool of pools: the float text pool and the projectile pool share a base (the
spawn/retire/reset shape, the floor-not-wall, the self-retire), parameterized by the
scene and the reset. The base is the pattern part B–E named; the two pools are its instances.
The test is part 02's acceptance: a third pooled object (the particle, the UI toast) built
against the base with no new pool logic — only the scene and the reset. If the third pool
needs a new branch in the base, the base isn't the pattern yet; find the branch and move it
to the instance.
If you get stuck#
- Pooled nodes stack in the same spot → the
positionline in the reset is missing (or the node's last-life position wins). The reset is the start state; position is in it. Part C's drill, the position variant. - A
resuming function on freed objecton scene reload → the containment broke: a text outlived the pool (part D's ghost — aset_as_top_levelor a reparent toroot). Check the parent chain; the text's holder must die with the world. - The pool double-spawns (two texts, one
spawn) → the self-retire fired early (theawaitresumed before the tween visibly finished — a tween bound to a node that was reset mid-flight, the reset killing the tween theawaitwas on). The reset must not run while a life is in flight; the dispatch (part B's flow) guarantees a node is retired before it's spawned, and a reset on a busy node is the ordering broken. - The burst dip is gone but the steady frame is slower → the pool's workers are doing
per-frame work while "free" (a
_processon the float text, part 01's process line). A pooled worker that's not in use must be inert —set_process(false)on retire,set_process(true)on spawn (the audio pool's voices are inert when stopped, the same rule). The pool removed the allocation spike and added a steady-state cost; the inert worker is the fix for the cost the pool introduced.