Skip to content

Action Cooldowns on Spectrum

Keep an action timer advancing between requests, and spend its cooldown only when a requested resource is available.

Fire, release the button, then fire again. The cooldown should keep counting while your finger is off the button. This component separates the passing of simulation time from the request to act, so both tapped and held inputs can use the same small timer.

The working caller requests an action every third update. A three-step period grants four requests in twelve updates. The deliberately wrong caller only ticks while a request is present: the counter freezes between taps, and just two requests are granted.

Spectrum shows 04 01 01 01 00 01 03 00: four granted requests with an independently advancing timer.
The first field counts grants. The next two marked request points both succeed; the timer continued advancing between them.

Initialise, tick, then try

Reserve four private bytes: REMAINING, REQUEST, AVAILABLE and GRANTED. Include the helper once and call cooldown_init at session start. It clears all four; the first action can be ready immediately. To start with a delay, set REMAINING deliberately after initialisation.

Call cooldown_tick once per simulation update, regardless of input. It clears the previous grant pulse and decrements a positive counter, leaving zero at zero. Then supply REQUEST and AVAILABLE and call cooldown_try. Nonzero input bytes mean true. A requested action is granted only when the timer is zero and the required resource is available. On a grant, the component sets GRANTED to 1 and reloads REMAINING from PERIOD. Rejection spends no cooldown.

Reusable initialise, tick and try routines

component.inc
; SPDX-License-Identifier: MIT
; REMAINING/REQUEST/AVAILABLE/GRANTED: four private bytes, PERIOD0..255.
; AF changes; BC/DE/HL/IX/IY survive. tick once/update even without input;
; try commits a request only if ready and available (nonzero inputs true).
; GRANTED clears on tick/try. Caller owns truthful resource availability,
; immediate effect creation, input policy and one mutation owner. No waits/I/O.
cooldown_init:
    xor a
    ld (REMAINING),a
    ld (REQUEST),a
    ld (AVAILABLE),a
    ld (GRANTED),a
    ret
cooldown_tick:
    xor a
    ld (GRANTED),a
    ld a,(REMAINING)
    or a
    ret z
    dec a
    ld (REMAINING),a
    ret
cooldown_try:
    xor a
    ld (GRANTED),a
    ld a,(REQUEST)
    or a
    ret z
    ld a,(AVAILABLE)
    or a
    ret z
    ld a,(REMAINING)
    or a
    ret nz
    ld a,1
    ld (GRANTED),a
    ld a,PERIOD
    ld (REMAINING),a
    ret
component_end:

GRANTED is a committed permission, not proof that a projectile already exists. The caller owns ammo, object-slot reservation and effect creation. Under one update owner, establish that the resource really can be used, call try, then immediately create the effect and consume the resource if granted. Recompute availability for each subsequent action. If another writer can take it between the check and creation, use an explicit reservation/commit design rather than trusting this byte as a lock.

A caller with unlimited resources can supply AVAILABLE = 1. A full object pool or empty magazine supplies zero, preserving readiness for later. This helper never allocates objects, decrements ammo, refunds a failed spawn or reads a button.

Only AF changes; BC/DE/HL/IX/IY survive. A direct call uses two return-address stack bytes.

Choose what counts as a request

Supply a newly pressed bit for one action per press, or a held bit for repeat while held. The timer does not choose between those controls. Input edges explain the difference; object slots make a useful resource constraint concrete.

Consume GRANTED immediately: both tick and try clear it, so another call replaces the pulse. With a positive period, a second try before another tick sees the newly active cooldown. PERIOD 0 disables rate gating and can allow several tries in one update; it does not bypass request/availability checks or limit your caller to one attempt.

The default 3 means grants at indices 0, 3, 6 and 9 with tick-before-try ordering. The custom period 5 grants at 0 and 6 for this particular every-third-update input trace. A ready timer does not queue missed input or manufacture a request when no action is being requested.

Service time independently, then offer the current request

exercise.inc, excerpt
preview_rules:
    call cooldown_init
    xor a
    ld (accepted),a
    ld (blocked),a
    ld (trace_index),a
trace_loop:
    ld a,(trace_index)
    ld l,a
    ld h,0
    ld de,trace_requests
    add hl,de
    ld a,(hl)
    ld (REQUEST),a
    ld a,1
    ld (AVAILABLE),a
    if BROKEN
    ld a,(REQUEST)
    or a
    jr z,trace_no_tick
    endif
    call checked_tick
trace_no_tick:
    call checked_try
    ld a,(GRANTED)
    or a
    jr z,trace_no_grant
    ld a,(accepted)
    inc a
    ld (accepted),a
trace_no_grant:
    ld a,(REQUEST)
    or a
    jr z,trace_after_block
    ld a,(GRANTED)
    or a
    jr nz,trace_after_block
    ld a,(blocked)
    inc a
    ld (blocked),a
trace_after_block:
    ld a,(trace_index)
    cp 3
    jr nz,trace_not_three
    ld a,(GRANTED)
    ld (at_three),a
trace_not_three:
    ld a,(trace_index)
    cp 6
    jr nz,trace_record
    ld a,(GRANTED)
    ld (at_six),a
