Touch an enemy for several updates and you should not necessarily lose all your health at once. A short recovery window can turn a stream of overlapping collision results into deliberate, separated hits. This routine handles the arithmetic and timing; your game decides which contacts count and what the player sees or hears.
The example starts with health 3 and supplies damage 1 on twelve consecutive simulation updates. It accepts three hits, reaches zero at update index 6, and emits one fatal-hit event. A dead entity takes no further damage and does not emit that event repeatedly.

One service per entity, per simulation update
Reserve five private bytes: HEALTH, REMAINING, AMOUNT, APPLIED and KILLED. Include the component once. damage_init starts health at 3 and clears the other fields. Starting a session or spawning an entity is an appropriate time to initialise; resuming a paused game is not.
Before each damage_step, supply AMOUNT (0–255). Zero means no requested damage. Aggregate contacts first, then call once for this entity in this simulation update. Calling once per overlapping collision pair would also advance recovery once per pair. If adding amounts, use a wider accumulator and clamp deliberately; wrapping a byte sum would change the requested damage.
The step first decrements a positive recovery counter. It then accepts a nonzero amount only if health is positive and recovery is zero. A nonfatal hit subtracts the amount and reloads recovery from PERIOD. A fatal or oversized hit saturates health at zero, clears recovery and sets both event bytes. It never wraps zero health round to 255.
Reusable initialise and damage-step routines
; SPDX-License-Identifier: MIT
; Five caller-owned bytes HEALTH/REMAINING/AMOUNT/APPLIED/KILLED.
; PERIOD0..255 counts simulation updates; initial health3.
; step decrements a positive recovery counter before considering damage.
; One call per entity/update: aggregate contacts first. Amount0 means no hit.
; Accepted damage saturates health at0; fatal hit emits APPLIED=KILLED=1 once.
; A/flags change; X/Y and D flag survive. D MUST be clear (binary SBC).
; No I/O, frame wait, input reader or hidden state. Single owner.
damage_init:
lda #3
sta HEALTH
lda #0
sta REMAINING
sta AMOUNT
sta APPLIED
sta KILLED
rts
damage_step:
lda #0
sta APPLIED
sta KILLED
lda REMAINING
beq damage_after_tick
dec REMAINING
damage_after_tick:
lda HEALTH
beq damage_done
lda REMAINING
bne damage_done
lda AMOUNT
beq damage_done
cmp HEALTH
bcs damage_fatal
lda HEALTH
sec
sbc AMOUNT
sta HEALTH
lda #PERIOD
sta REMAINING
lda #1
sta APPLIED
rts
damage_fatal:
lda #0
sta HEALTH
sta REMAINING
lda #1
sta APPLIED
sta KILLED
damage_done:
rts
component_end:APPLIED pulses for any accepted hit, including a fatal one. KILLED pulses only for the positive-to-zero transition. Both clear at the next step; consume them before calling again. Trigger your sound, flash or game-over request from these events rather than repeatedly testing HEALTH == 0. The helper leaves AMOUNT alone, so explicitly supply zero on updates without damage.
A/flags change; X/Y and decimal mode survive. Damage subtraction requires decimal mode clear on C64; the native caller executes CLD. NES ignores decimal arithmetic, but retains the same clear-D calling convention. The helper uses no scratch pointer. A direct call uses two return-address stack bytes.
Decide what a recovery step means
The default PERIOD is 3. With tick-before-damage ordering, accepted hits occur at indices 0, 3 and 6: two intervening requests are blocked between accepted hits. This is three simulation steps between hits, not three complete blocked updates after a hit. No frame rate or wall clock is hidden in the helper.
PERIOD 0 disables recovery gating; values up to 255 fit the component’s byte contract. The maintained configurations execute periods 3 and 5. These tiny values make the diagnostic easy to inspect; they are not a recommendation for every game. Choose your real window against the simulation rate and the experience you want.
When paused, skipping this entity’s service freezes its recovery. If recovery should use wall-clock time while simulation is paused, that is a different timing policy; use the timing choices and elapsed-clock routines rather than pretending update counts measure seconds.
Sustained-contact client and event counters
preview_rules:
jsr damage_init
lda #0
sta accepted
sta deaths
sta blocked
sta trace_index
lda #255
sta first_death
trace_loop:
lda #1
sta AMOUNT
!if BROKEN {
lda #PERIOD
sta REMAINING
}
jsr checked_step
lda APPLIED
beq trace_no_apply
inc accepted
trace_no_apply:
lda KILLED
beq trace_no_death
inc deaths
lda first_death
cmp #255
bne trace_no_death
lda trace_index
sta first_death
trace_no_death:
lda HEALTH
beq trace_after_block
lda APPLIED
bne trace_after_block
inc blocked
trace_after_block:
lda trace_index
cmp #4
bne trace_record
lda HEALTH
sta snapshot
trace_record:
jsr emit_state
inc trace_index
lda trace_index
cmp #12
bne trace_loop
lda accepted
sta preview
lda first_death
sta preview+1
lda HEALTH
sta preview+2
lda REMAINING
sta preview+3
lda deaths
sta preview+4
lda blocked
sta preview+5
lda snapshot
sta preview+6
lda #PERIOD
sta preview+7
rtsInclude it in a game
Buildable caller and exact include order
HEALTH=$80
REMAINING=$81
AMOUNT=$82
APPLIED=$83
KILLED=$84
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
first_death=$79
deaths=$7a
blocked=$7b
snapshot=$7c
OUTPUT=$c000
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=3
BROKEN=0
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "cases.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 PAL PRG enters through stock BASIC/KERNAL, then owns the screen/font/colour RAM and disables IRQ sources. Diagnostics use $70–$84 and a buffer at $C000. It does not return to BASIC. The reusable component owns none of that hardware. Move its fields into your own memory layout and keep one owner for mutations. Interrupts can record requests; they must not change health or recovery halfway through this service.
The eight hexadecimal summary fields are accepted hits, first fatal update (FF if none), final health, recovery remaining, fatal events, blocked requests while still alive, health at index 4, period. The working row is 03 06 00 00 01 04 01 03. Counts describe this twelve-update workload, not a frame-time benchmark.
Make it your own
custom.asm changes PERIOD from 3 to 5, keeping health and requested damage unchanged. Death moves to index 0A (10), and health at index 4 is 02 rather than 01. Component code and state sizes are unchanged in these layouts; only a constant changes.
Extend the recovery window to five updates
HEALTH=$80
REMAINING=$81
AMOUNT=$82
APPLIED=$83
KILLED=$84
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
first_death=$79
deaths=$7a
blocked=$7b
snapshot=$7c
OUTPUT=$c000
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=5
BROKEN=0
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "cases.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:A different initial health, strongest-hit policy, damage types or per-source recovery all need their own caller rules and cases. Healing, armour, stacked damage, revival and object removal are not silently provided by this component. Object slots handle ownership after removal, while game states distinguish fresh-session reset from resume.
Common mistake: refresh recovery on every contact
The labelled wrong caller writes PERIOD into REMAINING before every request. The service decrements it to 2, sees that recovery is still active, and rejects damage. Sustained contact keeps repeating that mistake. Health stays 03, accepted hits stay 00, and the fatal index is FF. The whole trace completes; this is not a stalled loader.
Deliberate fault: re-arm the timer before every contact
HEALTH=$80
REMAINING=$81
AMOUNT=$82
APPLIED=$83
KILLED=$84
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
trace_index=$77
accepted=$78
first_death=$79
deaths=$7a
blocked=$7b
snapshot=$7c
OUTPUT=$c000
preview=OUTPUT+1480
ready=OUTPUT+1488
PERIOD=3
BROKEN=1
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "cases.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:Remove that write. The component already reloads recovery when it accepts a nonfatal hit. The repaired executable matches the working executable exactly; Spectrum tape filenames can differ while the loaded CODE is identical.

