A bullet needs somewhere to keep its position and remaining life. When it leaves the screen, that space should become available again. This small fixed pool gives you eight slots, refuses a ninth object, then shows slot 1 being released and reused. A lifetime pass removes the finished objects without shifting anyone else in memory.

Include, initialise and call
Reserve POOL_CAPACITY contiguous flag bytes at pool_flags and include pool.inc once. Call pool_init at startup; it clears the flags, leaving payload alone. Flag zero means free and one means occupied. The component’s capacity contract is 1–255; these maintained native callers qualify 4 and 8, and their compact occupancy renderer only represents eight slots.
Call pool_acquire: D1.L = 0 / D0.L = slot means success; D1.L = 1 / D0.L = capacity means full. Both results are full-width values. pool_release takes an unsigned D0.L index, preserves it and returns D1.L = 0 on release or 1 for invalid/already free. It rejects the index before forming an indexed address. D0/D1/A0/CCR change, except release preserves D0; D2–D7 and A1–A6 survive. Do not interpret carry as its status API. Init has no D0 result. A direct call uses a four-byte return address; ageing additionally saves D3 and calls release, adding four saved bytes and another four-byte return address on expiry.
Reusable pool initialisation, acquire and release
; SPDX-License-Identifier: MIT
; pool_flags: capacity private bytes; 0 free, 1 occupied. Capacity1..255.
; acquire D1.L=0 and D0.L=index; full D1.L=1, D0.L=capacity.
; release D0.L=index; D1.L=0 success, 1 invalid/already free, no write.
; init clobbers D0/D1. All calls clobber D0/D1/A0/CCR except release preserves D0.
; D2-D7/A1-A6 survive. No chipset/IRQ changes. Single owner.
pool_init:
lea pool_flags,a0
move.w #POOL_CAPACITY-1,d0
moveq #0,d1
pool_init_loop:
clr.b (a0)+
dbra d0,pool_init_loop
rts
pool_acquire:
lea pool_flags,a0
moveq #0,d0
pool_acquire_loop:
tst.b 0(a0,d0.w)
beq.s pool_claim
addq.l #1,d0
cmpi.l #POOL_CAPACITY,d0
bne.s pool_acquire_loop
moveq #1,d1
rts
pool_claim:
move.b #1,0(a0,d0.w)
moveq #0,d1
rts
pool_release:
cmpi.l #POOL_CAPACITY,d0
bhs.s pool_reject
lea pool_flags,a0
tst.b 0(a0,d0.w)
beq.s pool_reject
clr.b 0(a0,d0.w)
moveq #0,d1
rts
pool_reject:
moveq #1,d1
rts
component_end:On success the slot is already marked occupied. Initialise every payload field that its update or renderer will read—position, speed, type or lifetime—before another reader sees it. Keep one owner for pool mutations. Interrupts can record requests for that owner rather than looking at half-initialised objects. The helper is not re-entrant and supplies no locking.
Check the success result before indexing a payload array. A full result returns the capacity, just outside the valid index range. It is an ordinary game decision: refuse another shot, drop a cosmetic effect or deliberately choose an eviction policy. The allocator never silently replaces an object.
Buildable caller and include order
section code,code_c
POOL_CAPACITY = 8
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "pool.inc"
include "age.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
guard_a: dc.b 0
pool_flags: dcb.b POOL_CAPACITY,0
guard_b: dc.b 0
pool_life: dcb.b POOL_CAPACITY,0
selected: dc.b 0
status_bits: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1294,0
even
signature: dc.b "198XPOOLSAMIGA!",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0The standalone A500 OCS/PAL caller cold-boots an OFS ADF with Kickstart 1.3. It takes over interrupts, DMA, Copper and a private single bitplane; it never returns to the OS. The helper itself touches no custom-chip registers or IRQ state. Code and private flag/payload storage are in a relocated Hunk; keep that writable storage in RAM and provide a suitably aligned instruction address. The 880 KiB ADF and its executable include loader, font, display and verification overhead; neither is the cost of including the helper.
Release during an update
pool_release clears one occupied flag. It changes neither position nor any other payload. Invalid and double releases return failure without a write. A reused index refers to a new object: clear references held by other objects when releasing it. Generation-tagged handles are a possible later extension when references must survive reuse; this routine has none.
The optional lifetime pass visits fixed indices in order. Each occupied slot needs a lifetime of 1–255. On each simulation step it decrements live lifetimes and releases those reaching zero. A zero initial lifetime is outside this pass’s contract and would wrap. Nothing moves when an object is released, so the loop cannot skip a neighbour that was shifted into its place.
Optional fixed-slot lifetime pass
; Optional lifetime policy. Occupied life1..255; spawn after pass.
; D0/D1/A0/CCR change; D2-D7/A1-A6 survive. Four saved stack bytes
; plus return address and a nested release return on expiry.
pool_age:
move.l d3,-(sp)
moveq #0,d3
pool_age_loop:
lea pool_flags,a0
tst.b 0(a0,d3.w)
beq.s pool_age_next
lea pool_life,a0
subq.b #1,0(a0,d3.w)
bne.s pool_age_next
move.l d3,d0
bsr pool_release
pool_age_next:
addq.l #1,d3
cmpi.l #POOL_CAPACITY,d3
bne.s pool_age_loop
move.l (sp)+,d3
rts
age_end:Apply new spawn requests after this pass. Otherwise a new object in a later slot might update immediately while one reusing an earlier slot waits. This is simulation work, not a frame waiter: choose when your game’s loop calls it.
Fill, refuse overflow, reuse and age the collection
preview_pool:
bsr pool_init
bsr pool_acquire
move.b d0,preview
bsr pool_acquire
move.b d0,preview+1
bsr pool_acquire
move.b d0,preview+2
preview_fill:
bsr pool_acquire
tst.l d1
beq.s preview_fill
move.b #255,preview+3
moveq #1,d0
ifeq BROKEN
bsr pool_release
endc
bsr pool_acquire
tst.l d1
beq.s preview_reused
move.l #255,d0
preview_reused:
move.b d0,preview+4
move.l #255,d0
bsr pool_release
move.b d1,preview+5
lea pool_life,a0
moveq #0,d6
preview_life:
move.l d6,d0
andi.b #1,d0
addq.b #1,d0
move.b d0,(a0)+
addq.l #1,d6
cmpi.l #POOL_CAPACITY,d6
bne.s preview_life
bsr pool_age
bsr occupancy
move.b d0,preview+6
bsr pool_age
bsr occupancy
move.b d0,preview+7
rtsMake it your own
custom.asm changes capacity from 8 to 4. Component code size is unchanged in this layout; flags and lifetime storage each shrink by four bytes. The first six output fields stay the same; the first lifetime mask becomes 0A, meaning slots 1 and 3 survive, then 00. Both capacities run through the native ownership tests.
Use four slots with the same ownership rules
section code,code_c
POOL_CAPACITY = 4
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "pool.inc"
include "age.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
guard_a: dc.b 0
pool_flags: dcb.b POOL_CAPACITY,0
guard_b: dc.b 0
pool_life: dcb.b POOL_CAPACITY,0
selected: dc.b 0
status_bits: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1294,0
even
signature: dc.b "198XPOOLSAMIGA!",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0The eight fields are the first three acquired indices, FF for the refused allocation, the reclaimed index, status from an invalid release, and occupied-slot masks after two updates. In a mask, bit i means slot i is live. AA means slots 1, 3, 5 and 7 remain. FF is the caller’s display sentinel, never a successful slot.
Give bullets position/velocity arrays or enemies a type/state array, without making a pool index into a hardware sprite number. A game object can be offscreen, use several sprites or have no visible sprite. The sprite lifecycle is a separate drawing concern.
Common mistake: forget to release
The labelled wrong caller omits the release of slot 1. All flags stay occupied, so the next acquire correctly fails and the fifth field becomes FF. The program completes its remaining lifetime passes: this is an observed exhaustion result, not a failed loader. Restore the release call shown in the working preview; the repaired executable is identical to the working executable. The Spectrum’s tape header can have a different filename even when its loaded CODE is identical.
Deliberate fault: omit release before reacquiring
section code,code_c
POOL_CAPACITY = 8
BROKEN = 1
include "caller.inc"
include "exercise.inc"
component_start:
include "pool.inc"
include "age.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
guard_a: dc.b 0
pool_flags: dcb.b POOL_CAPACITY,0
guard_b: dc.b 0
pool_life: dcb.b POOL_CAPACITY,0
selected: dc.b 0
status_bits: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1294,0
even
signature: dc.b "198XPOOLSAMIGA!",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0
If a real game stops spawning, inspect every removal path: leaving the playfield, ending an effect and changing rooms all need an ownership decision. Releasing a slot does not erase stale payload or invalidate somebody else’s saved index automatically.
Cost and alternatives
On this layout, the helper uses 84 code bytes plus N flag bytes. The optional lifetime pass adds 42 code bytes plus N lifetime bytes. Those are component costs, separate from native packaging and the diagnostic client.
Init writes N flags. Acquisition scans through the first free flag, or all N when full. Release checks a valid slot and writes at most one flag. Ageing scans N flags, decrements each live lifetime and calls release for each expiry. These are work counts, not physical-hardware cycle measurements. Addressing choices and complete update/display work still need a budget.
A free list trades faster allocation for extra bookkeeping. Packed arrays avoid scanning holes but move objects, changing references and deletion rules. For a small bounded collection, the scan keeps ownership easy to inspect. Maths for Games connects indexing and bit masks; The Craft helps decide what exhaustion should mean to the player.
What ran
Four native executions with Asm198x 0.0.72 / Emu198x 0.44.2 cover eight slots, four slots, omitted release and exact executable repair. Amiga media is mastered by Build198x 0.2.9. A separate caller emits all 256 occupancy masks for each configured capacity, allocation/full/release results, a preserved-register sentinel, guards, cleared flags and untouched lifetime payload. The four-slot fixture ignores upper mask bits. A set-based oracle checks that output; the visible client checks reuse and safe lifetime removal.
The checks also bind actual loaded code, startup completion, component extents, capture hashes and native text pixels. Configuration records pin tools and applicable firmware; no firmware is distributed. These are named emulator workloads, not original-hardware timings or a claim about untested capacities.