Skip to content

Reusable Object Slots on C64

Give bullets, enemies and effects fixed slots, handle a full pool, and remove objects without skipping their neighbours.

Fire a shot. Find somewhere to keep its position, speed and remaining life. When it leaves the screen, make that space available again. A small fixed pool gives those jobs a clear home—and forces you to decide what happens when every slot is busy.

This example has eight slots. It fills them, refuses a ninth object, releases slot 1 and claims that same slot again. Two lifetime updates then remove all the objects without shifting anything in memory. You can lift the allocator into a game without taking the display or the verification fixture with it.

C64 shows 00 01 02 FF 01 01 AA 00: slots fill, overflow is refused, slot one is reused and ageing empties the pool.
The fifth field is 01: the released slot is available again. AA and 00 show the occupied slots after the two lifetime passes.

Reserve the flags, then claim a slot

Define POOL_CAPACITY and POOL_FLAGS, reserve that many writable bytes and include the component once. Call pool_init before using the pool. Each flag is zero for free or one for occupied. Keep positions, velocities, types and other payload in your own arrays, indexed by the returned slot.

pool_acquire returns carry clear and X = slot on success. Fill that slot’s required payload fields before drawing or updating it. Carry set means full, and X = capacity. Branch on carry before using X: on failure it is just beyond the valid range. The helper has already marked a successful slot occupied, so keep allocation and initialization within the same owner’s update; an interrupt must not read half-initialised payload.

A full pool is an ordinary result. A shooter might refuse a shot; a cosmetic effect could be dropped. Replacing an old object requires an explicit policy. This routine never silently evicts one.

pool_release takes X. It clears a valid occupied slot and returns carry clear. Out-of-range or already-free returns carry set without writing. It leaves X intact. The helper does not erase payload: initialise the fields you need when reusing the slot, rather than assuming an old position or timer has disappeared.

Reusable initialise, acquire and release routines

6502.inc
; SPDX-License-Identifier: MIT
; Caller defines POOL_FLAGS (POOL_CAPACITY private writable bytes), capacity 1..255.
; Zero is free; one is occupied. Payload arrays belong to the caller.
; init clears flags only. acquire claims the lowest free slot: C=0, X=index.
; Full: C=1, X=POOL_CAPACITY. Never index arrays before checking carry.
; release X: C=0 on occupied valid slot; C=1 on invalid/already free, no write.
; All routines change A/flags, init/acquire also X. Y and decimal mode survive.
; Not re-entrant; do not mutate this pool from interrupts or another owner.
pool_init:
    ldx #0
    lda #0
pool_init_loop:
    sta POOL_FLAGS,x
    inx
    cpx #POOL_CAPACITY
    bne pool_init_loop
    rts
pool_acquire:
    ldx #0
pool_acquire_loop:
    lda POOL_FLAGS,x
    beq pool_claim
    inx
    cpx #POOL_CAPACITY
    bne pool_acquire_loop
    sec
    rts
pool_claim:
    lda #1
    sta POOL_FLAGS,x
    clc
    rts
pool_release:
    cpx #POOL_CAPACITY
    bcs pool_reject
    lda POOL_FLAGS,x
    beq pool_reject
    lda #0
    sta POOL_FLAGS,x
    clc
    rts
pool_reject:
    sec
    rts
pool_code_end:

A and flags change. Init and acquire change X; Y and decimal mode survive. Reserve private writable flags; they need not be in zero page. The helper uses no scratch pointer, heap or hardware registers. It is not re-entrant: one owner mutates the pool. Let interrupts record requests for that owner to handle later.

The component’s caller contract permits capacities 1–255. These maintained native examples verify 4 and 8. Their compact occupancy display represents only eight slots, so raising the fixture’s capacity beyond eight needs a different observer and new verification.

Include it in a project

The maintained top-level source gives the exact symbols and include order. caller.inc is optional machine startup and presentation; exercise.inc contains the verification client and the small pool example. In your game, include the allocator and optionally the ageing helper, then supply your own update and draw code.

