Skip to content

Project a World Point into the View in Spectrum BASIC

Convert byte-pair world coordinates to visible screen positions without dropping high bytes or wrapping negative differences.

A world can be much larger than the visible screen. Subtract the camera’s position from a world point, then decide whether that relative position fits inside the view. Crossing coordinate 255 must not suddenly send the point back to zero.

Spectrum BASIC shows both retained behaviour checks passing.
The working caller completes with both saved checks equal to one.

Include and initialise

Reserve public X/Y/V/O/A/B/C/D/E/F/G/H/W/T, private I/J/K/L and lines 3000–3240. There are no arrays. Keep private I/J/K/L away from any active caller FOR variables. Combine routines.bas with the caller using build.py, with END, STOP or a jump before the helper. Use GOSUB on C64/BBC or GO SUB on Spectrum. Call 3000 to clear public fields.

Supply world X as low/high bytes A/B and world Y as C/D. Camera X uses E/F and camera Y uses G/H. Each pair represents low+256*high, an unsigned coordinate zero through 65535. W/T are positive byte width/height, in the same units as the coordinates.

Transform before clipping

Call 3100. O=1 means valid byte inputs and nonzero dimensions. V=1 means the point is inside; X/Y are its screen coordinates. A point to the left or above has a negative relative coordinate and is outside. The right and bottom boundaries are excluded: relative X must be less than W and relative Y less than T.

Reusable numbered routines

routines.bas
3000 LET X=0: LET Y=0: LET V=0: LET O=0: LET A=0: LET B=0: LET C=0: LET D=0: LET E=0: LET F=0: LET G=0: LET H=0: LET W=0: LET T=0: RETURN
3100 LET X=0: LET Y=0: LET V=0: LET O=0
3110 IF A<0 OR A>255 OR A<>INT (A) THEN RETURN
3120 IF B<0 OR B>255 OR B<>INT (B) THEN RETURN
3130 IF C<0 OR C>255 OR C<>INT (C) THEN RETURN
3140 IF D<0 OR D>255 OR D<>INT (D) THEN RETURN
3150 IF E<0 OR E>255 OR E<>INT (E) THEN RETURN
3160 IF F<0 OR F>255 OR F<>INT (F) THEN RETURN
3170 IF G<0 OR G>255 OR G<>INT (G) THEN RETURN
3180 IF H<0 OR H>255 OR H<>INT (H) THEN RETURN
3190 IF W<1 OR W>255 OR W<>INT (W) THEN RETURN
3200 IF T<1 OR T>255 OR T<>INT (T) THEN RETURN
3210 LET I=A+256*B: LET J=C+256*D: LET K=E+256*F: LET L=G+256*H: LET O=1
3220 LET I=I-K: LET J=J-L
3230 IF I<0 OR I>=W OR J<0 OR J>=T THEN RETURN
3240 LET X=I: LET Y=J: LET V=1: RETURN

An outside but valid point returns X/Y/V=0 and O=1. Invalid input clears X/Y/V/O. Every input survives either result. Check V before using X/Y: zero coordinates can mean the visible top-left point or an invisible result.

Keep both bytes

The normal caller projects world 261,266 against camera 250,256 in a 32-by-24 view. The result is 11,10. The custom caller uses world 65535,65535 and camera 65534,65534 in a two-by-two view, giving 1,1. These examples cross a byte boundary and approach the full world-coordinate limit.

Example calls from the maintained caller

demo.bas, excerpt
  70 GO SUB 3000: LET AA=0: LET BB=0
  80 LET A=5: LET B=1: LET C=10: LET D=1: LET E=250: LET F=0: LET G=0: LET H=1: LET W=32: LET T=24
  90 GO SUB 3100: GO SUB 2000
 100 IF X=11 THEN LET AA=1
 110 IF V=1 THEN LET BB=1
 120 LET A=0: LET B=0
 130 GO SUB 3100: GO SUB 2000
 140 LET A=255: LET B=255: LET W=0
 150 GO SUB 3100: GO SUB 2000
 160 LET W=2
 170 GO SUB 3100: GO SUB 2000
 180 PRINT "VIEW RESULTS";AA;BB
 190 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: STOP

Later calls move the point outside and submit a zero-width view. AA/BB/JJ (Z on Spectrum), line 2000 and the mailbox are diagnostic machinery. The helper writes no display registers, scrolls no hardware and changes no camera position.

Match the test to the object

This is point visibility. A sprite can overlap a viewport while its origin lies outside. Rectangle clipping includes dimensions and uses the appropriate intersection test. A camera offset or screen shake should be applied at a deliberate rendering stage, with wider signed arithmetic if it can make the visible range extend beyond this origin.

The representation can describe world pixels, tiles or another chosen unit, but all inputs must agree. A width of 32 tiles is not a width of 32 pixels. Nonwrapping subtraction is intentional: a toroidal world needs a shortest-distance or another explicit wrap policy.

Mistake and repair

The wrong caller discards the world X high byte before conversion. Both normal and custom points move to an unrelated world position and become invisible. The saved X and visible checks fail. Preserve the complete byte pair through subtraction. Repaired source and loaded program match normal exactly.

Spectrum BASIC shows two failed checks from the deliberate caller mistake.
The wrong caller runs to completion, but both saved behaviour checks are zero.

Cost and Maths for Games

The routine validates ten input bytes, reconstructs four words and subtracts two coordinate pairs before four bounds comparisons. Screen outputs fit bytes because valid dimensions are at most 255. The widened differences must remain signed until clipping; masking them back to bytes first would turn some outside points into false positives.

Maths for Games connects coordinate spaces, translation and half-open bounds. The Craft connects camera ownership and clipping to a view that stays stable as the world grows.

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 instead of the other callers’ JJ. The helper uses scalar numeric fields and allocates no arrays. 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 731 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 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-traversal.py viewport-world-transform sinclair-zx-spectrum --tools /path/to/extracted/releases and inspect with python3 scripts/check-basic-traversal.py viewport-world-transform.

Explore further

Compare the assembly technique. The BASIC game-flow routine makes session boundaries explicit. Complete source and native evidence.