Skip to content

Reusable Object Slots on Spectrum

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 Spectrum 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: carry clear / A = slot means success; carry set / A = capacity means full. pool_release takes A = index, and returns carry clear on release or carry set for invalid/already free. It does not promise to preserve A. AF, BC and HL change; DE, IX and IY survive. No port, interrupt or hidden scratch ownership. A direct call uses a two-byte return address. The optional ageing pass also saves a two-byte loop counter while making a nested release call.

Reusable pool initialisation, acquire and release

pool.inc
; SPDX-License-Identifier: MIT
; pool_flags: POOL_CAPACITY private bytes, 1..255; 0 free, 1 occupied.
; init clears flags only. acquire: NC/A=index; full C/A=capacity.
; release A=index: NC on occupied valid slot; C on invalid/already free.
; A/flags, BC, HL change; DE, IX, IY survive. No I/O, IRQ or hidden state.
; Single owner; initialise payload after acquisition, before any reader.
pool_init:
    ld hl,pool_flags
    ld b,POOL_CAPACITY
    xor a
pool_init_loop:
    ld (hl),a
    inc hl
    djnz pool_init_loop
    ret
pool_acquire:
    ld hl,pool_flags
    ld b,POOL_CAPACITY
    ld c,0
pool_acquire_loop:
    ld a,(hl)
    or a
    jr z,pool_claim
    inc hl
    inc c
    djnz pool_acquire_loop
    ld a,POOL_CAPACITY
    scf
    ret
pool_claim:
    ld (hl),1
    ld a,c
    or a
    ret
pool_release:
    cp POOL_CAPACITY
    jr nc,pool_reject
    ld l,a
    ld h,0
    ld bc,pool_flags
    add hl,bc
    ld a,(hl)
    or a
    jr z,pool_reject
    ld (hl),0
    or a
    ret
pool_reject:
    scf
    ret
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
    org $8000
POOL_CAPACITY equ 8
BROKEN equ 0
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "pool.inc"
    include "age.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
guard_a: defb 0
pool_flags: defs POOL_CAPACITY,0
guard_b: defb 0
pool_life: defs POOL_CAPACITY,0
mask_index: defb 0
selected: defb 0
status_bits: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1294,0
    end start

The standalone 48K PAL caller is entered by the ROM tape loader and BASIC USR. It disables interrupts, owns the bitmap, attributes and border port, and uses ROM glyphs for its small diagnostic row. It stays running; it does not restore a BASIC session. Its code starts at $8000 and its private flags, payload, packet and output variables follow the code. The reusable routines touch none of those video or port resources.

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. Live life must be 1..255. Spawn after the pass.
; AF/BC/HL change; DE/IX/IY survive. Fixed indices, no compaction.
; Saved loop counter plus nested release call use stack space on expiry.
pool_age:
    ld b,POOL_CAPACITY
    ld c,0
pool_age_loop:
    ld l,c
    ld h,0
    push bc
    ld bc,pool_flags
    add hl,bc
    ld a,(hl)
    pop bc
    or a
    jr z,pool_age_next
    ld l,c
    ld h,0
    push bc
    ld bc,pool_life
    add hl,bc
    dec (hl)
    pop bc
    jr nz,pool_age_next
    ld a,c
    push bc
    call pool_release
    pop bc
pool_age_next:
    inc c
    djnz pool_age_loop
    ret
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:
    call pool_init
    call pool_acquire
    ld (preview),a
    call pool_acquire
    ld (preview+1),a
    call pool_acquire
    ld (preview+2),a
preview_fill:
    call pool_acquire
    jr nc,preview_fill
    ld a,255
    ld (preview+3),a
    ld a,1
    if BROKEN=0
    call pool_release
    endif
    call pool_acquire
    jr nc,preview_reused
    ld a,255
preview_reused:
    ld (preview+4),a
    ld a,255
    call pool_release
    ld a,0
    adc a,0
    ld (preview+5),a
    ld hl,pool_life
    ld b,POOL_CAPACITY
    ld c,0
preview_life:
    ld a,c
    and 1
    inc a
    ld (hl),a
    inc hl
    inc c
    djnz preview_life
    call pool_age
    call occupancy
    ld (preview+6),a
    call pool_age
    call occupancy
    ld (preview+7),a
    ret

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
    org $8000
POOL_CAPACITY equ 4
BROKEN equ 0
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "pool.inc"
    include "age.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
guard_a: defb 0
pool_flags: defs POOL_CAPACITY,0
guard_b: defb 0
pool_life: defs POOL_CAPACITY,0
mask_index: defb 0
selected: defb 0
status_bits: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1294,0
    end start

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
    org $8000
POOL_CAPACITY equ 8
BROKEN equ 1
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "pool.inc"
    include "age.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
guard_a: defb 0
pool_flags: defs POOL_CAPACITY,0
guard_b: defb 0
pool_life: defs POOL_CAPACITY,0
mask_index: defb 0
selected: defb 0
status_bits: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1294,0
    end start
The Spectrum 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 56 code bytes plus N flag bytes. The optional lifetime pass adds 39 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.