A [dynamic]T is a small fixed header that owns a separate, growable buffer. The header is four fields — data (where the elements live), len (how many are in use), cap (how many fit before the buffer must be replaced), and an allocator (where new memory comes from). Append items and watch len fill while the buffer stays put — until it can't.
Level 1 was what it is. Now: the case where a fixed array is the wrong call — a count you only learn at runtime, that keeps changing.
Think of the live entities a frame has to update: monsters spawn, projectiles fly, particles are born and die. How many there are, and which kinds, change every frame — you cannot write the count into the source. The example below is one frame; hit "next frame" and watch the population swing. A fixed [N]T forces you to guess N at compile time and either overflow it or waste it; a [dynamic]T simply grows to whatever this frame needs and shrinks back when you clear it.
The mechanism: one call does it — append(&entities, e). Because the live elements sit contiguously in the backing buffer, getting entities[i] is a direct indexed access — step i elements from the start of the buffer, you are there. The buffer is one packed run, so iterating the frame's entities walks straight through memory. The header carries the bookkeeping; you carry none of it.
The counterfactual: if the count were genuinely fixed — exactly four party members, always — you would not reach for this. A [4]Entity fixed array is smaller, needs no allocator, and never reallocates. The dynamic array earns its 40-byte header and its allocation because the count is unknown and moves.
A fixed [N]T sized to the worst case — say [1024]Entity "to be safe": every frame pays for 1024 slots even when 12 are alive, and a 1025th spawn still overflows. Wasteful and still not safe.
A fixed array plus a hand-tracked count (buf: [1024]Entity alongside count: int you bump yourself): now count and the buffer can drift out of step — bump it but forget to write the slot, or write the slot but forget to bump, and you read a stale or uninitialized element. The same two numbers the dynamic-array header keeps in lockstep for you, now yours to desync.
A buffer you resize by hand — allocate, and when it fills, allocate a bigger one, copy across, free the old: that is a dynamic array, rebuilt by hand, with the data pointer, len, cap, and the free all yours to get wrong. [dynamic]T folds those four into one type and hands you append. More code, same idea, more bugs.
Level 2 sold you on it. Level 3 is the bill: growth is not free — when the buffer fills, the array allocates a bigger one, copies every element across, and frees the old. Append one at a time and watch exactly when that happens.
Each press of append adds one element. While len < cap it just writes the next slot — cheap. When len would exceed cap, the buffer is replaced: this toolchain goes cap 0 → 8 on the first append, holds at 8 through the eighth element, then jumps 8 → 24 on the ninth. Each jump is a full copy of everything already in the array.
The hidden hazard: because a grow moves the buffer, any pointer you took into the old one — p := &entities[0] — now points at freed memory. A measured grow on this toolchain: cap before = 8, cap after = 24, buffer moved = true. Hold a pointer across an append that grows, and you are reading reclaimed storage. A slice entities[:] goes stale the same way. Treat any pointer or slice into a dynamic array as valid only until the next append that might grow.
Each grow is alloc + copy + free. The growth is geometric, so it is amortized cheap — but if you already know roughly how many elements are coming, you can skip the intermediate grows entirely with make([dynamic]int, 0, 1024) (length 0, capacity 1024) or reserve(&xs, 1024). Toggle the two paths to 1024 elements:
The last level isn't about bytes — it's a discipline that emerges from one fact: a [dynamic]T owns its buffer, and nothing frees it for you. Ownership you can see is ownership you can audit.
There is no garbage collector. Every dynamic array you allocate is a buffer that stays alive until you hand it back with delete. Forget, and it leaks — silently at small scale, fatally over hours. The idiom makes the obligation visible: write defer delete(xs) on the line right after the declaration, so the cleanup sits next to the thing it cleans up and a reader can confirm the pair at a glance.
xs: [dynamic]int defer delete(xs) // paired with the declaration — runs at scope exit append(&xs, 10) append(&xs, 20, 30, 40) // the array owns one buffer; one delete frees it
Two ways to empty, and they are not the same. Within a frame you usually want to reuse the buffer, not give it back: clear(&xs) sets len to 0 but keeps the capacity, so the slots you already paid for are ready for next frame's appends — no allocation. delete(xs) is the opposite: it returns the buffer to the allocator entirely. The measured difference on a 5-element array:
The emergent payoff: because ownership is explicit, "who frees this?" is never a mystery you chase at runtime — it is a line of code you can point at. Every [dynamic]T declaration carries a visible defer delete beside it; every per-frame buffer is a clear at the top of the frame instead of a fresh allocation. At the scale of a real game loop, that discipline is the difference between a process that runs for ten frames and one that runs for ten hours — and it is auditable by reading, because the language never hid the allocation from you in the first place.
That's the arc: L1 a 40-byte header — data, len, cap, allocator — owning a separate buffer → L2 that's why you reach for it: a runtime count that keeps changing, grown by one append → L3 the bill is grow = alloc + copy + free (cap 0→8→24 here), so a held pointer dangles and reserve skips the grows → L4 you own the buffer, so defer delete frees it and clear reuses it.