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.

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
; SPDX-License-Identifier: MIT
; Four private bytes REMAINING/REQUEST/AVAILABLE/GRANTED. PERIOD0..255.
; tick once/update regardless of input. try may commit a request only if ready
; and a resource is available. Nonzero REQUEST/AVAILABLE mean true.
; GRANTED is a pulse cleared by tick/try; consume immediately. Caller owns
; truthful resource reservation and immediate effect creation under one owner.
; A/flags change; X/Y and decimal mode survive. No binary-arithmetic precondition.
; No input, ammo, allocation, waits or IRQ mutation inside this component.
cooldown_init:
lda #0
sta REMAINING
sta REQUEST
sta AVAILABLE
sta GRANTED
rts
cooldown_tick:
lda #0
sta GRANTED
lda REMAINING
beq cooldown_tick_done
dec REMAINING
cooldown_tick_done:
rts
cooldown_try:
lda #0
sta GRANTED
lda REQUEST
beq cooldown_try_done
lda AVAILABLE
beq cooldown_try_done
lda REMAINING
bne cooldown_try_done
lda #1
sta GRANTED
lda #PERIOD
sta REMAINING
cooldown_try_done:
rts
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.
A/flags change; X/Y and decimal mode survive. Unlike the damage subtraction routine, this helper has no decimal-clear precondition: it only tests and decrements the counter. 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
preview_rules:
jsr cooldown_init
lda #0
sta accepted
sta blocked
sta trace_index
trace_loop:
ldx trace_index
lda trace_requests,x
sta REQUEST
lda #1
sta AVAILABLE
.if BROKEN
lda REQUEST
beq trace_no_tick
.endif
jsr checked_tick
trace_no_tick:
jsr checked_try
lda GRANTED
beq trace_no_grant
inc accepted
trace_no_grant:
lda REQUEST
beq trace_after_block
lda GRANTED
bne trace_after_block
inc blocked
trace_after_block:
lda trace_index
cmp #3
bne trace_not_three
lda GRANTED
sta at_three
trace_not_three:
lda trace_index
cmp #6
bne trace_record
lda GRANTED
sta at_six
trace_record:
lda REMAINING
jsr emit
lda REQUEST
jsr emit
lda AVAILABLE
jsr emit
lda GRANTED
jsr emit
inc trace_index
lda trace_index
cmp #12
bne trace_loop
lda accepted
sta preview
lda REMAINING
sta preview+1
lda at_three
sta preview+2
lda at_six
sta preview+3
lda blocked
sta preview+4
lda availability_guard
sta preview+5
lda #PERIOD
sta preview+6
lda GRANTED
sta preview+7
rtsSkip 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
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
REMAINING=$80
REQUEST=$81
AVAILABLE=$82
GRANTED=$83
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
blocked=$79
at_three=$7a
at_six=$7b
availability_guard=$7c
case_kind=$7d
OUTPUT=$0200
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=3
BROKEN=0
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "cases.inc"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0The NTSC NROM cartridge disables NMI, DMC and audio and draws background text. Its diagnostics occupy $70–$83 and $0200–$07D0. This fixture conflicts with a usual $0200 OAM buffer and performs no OAM DMA. 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
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
REMAINING=$80
REQUEST=$81
AVAILABLE=$82
GRANTED=$83
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
blocked=$79
at_three=$7a
at_six=$7b
availability_guard=$7c
case_kind=$7d
OUTPUT=$0200
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=5
BROKEN=0
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "cases.inc"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0The 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
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
REMAINING=$80
REQUEST=$81
AVAILABLE=$82
GRANTED=$83
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
blocked=$79
at_three=$7a
at_six=$7b
availability_guard=$7c
case_kind=$7d
OUTPUT=$0200
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=3
BROKEN=1
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "cases.inc"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0Move 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.

Cost and verification
This layout uses 47 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.