Skip to content

Damage and Recovery on NES

Apply damage without byte underflow, give an entity time to recover and emit one fatal-hit event.

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.

NES shows 03 06 00 00 01 04 01 03 after sustained contact: three accepted hits and one death event.
The first field counts accepted hits; the second is the zero-based update where health reached zero. The fifth counts fatal-hit events.

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

6502.inc
; 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

exercise.inc, excerpt
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
.endif
    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
    rts

Include it in a game

Buildable caller and exact include order

demo.asm
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
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=$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,0

The standalone NTSC NROM cartridge disables NMI, DMC and audio and draws background text. Diagnostics use $70–$84 and $0200–$07D0; this conflicts with a usual $0200 OAM buffer. The fixture performs no OAM DMA. 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

custom.asm
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
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=$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,0

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

wrong.asm
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
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=$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,0

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.

NES shows 00 FF 03 02 00 0C 03 03: repeatedly refreshing recovery prevents every hit.
The caller keeps refreshing the window. Twelve requests are blocked while health remains three.

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.