The picture number in a map does not automatically tell you whether a tile is solid, harmful or climbable. A small property table lets artwork and game behaviour change independently, with a bounds check before looking anything up.

Include, initialise and call
Reserve ATTRS, MATCH, VALID, TILE_ID, MASK as 5 contiguous private bytes in that order. Include the helper once. Call tile_properties_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call tile_properties_step for each intended service.
Query the map for a tile ID, then offer that ID and the property mask. Check VALID if a missing ID should be treated as a content error rather than empty space. The sample assigns independent bit values 1, 2, 4 and 8; your game gives those bits their meanings.
TILE_ID selects one entry of the caller-owned property_table, whose qualified size is 16. MASK selects properties of interest. Step replaces ATTRS/MATCH/VALID: an in-range ID returns its attribute byte and VALID 1; MATCH is 1 when any selected bit is present. MASK zero matches nothing. An invalid ID returns all three outputs zero without reading outside the table. Inputs survive.
Reusable initialisation and service
; SPDX-License-Identifier: MIT
; Look Up Tile Properties. Contiguous private bytes: ATTRS, MATCH, VALID, TILE_ID, MASK.
; init clears all fields; set inputs afterwards. No I/O, waits or IRQ ownership.
; A/X/flags change; Y and decimal mode survive. D=0 for binary arithmetic.
tile_properties_init:
lda #0
sta ATTRS
sta MATCH
sta VALID
sta TILE_ID
sta MASK
rts
tile_properties_step:
lda #0
sta ATTRS
sta MATCH
sta VALID
ldx TILE_ID
cpx #PROPERTY_COUNT
bcs property_done
lda property_table,x
sta ATTRS
and MASK
beq property_valid
inc MATCH
property_valid:
inc VALID
property_done:
rts
component_end:Caller-owned immutable lookup/encoded data
data_start:
property_table:
.byte 0,1,1,2,4,1,8,0,3,6,12,9,15,2,4,8
data_end:State, registers and ownership
Five bytes of state plus a separate immutable sixteen-byte property table. A valid tile with attribute zero differs from an invalid tile because VALID is separate. This is an any-bit test; the quest-flags helper’s all-bits question has a different meaning.
A/X/flags may change; Y and decimal mode survive. Clear decimal mode before binary arithmetic; the caller uses CLD. A direct call needs two return-address stack bytes.
The implementation binds fixed symbols and has one mutation owner. Independent users need separate state blocks and scoped/renamed labels, including routine labels; sharing the same bindings means sharing the state. Do not let an interrupt and the main loop mutate the same block without an explicit ownership policy.
The standalone cartridge owns its background/font and test RAM layout. Its bounded packet, preview and completion marker fit below the end of the NES’s 2 KiB internal RAM. The helper itself owns no PPU, DMA or audio registers.
A caller you can adapt
The buildable program supplies bindings and include order. Its separate client retains state between calls. checked_step calls the public tile_properties_step entry and checks register sentinels; emit_state records diagnostics. Your game can call the public entry directly, remove the diagnostics and consume the documented outputs.
Bindings, buildable caller and include order
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ATTRS=$80
MATCH=$81
VALID=$82
TILE_ID=$83
MASK=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=0
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "data.inc"
.include "cases.inc"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0A separate client retaining state between calls
preview_rules:
.if CUSTOM
lda #1
sta TILE_ID
lda #8
sta MASK
.if BROKEN
lda TILE_ID
sta ATTRS
and MASK
beq bad_property_zero_0_custom
lda #1
bad_property_zero_0_custom:
sta MATCH
lda #1
sta VALID
.else
jsr checked_step
.endif
jsr emit_state
lda #6
sta TILE_ID
.if BROKEN
lda TILE_ID
sta ATTRS
and MASK
beq bad_property_zero_1_custom
lda #1
bad_property_zero_1_custom:
sta MATCH
lda #1
sta VALID
.else
jsr checked_step
.endif
jsr emit_state
.else
lda #1
sta TILE_ID
lda #1
sta MASK
.if BROKEN
lda TILE_ID
sta ATTRS
and MASK
beq bad_property_zero_0
lda #1
bad_property_zero_0:
sta MATCH
lda #1
sta VALID
.else
jsr checked_step
.endif
jsr emit_state
lda #6
sta TILE_ID
.if BROKEN
lda TILE_ID
sta ATTRS
and MASK
beq bad_property_zero_1
lda #1
bad_property_zero_1:
sta MATCH
lda #1
sta VALID
.else
jsr checked_step
.endif
jsr emit_state
.endif
rtsThe display fields are ATTRS, MATCH, VALID, TILE_ID, MASK; remaining display fields are zero. Each is one hexadecimal byte. The normal row is 08 00 01 06 01 00 00 00. This is a completed state observation, not a recording of an actual character jumping, a camera scrolling or an asset being displayed.
Make it your own
The normal client asks about mask 1; the custom caller asks about mask 8. Tile ID six maps to attribute eight, so the latter matches and the former does not. No tile image or map layout has to change.
Maintained custom configuration or caller
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ATTRS=$80
MATCH=$81
VALID=$82
TILE_ID=$83
MASK=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=1
BROKEN=0
COYOTE_STEPS=2
BUFFER_STEPS=2
TEXT_CAP=4
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=49
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "data.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 custom row is 08 01 01 06 08 00 00 00. These records qualify the named builds and inputs; another layout, format or input domain needs explicit bounds and fresh execution evidence.
Common mistake and repair
The wrong client treats the numeric tile ID as the attribute byte. Tile six then appears to carry bits two and four instead of its authored property bit eight. Look up the property data rather than encoding behaviour accidentally into artwork numbering.
Select the deliberately wrong client
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ATTRS=$80
MATCH=$81
VALID=$82
TILE_ID=$83
MASK=$84
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=1
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
.include "caller.inc"
.include "exercise.inc"
component_start:
.include "component.inc"
.include "data.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 BROKEN branch is visible in the client. repaired.asm restores the normal selection; its executable matches demo exactly. Spectrum tape filenames may differ while loaded CODE remains identical. The fault is a labelled experiment, not a suggested alternative.
Cost, limits and verification
The measured layout has 41 code bytes, 16 immutable data bytes and 5 private state bytes. Alignment, stack usage, diagnostic tables and display machinery are additional. The extents come from the loaded program’s symbols, not from counting source lines.
One bounds check, one byte lookup and a mask test. The immutable table costs sixteen bytes; no per-tile properties need to be copied into every map cell. The table transfer to an actual display is unrelated work.
A larger catalogue needs an updated count, storage and index width as appropriate. Sparse IDs may need a different lookup structure. Animated frames can share the same properties, or the animation owner can choose when behaviour changes; neither should happen accidentally because the image index changed.
Four native runs use NTSC mapper-0 NES cartridge, CHR-ROM diagnostic font, NMI/DMC disabled; Asm198x 0.0.72 and Emu198x 0.44.2. Each checks 64 boundary/varied/error cases, dirty initialisation and every state of a separate sequential client against an independent integer model. This is not exhaustive coverage of every possible byte combination. Source/tool/firmware hashes, immutable loaded code/data, native startup/completion, preserved-register sentinels, helper/data sizes, screenshot bytes/pixels and exact repair are checked. Deliberately altered observations must fail. Firmware is hashed, never distributed.