The buildable caller and include order

demo.asm
POOL_CAPACITY=8
POOL_FLAGS=$81
POOL_LIFE=$90
mask_index=$70
mask_shift=$71
selected=$72
status_bits=$73
ptr=$74
saved_y=$76
saved_x=$77
occupancy_bits=$78
OUTPUT=$c000
preview=OUTPUT+1296
ready=OUTPUT+1304
BROKEN=0
!source "caller.inc"
!source "exercise.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/6502.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/age6502.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

The standalone PRG enters through the stock PAL BASIC/KERNAL startup, disables IRQ sources, owns the screen at $0400, character RAM at $2000 and colour RAM, and does not return to BASIC. The component itself changes no video, bank or interrupt registers.

The fixture reserves $70–$78 for diagnostics, flags at $81 with adjacent guards and lifetime bytes at $90. Its large output record and display are separate from the component’s small memory requirement. Do not copy those addresses blindly into another program.

Remove an object without skipping the next one

A slot stays in the same place until you release it. This optional pass visits each index once, skips free slots and decrements the lifetime of each live object. On zero it releases that slot and moves to the next index. Deleting slot 0 cannot move slot 1 beneath the loop’s feet.

Optional fixed-slot lifetime update

age6502.inc
; Optional caller policy, not part of the allocator.
; POOL_LIFE: capacity bytes; occupied slots must have life 1..255 at entry.
; One call is one simulation step. Flags stay in fixed slots; nothing moves.
; No spawning during this pass. Queue spawn requests and apply after the pass.
; A/X/flags change; Y survives. Nested JSR adds stack use on expiry.
pool_age:
    ldx #0
pool_age_loop:
    lda POOL_FLAGS,x
    beq pool_age_next
    dec POOL_LIFE,x
    bne pool_age_next
    jsr pool_release
pool_age_next:
    inx
    cpx #POOL_CAPACITY
    bne pool_age_loop
    rts
pool_age_code_end:

Give every occupied slot a lifetime of 1–255 before calling this pass. It runs once per simulation update; it contains no frame wait. A zero lifetime is outside its contract and would wrap on decrement. Choose whether a newly spawned object ages immediately or starts next step. The simplest policy here is to apply queued spawns after the pass. Spawning into a later slot during the loop would otherwise let that object update immediately while one reusing an earlier slot waits.

A released index is no longer a reference to its old object. If a target or owner stores it, clear that reference on removal. If references must survive slot reuse, generation-tagged handles are a useful further design; this component does not implement them.

Fill, refuse overflow, reuse and age the slots

exercise.inc, excerpt
preview_pool:
    jsr pool_init
    jsr pool_acquire
    stx preview
    jsr pool_acquire
    stx preview+1
    jsr pool_acquire
    stx preview+2
preview_fill:
    jsr pool_acquire
    bcc preview_fill
    lda #$ff
    sta preview+3
    ldx #1
!if BROKEN=0 {
    jsr pool_release
}
    jsr pool_acquire
    bcc preview_reused
    ldx #$ff
preview_reused:
    stx preview+4
    ldx #255
    jsr pool_release
    lda #0
    rol
    sta preview+5
; Initialise payload only after claiming slots. All are occupied here.
    ldx #0
preview_life:
    txa
    and #1
    clc
    adc #1
    sta POOL_LIFE,x
    inx
    cpx #POOL_CAPACITY
    bne preview_life
    jsr pool_age
    jsr occupancy
    sta preview+6
    jsr pool_age
    jsr occupancy
    sta preview+7
    rts

The screen’s eight hexadecimal fields are: the first three allocated indices; FF for the refused allocation; the reacquired index; carry from an invalid release; and the 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; the next pass gives 00.

Make it your own

The custom caller changes capacity from 8 to 4. The helper and ageing code remain the same size in this layout, while flags and lifetime arrays each need four fewer bytes. The first six fields stay the same. The first lifetime mask becomes 0A, with slots 1 and 3 still live, then 00. Both configurations run through the independent native checks.

