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
; Private byte fields HEALTH/REMAINING/AMOUNT/APPLIED/KILLED.
; D0/D1/CCR change; D2-D7/A0-A6 survive. PERIOD0..255, init health3.
; One call/entity/simulation update; tick before aggregated damage.
; Saturate health0; one fatal event, pulses clear on the next call.
; No hardware/IRQ/wait/input ownership; single owner.
damage_init:
move.b #3,HEALTH
clr.b REMAINING
clr.b AMOUNT
clr.b APPLIED
clr.b KILLED
rts
damage_step:
clr.b APPLIED
clr.b KILLED
tst.b REMAINING
beq.s damage_after_tick
subq.b #1,REMAINING
damage_after_tick:
move.b HEALTH,d0
beq.s damage_done
tst.b REMAINING
bne.s damage_done
move.b AMOUNT,d1
beq.s damage_done
cmp.b d0,d1
bhs.s damage_fatal
sub.b d1,d0
move.b d0,HEALTH
move.b #PERIOD,REMAINING
move.b #1,APPLIED
rts
damage_fatal:
clr.b HEALTH
clr.b REMAINING
move.b #1,APPLIED
move.b #1,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.
D0/D1/CCR change; D2–D7/A0–A6 survive. The component fields are private bytes in the relocated Hunk. Read the explicit output fields, not carry. A direct call uses four 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:
bsr damage_init
clr.b accepted
clr.b deaths
clr.b blocked
move.b #255,first_death
moveq #0,d4
trace_loop:
move.b #1,AMOUNT
ifne BROKEN
move.b #PERIOD,REMAINING
endc
bsr checked_step
tst.b APPLIED
beq.s trace_no_apply
addq.b #1,accepted
trace_no_apply:
tst.b KILLED
beq.s trace_no_death
addq.b #1,deaths
cmpi.b #255,first_death
bne.s trace_no_death
move.b d4,first_death
trace_no_death:
tst.b HEALTH
beq.s trace_after_block
tst.b APPLIED
bne.s trace_after_block
addq.b #1,blocked
trace_after_block:
cmpi.l #4,d4
bne.s trace_record
move.b HEALTH,snapshot
trace_record:
bsr emit_state
addq.l #1,d4
cmpi.l #12,d4
bne trace_loop
move.b accepted,preview
move.b first_death,preview+1
move.b HEALTH,preview+2
move.b REMAINING,preview+3
move.b deaths,preview+4
move.b blocked,preview+5
move.b snapshot,preview+6
move.b #PERIOD,preview+7
rtsInclude it in a game
Buildable caller and exact include order
section code,code_c
PERIOD = 3
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
HEALTH: dc.b 0
REMAINING: dc.b 0
AMOUNT: dc.b 0
APPLIED: dc.b 0
KILLED: dc.b 0
accepted: dc.b 0
first_death: dc.b 0
deaths: dc.b 0
blocked: dc.b 0
snapshot: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1248,0
even
signature: dc.b "198XDAMAGEAMIGA",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0The A500 OCS/PAL caller cold-boots an OFS ADF with Kickstart1.3, then owns IRQ, DMA, Copper and a private bitplane. Code/data are a relocated Hunk; it does not return to the OS. 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
section code,code_c
PERIOD = 5
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
HEALTH: dc.b 0
REMAINING: dc.b 0
AMOUNT: dc.b 0
APPLIED: dc.b 0
KILLED: dc.b 0
accepted: dc.b 0
first_death: dc.b 0
deaths: dc.b 0
blocked: dc.b 0
snapshot: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1248,0
even
signature: dc.b "198XDAMAGEAMIGA",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0A 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
section code,code_c
PERIOD = 3
BROKEN = 1
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
HEALTH: dc.b 0
REMAINING: dc.b 0
AMOUNT: dc.b 0
APPLIED: dc.b 0
KILLED: dc.b 0
accepted: dc.b 0
first_death: dc.b 0
deaths: dc.b 0
blocked: dc.b 0
snapshot: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1248,0
even
signature: dc.b "198XDAMAGEAMIGA",0
even
copperlist:
dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
dc.w $180,$012,$182,$fff
copbpl:
dc.w $0e0,0,$0e2,0
dc.w $ffff,$fffe
plane: dcb.b 10240,0Remove 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 140 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.
The standalone takeover explicitly disarms all eight inherited sprites by writing SPRxCTL after disabling DMA. Stopping sprite DMA alone can leave manual sprite output armed; see Amiga Hardware Reference Manual, Sprite Hardware, “Manual Mode”, pp119–120. This setup belongs to the caller, not the damage component.
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.