Skip to content

Reusable Object Slots on Amiga

Claim and release fixed game-object slots, handle exhaustion, and expire objects without moving their neighbours.

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.

The Amiga caller shows 00 01 02 FF 01 01 AA 00, including successful reuse of slot one.
The fifth field is 01: slot 1 was released and reclaimed. AA and 00 show occupied slots after two lifetime updates.

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

pool.inc
; 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

demo.asm
    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,0

The 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

age.inc
; 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

exercise.inc, excerpt
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
    rts

Make 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

custom.asm
    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,0

The 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

wrong.asm
    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
The Amiga caller shows FF in the fifth field because the pool is still full.
An omitted release leaves all slots occupied. The fifth field changes from 01 to FF.

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.

Maintained source and native evidence.