Four slots, the same ownership rules

custom.asm
POOL_CAPACITY=4
POOL_FLAGS=$81
POOL_LIFE=$90
mask_index=$70
mask_shift=$71
selected=$72
status_bits=$73
ptr=$74
saved_y=$76
saved_x=$77
occupancy_bits=$78
OUTPUT=$c000
preview=OUTPUT+1296
ready=OUTPUT+1304
BROKEN=0
!source "caller.inc"
!source "exercise.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/6502.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/age6502.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

For bullets, replace lifetime-only payload with position and velocity arrays, but still initialise every field on acquisition. For enemies, a type or behaviour-state array can select an update. Keep allocation separate from drawing: a game-object slot is not a hardware sprite slot. An object can be offscreen, use several sprites or need none. The sprite lifecycle explains the separate drawing work.

Common mistake: forget to release

The labelled wrong caller omits pool_release before trying to reclaim slot 1. All flags remain occupied. Acquire correctly refuses the request, and the fifth field becomes FF. The program continues through its lifetime passes and completes, so this is a demonstrated full-pool result, not a crashed loader.

Deliberate fault: omit release before reacquiring

wrong.asm
POOL_CAPACITY=8
POOL_FLAGS=$81
POOL_LIFE=$90
mask_index=$70
mask_shift=$71
selected=$72
status_bits=$73
ptr=$74
saved_y=$76
saved_x=$77
occupancy_bits=$78
OUTPUT=$c000
preview=OUTPUT+1296
ready=OUTPUT+1304
BROKEN=1
!source "caller.inc"
!source "exercise.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/6502.inc"
!source "../../../../../cross-platform/patterns/framework/object-pool/age6502.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

The smallest fix is the normal release call in preview_pool. The repaired build is byte-for-byte identical to the working program and shows 01 again. If objects stop spawning in your game, inspect which paths release them: leaving the playfield, finishing an explosion and changing rooms all need an explicit ownership decision.

C64 shows FF in the fifth field because every slot is still occupied.
The allocator is doing its job: the omitted release leaves no free slot. The fifth field changes from 01 to FF.

What it costs, and when to go further

At the supplied address layout, the allocator is 47 code bytes plus one flag byte per slot. The optional ageing pass adds 19 code bytes plus one lifetime byte per slot. Direct helper calls use two stack bytes for the return address; ageing adds another two while calling release, in addition to your enclosing calls and interrupts. The complete C64 artifact is 686 bytes, including diagnostics, display and native packaging; that is not the component cost.

Init writes N flags. Acquisition reads through the first free slot, or all N when full; it writes only a successful claim. Release checks one in-range flag and writes at most one. Age scans N flags, decrements each live lifetime and calls release for each expiry. These are operation counts, not measured cycle timings. Moving arrays to another addressing mode or crossing pages changes the cost.

A free list can avoid allocation scans, at the cost of more bookkeeping and new corruption cases. A packed list can reduce iteration over empty space, but moving objects changes the reference/deletion problem. For a small bounded collection, a straightforward scan is often easier to budget and inspect. Maths for Games develops indexing and bit masks; The Craft helps decide what a full pool should mean to the player.

What ran

Asm198x 0.0.72 / Emu198x 0.44.2 ran working, four-slot custom, wrong and exact-repair programs on each machine’s own native startup path. A second caller tests all 256 occupancy masks for each capacity (upper bits are ignored for four slots), allocation and full results, invalid and double release, Y preservation, flag guards, initialization and every untouched lifetime payload byte. A separate set model checks the emitted packet. The visible client checks slot reuse and deletion through two lifetime passes.

Loaded code, native artifact identities, text pixels and capture hashes are checked. Deliberate changes to the retained evidence must fail the gate. Configuration records pin the tools and applicable firmware; no firmware is distributed. This establishes the named emulator workloads, not physical-hardware timings or a complete game.

Maintained source and native evidence.