doc 4 of 5

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; Entities is freed on the scene reload. When Entities frees, the pool frees, the texts free, and the tweens die with their targets — a create_tween() bound to a node is freed when the node is, and an await on 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_free defers to end of frame; free is now. queue_free is the safe default — you can call it from inside a signal, a physics callback, an area_entered, and the deletion happens after the frame settles, so no mid-frame code touches the freed node. free is 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_free from any callback; free only when you've verified nothing else is mid-call — which is rarely true, which is why queue_free is the default and free is the exception you justify. (The survivor's stage 04 death is queue_free from the health_depleted signal — the safe call, in the right place.)
  • Configure, then commit to the tree. The survivor's stage 06 drop: instantiate(), set exp_value and position, then add_child. Parenting runs _ready and 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's spawn (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_free vs free and 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 position line 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 object on scene reload → the containment broke: a text outlived the pool (part D's ghost — a set_as_top_level or a reparent to root). 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 (the await resumed before the tween visibly finished — a tween bound to a node that was reset mid-flight, the reset killing the tween the await was 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 _process on the float text, part 01's process line). A pooled worker that's not in use must be inertset_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.