Sixteen possible tile values need four bits each. Pair two cells in one byte value and the map data gets smaller, at the cost of extra work whenever a cell is read or changed.

Include and initialise
DIM B(8). Reserve public V/O/C/W/N, the eight elements, private I/K/H/L and lines 3000–3240. Private I must not be the caller’s outer FOR variable. Combine routines.bas and the caller through build.py and prevent fall-through with END, STOP or a jump. C64/BBC use GOSUB; Spectrum uses GO SUB.
Call 3000 at a reset boundary. It clears every public field and all eight elements. Load existing packed values afterwards. Logical cells are zero through fifteen; B(1) is the first packed byte value. An even cell occupies the high nibble and the following odd cell the low nibble.
Read or replace one cell
Set cell C and write flag W, then call 3100. W=0 reads; any nonzero byte writes N and returns it. On writes N must fit zero through fifteen. Reads ignore N’s value within its byte envelope. V is the returned nibble and O=1 reports success.
Reusable numbered routines
3000 LET V=0: LET O=0: LET C=0: LET W=0: LET N=0
3010 FOR I=1 TO 8: LET B(I)=0: NEXT I: RETURN
3100 LET V=0: LET O=0
3110 IF C<0 OR C>15 OR C<>INT (C) THEN RETURN
3120 IF W<0 OR W>255 OR W<>INT (W) THEN RETURN
3130 IF N<0 OR N>255 OR N<>INT (N) THEN RETURN
3140 IF W<>0 AND N>15 THEN RETURN
3150 FOR I=1 TO 8
3160 IF B(I)<0 OR B(I)>255 OR B(I)<>INT (B(I)) THEN RETURN
3170 NEXT I: LET K=INT (C/2)+1
3180 LET H=INT (B(K)/16): LET L=B(K)-16*H
3190 IF C=2*INT (C/2) THEN GO TO 3230
3200 IF W<>0 THEN LET L=N: LET B(K)=16*H+L
3210 LET V=L: LET O=1: RETURN
3230 IF W<>0 THEN LET H=N: LET B(K)=16*H+L
3240 LET V=H: LET O=1: RETURN| Fields | Use |
|---|---|
| B(1)..B(8) | Retained packed byte values |
| C / W / N | Logical cell / write flag / replacement |
| V / O | Returned cell value / valid flag |
C, W, N and all stored values must be integral in their bounds. An invalid request clears V/O and preserves the complete array and inputs. The full span is checked before any write. Only the chosen nibble changes; its partner and every other packed byte survive.
Read back the intended cell
The normal caller writes three to cell nine, then reads cell nine and its neighbour. The custom caller writes seven to the final low nibble, cell fifteen. Both also verify that the unrelated first packed byte remains 18. A later invalid cell request leaves the map alone.
Example calls from the maintained caller
90 GO SUB 3000: LET AA=0: LET BB=0
100 LET B(1)=18: LET B(2)=52: LET B(3)=86: LET B(4)=120: LET B(5)=154: LET B(6)=188: LET B(7)=222: LET B(8)=240
110 LET C=9: LET W=1: LET N=3: GO SUB 3100: GO SUB 2000
120 LET C=9: LET W=0: LET N=255: GO SUB 3100: GO SUB 2000
130 IF V=3 THEN LET AA=1
140 IF B(1)=18 THEN LET BB=1
150 LET C=8: GO SUB 3100: GO SUB 2000
160 LET C=16: LET W=1: LET N=15: GO SUB 3100: GO SUB 2000
170 PRINT "CELL RESULTS";AA;BB
180 POKE MB+995,AA: POKE MB+996,BB: POKE MB+997,GD: POKE MB+998,WC-256*INT (WC/256): POKE MB+999,INT (WC/256): POKE MB+1000,239: STOPLine 2000, AA/BB, JJ/KK (Z/Q on Spectrum) and the mailbox are diagnostics. A game passes its logical cell index, not an already halved byte offset. Convert world or map coordinates to that logical index before this layer. Reading data does not draw a tile or decide its properties.
Weigh the saving
Eight BASIC numeric elements hold sixteen logical cells. They do not occupy eight physical RAM bytes: interpreter numeric storage and array headers still apply. Compared with sixteen numeric elements, fewer elements are needed, but the helper and arithmetic also cost space and time. Measure the whole program before choosing packing for a small map.
The existing tile-map helper provides ordinary cell lookup; the bit-reader/writer process fields through a retained cursor. This component instead offers random-access four-bit cells with checked replacement. Larger tile catalogues need more bits, another mapping or an unpacked representation. Streaming compression is a different trade-off from cheap repeated edits.
Mistake and repair
The wrong first write passes cell zero instead of the requested cell. The operation succeeds, but reading the real target still returns its old value and the unrelated first packed byte has changed. Both saved checks fail. Keep logical cell identity separate from packed-byte offsets. Repaired source and loaded program match normal exactly.

Cost and Maths for Games
Each query validates eight elements, locates one element and splits its two nibbles. A write recombines only that selected pair. No shifting of the rest of the map, allocation or rendering occurs. Trusted-data variants can reduce validation; direct byte storage avoids numeric-array overhead but needs explicit RAM ownership and machine-specific access.
Maths for Games connects quotient/remainder, place value and preserving neighbouring fields. The Craft connects compact world data to the cost of changing that world during play.
Global names and line numbers
BASIC numeric names, arrays and numbered lines are global. To combine helpers, rename public and private fields and renumber the helper together with every GO SUB/GO TO reference. Keep each state block under one owner. The maintained build.py rejects duplicate line numbers; it does not automatically namespace or renumber routines.
Machine and measured size
Spectrum FOR variables must be single letters: this diagnostic uses Z/Q instead of the other callers’ JJ/KK. Numeric arrays start at one. AND/OR in these listings join comparisons; neither is being used as a bitwise operator. The diagnostic reserves its mailbox above CLEAR 59999.
The numbered helper occupies 569 tokenised program bytes on this interpreter, including line headers. This excludes the caller, test tables, numeric scalar/array storage, interpreter stacks and ROM. Tokenised size is not machine-code size or a hardware-cycle count. No real-time or whole-game performance budget is inferred from the diagnostic. The caller’s AA/BB, Z/Q and RAM mailbox are observation machinery, not required game state.
Execution evidence
Working, custom, deliberately wrong and exact repaired callers executed through Spectrum’s stock BASIC interpreter under Emu198x 0.44.2. Build198x 0.2.9 tokenises C64 PRG and Spectrum TAP programs. C64 enters stock RUN, Spectrum uses ROM tape loading, and BBC enters every numbered source line through MOS/BASIC. Actual loaded program bytes, complete state packets, positive completion/count/guards and visible result pixels are checked. After the positive completion marker, the observer advances two complete display frames before capturing, so a partially scanned final character cannot masquerade as a finished picture. Firmware is not distributed.
An independent mathematical/order model checks the trace and rejects altered source, configuration, loaded program, state, completion and image evidence. Host exhaustive checks complement the native boundary/sequential examples; those host ranges are not claimed as natively exhaustive. The supplied diagnostic callers deliberately end with STOP; the interpreter report is expected. The wrong caller prints 0,0 and the normal/custom/repaired callers print 1,1.
Public CI checks retained evidence; it does not execute restricted firmware. Refresh with python3 scripts/verify-basic-interfaces.py packed-nibble-map sinclair-zx-spectrum --tools /path/to/extracted/releases and inspect with python3 scripts/check-basic-interfaces.py packed-nibble-map.
Explore further
Compare the assembly technique. The BASIC game-flow routine makes session boundaries explicit. Complete source and native evidence.