Skip to content

A Saturating 16-bit Score on Spectrum

A coin worth 300 points should still be worth 300 when the low byte fills up.

A coin worth 300 points should still be worth 300 when the low byte fills up. This binary score uses two bytes and keeps the carry between them. It also makes the top of the range deliberate: the score stops at its cap instead of wrapping back to zero.

Spectrum shows the working result FF FF FF FF 01 00 00 00.
The completed native caller shows hexadecimal state bytes. The field order below explains the result; the full verification packet retains every call.

Include, initialise and call

Bind SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED as 5 contiguous private bytes in that order. Include the helper once. Call score_counter_init at the deliberate reset boundary: it clears all named bytes, including inputs. Set your starting values afterwards. Call score_counter_step when this service is needed.

Initialise at the start of a new session. Set both award bytes before each call; an award of 300 is low byte 44, high byte 1. Keep the score in binary for arithmetic and format a separate display copy. Call once for each scoring event, not once per overlapping frame unless that is the scoring rule.

SCORE_LO and SCORE_HI hold an unsigned little-endian score; ADD_LO and ADD_HI hold an unsigned award. The current score must be 0 through SCORE_CAP. Step adds the award with a wide intermediate, clamps to SCORE_CAP and sets CLIPPED to 1 only when the sum exceeds the cap. An exact hit on the cap leaves CLIPPED zero. The award bytes survive.

Reusable initialisation and service

component.inc
; SPDX-License-Identifier: MIT
; A Saturating 16-bit Score. Bind these contiguous private bytes: SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED.
; init clears the whole block; step uses current inputs. No I/O, waits or IRQ owner.
; AF/BC/DE/HL may change; IX/IY survive.
score_counter_init:
    xor a
    ld (SCORE_LO),a
    ld (SCORE_HI),a
    ld (ADD_LO),a
    ld (ADD_HI),a
    ld (CLIPPED),a
    ret
score_counter_step:
    xor a
    ld (CLIPPED),a
    ld hl,(SCORE_LO)
    ld de,(ADD_LO)
    add hl,de
    jr c,score_cap
    ld de,SCORE_CAP
    push hl
    or a
    sbc hl,de
    pop hl
    jr c,score_store
    jr z,score_store
score_cap:
    ld hl,SCORE_CAP
    ld a,1
    ld (CLIPPED),a
score_store:
    ld (SCORE_LO),hl
    ret
component_end:

State and ownership

The two SCORE bytes persist; the two ADD bytes are inputs; CLIPPED is a replaced overflow/cap pulse. Byte order is explicit on every CPU, including the big-endian 68000.

AF/BC/DE/HL may change; IX/IY survive. A direct CALL uses two return-address stack bytes. The cap comparison saves HL with PUSH/POP, adding two temporary bytes: allow four bytes for this helper call, excluding its caller and interrupts.

This implementation has fixed symbol bindings and one mutation owner. Independent users need distinct state blocks and scoped/renamed bindings; including another copy with the same global labels is not sufficient. Sequential queries may reuse a scratch block only after the previous result has been consumed. Do not let an interrupt and the main loop mutate the same block without a separately designed ownership policy.

The standalone tape program starts at $8000, disables interrupts and owns bitmap/attribute RAM. It does not return to BASIC. The helper itself never disables interrupts or touches the display.

A small caller you can adapt

The standalone program includes the helper and a separate client. Its read-only table exercises 96 valid inputs, then this client initialises once and retains state between calls. Replace the supplied input changes with your own game values; the display and packet machinery are diagnostics rather than dependencies of the component.

Buildable caller, bindings and include order

demo.asm
    org $8000
CUSTOM equ 0
BROKEN equ 0
SCORE_CAP equ 65535
QUEUE_CAP equ 4
REPEAT_DELAY equ 4
REPEAT_RATE equ 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
STATE:
SCORE_LO: defb 0
SCORE_HI: defb 0
ADD_LO: defb 0
ADD_HI: defb 0
CLIPPED: defb 0
input_ptr: defw 0
output_ptr: defw 0
cases_left: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1300,0
    end start

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
    if CUSTOM
    ld a,250
    ld (SCORE_LO),a
    ld a,0
    ld (SCORE_HI),a
    ld a,44
    ld (ADD_LO),a
    ld a,1
    ld (ADD_HI),a
    if BROKEN
    ld a,0
    ld (ADD_HI),a
    call checked_step
    else
    call checked_step
    endif
    call emit_state
    ld a,255
    ld (ADD_LO),a
    ld a,255
    ld (ADD_HI),a
    if BROKEN
    ld a,0
    ld (ADD_HI),a
    call checked_step
    else
    call checked_step
    endif
    call emit_state
    else
    ld a,250
    ld (SCORE_LO),a
    ld a,0
    ld (SCORE_HI),a
    ld a,44
    ld (ADD_LO),a
    ld a,1
    ld (ADD_HI),a
    if BROKEN
    ld a,0
    ld (ADD_HI),a
    call checked_step
    else
    call checked_step
    endif
    call emit_state
    ld a,255
    ld (ADD_LO),a
    ld a,255
    ld (ADD_HI),a
    if BROKEN
    ld a,0
    ld (ADD_HI),a
    call checked_step
    else
    call checked_step
    endif
    call emit_state
    endif
    ret