Cost and verification
This layout uses 72 code bytes and five state bytes. Each service takes one bounded branch path, without an object scan, input read, display transfer or wait. N entities require N services plus your collision work. These are storage/work descriptions, not measured physical-hardware cycles. A compare-before-subtract and saturation also connect directly to Maths for Games; The Craft helps decide what recovery should feel like.
Four native runs on this machine cover normal, changed period, wrong caller and exact repair. Asm198x 0.0.72 / Emu198x 0.44.2 are pinned, with Build198x 0.2.9 for Amiga media. A second client checks initialisation from dirty fields, all 256 health bytes at damage 1, 36 health/damage boundary pairs, six recovery boundaries and every state/event result of the twelve-update trace. This is selected finite-domain coverage, not all possible health/timer/amount combinations.
An independent saturating-integer model checks the 1,248-byte packet. Native startup, completion, preserved-register sentinels, source/tool/firmware identities, loaded code, capture hashes, text pixels and repair are checked too. Deliberately altered evidence must fail. No firmware is distributed. These are named emulator logic workloads, not another hardware collision/input test or a complete game’s acceptance test.
Maintained source and native evidence.
This direct-address damage listing has one fixed state block, often suitable for the player. Independent entities need distinct blocks and bindings, or an explicit selected-state wrapper. An indexed/pointer version is a further implementation with its own costs. For request timing independent of contact recovery, see action cooldowns.