trace_record:
    ld a,(REMAINING)
    call emit
    ld a,(REQUEST)
    call emit
    ld a,(AVAILABLE)
    call emit
    ld a,(GRANTED)
    call emit
    ld a,(trace_index)
    inc a
    ld (trace_index),a
    cp 12
    jp nz,trace_loop
    ld a,(accepted)
    ld (preview),a
    ld a,(REMAINING)
    ld (preview+1),a
    ld a,(at_three)
    ld (preview+2),a
    ld a,(at_six)
    ld (preview+3),a
    ld a,(blocked)
    ld (preview+4),a
    ld a,(availability_guard)
    ld (preview+5),a
    ld a,PERIOD
    ld (preview+6),a
    ld a,(GRANTED)
    ld (preview+7),a
    ret

Skip timer service during pause if cooldown should freeze with simulation. If it should continue in real time, choose an elapsed-clock policy explicitly. These counts are simulation updates, not seconds or an assumed display refresh rate. Game states and restart keep a fresh-session reset separate from resume.

Include it in your project

Buildable standalone caller and include order

demo.asm
    org $8000
PERIOD equ 3
BROKEN equ 0
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
REMAINING: defb 0
REQUEST: defb 0
AVAILABLE: defb 0
GRANTED: defb 0
accepted: defb 0
blocked: defb 0
at_three: defb 0
at_six: defb 0
availability_guard: defb 0
case_kind: defb 0
trace_index: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1335,0
    end start

The 48K PAL caller loads its CODE through the ROM tape loader and BASIC USR at $8000, then owns the bitmap/attributes/border and disables interrupts. Mutable fields and packet follow its code; it does not restore BASIC. Its large input table is test overhead, not part of the timer. The reusable helper owns none of those resources. It has one fixed state block. For independent weapons, give each its own block and explicit bindings, or use a wrapper that selects/copies one block before service and writes it back afterwards. A pointer/indexed version can avoid that copy but is a separate implementation with its own register and timing costs. Sharing one block means sharing one cooldown.

The eight hexadecimal fields are grants, final remaining count, grant at index 3, grant at index 6, blocked requests, observed availability guard, period, final grant pulse. The working row is 04 01 01 01 00 01 03 00. The availability guard comes from an additional ready-but-unavailable request that must leave both grant and timer zero.

Make it your own

custom.asm changes PERIOD from 3 to 5. This trace then grants two actions and ends ready: 02 00 00 01 02 01 05 00. Helper code and state sizes stay the same in these layouts. The component’s byte period can represent 0–255, but the maintained executions qualify 3 and 5.

Use a five-update action period

custom.asm
    org $8000
PERIOD equ 5
BROKEN equ 0
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
REMAINING: defb 0
REQUEST: defb 0
AVAILABLE: defb 0
GRANTED: defb 0
accepted: defb 0
blocked: defb 0
at_three: defb 0
at_six: defb 0
availability_guard: defb 0
case_kind: defb 0
trace_index: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1335,0
    end start

The verification fixture packs some results into one byte with a grant bit in bit 7; it asserts that the counter fits below 128 before packing. That is a test-packet limit, not a limit on the reusable counter. Qualifying larger periods needs an unpacked packet and refreshed cases/records; simply changing the helper constant does not qualify an untested configuration.

Common mistake: only tick while Fire is held

The labelled wrong caller guards cooldown_tick with REQUEST. Time stops advancing on released updates. Its row becomes 02 03 00 00 02 01 03 00: only two grants, and both earlier request points are blocked. The program completes the entire trace, so the reduced count is an observed scheduling fault.

Deliberate fault: service the timer only when input requests an action

wrong.asm
    org $8000
PERIOD equ 3
BROKEN equ 1
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
REMAINING: defb 0
REQUEST: defb 0
AVAILABLE: defb 0
GRANTED: defb 0
accepted: defb 0
blocked: defb 0
at_three: defb 0
at_six: defb 0
availability_guard: defb 0
case_kind: defb 0
trace_index: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1335,0
    end start

Move tick outside that request condition, leaving try responsible for deciding whether to grant. The repaired executable matches the working executable exactly; Spectrum tape header filenames may differ while loaded CODE matches.

Spectrum shows 02 03 00 00 02 01 03 00 because the timer freezes between taps.
The component is ready less often because the caller only advances it on requested actions. Two requests are lost to the frozen counter.

Cost and verification

This layout uses 58 code bytes plus four state bytes. Tick and try each take a bounded path, with no input scan, object search, wait or display transfer. Independent state blocks cost four bytes each, plus your selection/binding wrapper. These are storage/work counts, not whole-game or physical-hardware timings. Maths for Games connects countdown boundaries and saturation; The Craft explores what repeated actions feel like to the player.

Four native executions cover normal, changed period, wrong scheduling and exact repair, with Asm198x 0.0.72 / Emu198x 0.44.2 and Build198x 0.2.9 for Amiga media. Native input tables cover all 256 remaining bytes, all 256 request bytes with and without availability, and all 256 availability bytes. An independent integer model checks the 1,335-byte packet, dirty initialisation, the availability guard and every result of the twelve-update tap/release trace.

Source/tool/configuration identities, native startup, positive completion, preserved registers, loaded code, capture hashes, pixels and exact repair are checked. The larger Spectrum test table needs a bounded 30-span ROM-entry allowance; observed loading completes in span 22, with the full loaded CODE checked before execution. Deliberately altered observations must fail the gate. Firmware is hashed rather than distributed. This is logical timer/request verification, not another input-reader or projectile-spawn test, nor qualification of every possible input combination.

Maintained source and native evidence.