The eight display fields follow SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED; unused display fields are zero. Each field is a hexadecimal byte. The working row is FF FF FF FF 01 00 00 00. The output is a completed diagnostic state, not a recording of movement, input or sound hardware.

Make it your own

The custom build sets SCORE_CAP to 999 instead of 65,535. It uses the same two-byte representation and enforces the smaller cap. The native case table keeps every starting score inside the chosen cap.

Maintained custom configuration or caller

custom.asm
    org $8000
CUSTOM equ 1
BROKEN equ 0
SCORE_CAP equ 999
QUEUE_CAP equ 2
REPEAT_DELAY equ 6
REPEAT_RATE equ 3
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
STATE:
SCORE_LO: defb 0
SCORE_HI: defb 0
ADD_LO: defb 0
ADD_HI: defb 0
CLIPPED: defb 0
input_ptr: defw 0
output_ptr: defw 0
cases_left: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1300,0
    end start

The observed custom row is E7 03 FF FF 01 00 00 00. The retained records qualify these named configurations; a new input domain or larger layout needs its own bounds and fresh execution checks.

Common mistake and repair

The wrong client clears ADD_HI before awarding points. A 300-point award becomes 44; a 65,535-point award becomes 255. Supply both bytes together. Merely changing a display width cannot restore points that the arithmetic never received.

Deliberately wrong client selection

wrong.asm
    org $8000
CUSTOM equ 0
BROKEN equ 1
SCORE_CAP equ 65535
QUEUE_CAP equ 4
REPEAT_DELAY equ 4
REPEAT_RATE equ 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "../../physics/rectangle-overlap/display.inc"
code_end:
STATE:
SCORE_LO: defb 0
SCORE_HI: defb 0
ADD_LO: defb 0
ADD_HI: defb 0
CLIPPED: defb 0
input_ptr: defw 0
output_ptr: defw 0
cases_left: defb 0
ready: defb 0
preview: defs 8,0
OUTPUT: defs 1300,0
    end start
Spectrum shows the deliberate fault result 25 02 FF 00 00 00 00 00.
The wrong client completes and records 25 02 FF 00 00 00 00 00. Compare its state with the working row and the explained failure.

The wrong branch is in the displayed client above, selected by BROKEN. repaired.asm selects the original client again. Its executable matches demo exactly; Spectrum tape filenames can differ while loaded CODE is identical. The bad caller is a labelled experiment, not an alternative recommended implementation.

Cost and verification

The measured helper occupies 55 code bytes plus 5 named state bytes in this layout. Return addresses, alignment, tables and display diagnostics are additional. The sizes cover init and step together, not the whole standalone program.

The addition and cap comparison use bounded paths. On 6502 the carry joins two byte additions; the Z80 uses a word addition and carry; the 68000 forms a long intermediate. Rendering and decimal conversion are separate costs.

A BCD score can simplify decimal display on some CPUs, but the NES CPU does not implement decimal arithmetic. Wider binary counters or milestone rewards need their own carry, cap and event policies. Saturation deliberately loses later awards; games that need lifetime totals should choose a wider representation.

Four native runs use Spectrum 48K with stock ROM tape loading and USR entry; Asm198x 0.0.72 and Emu198x 0.44.2. Every run checks 96 table results, dirty initialisation and every state in a separate sequential trace against an independent integer model. This is boundary/varied-input qualification, not exhaustive coverage of every byte combination. Source/tool/firmware hashes, native startup and completion, preserved-register sentinels, immutable loaded code, actual helper extent, capture bytes and pixels, and exact repair are checked. Deliberately changed observations must fail. Firmware is hashed, never distributed.

Related reusable technique · Maths for Games · The Craft.

Source and native evidence.