Skip to content

Give a Spectrum BASIC turn a deadline

Keep untimed play valid, then add a reusable FRAMES countdown with an explicit input and expiry policy.

Space takes a turn. Holding it takes only one: release before your next action. Waiting does nothing until T enables timing. Then a turn arrives after 200 clock ticks, roughly four seconds on this PAL Spectrum. Q stops after release.

The native BASIC caller displays TURN 3 E 1 T 1 after an automatic turn.
The game starts untimed. Enabling the deadline adds another way to spend a turn.

Load the complete example

Run make BUILD198X=/path/to/build198x in the maintained folder. Load build/demo.tap through the normal 48K ROM with LOAD “”; build/demo.bas is the complete listing for manual entry. Build198x 0.2.9 tokenises it; the ROM loads and executes it. NEW/reset before replacing complete variants.

caller.bas

caller.bas
   5 CLEAR 49999
  10 BORDER 0: PAPER 0: INK 7: CLS
  20 LET N=0: LET EX=0: LET DU=200: LET MB=50000: LET KH=0
  30 GO SUB 5000: GO SUB 3000
  40 POKE MB+16,165: POKE MB+17,90
  50 GO SUB 8000
 100 GO SUB 5000: GO SUB 3200
 110 GO SUB 4000
 120 IF DX=0 AND (K$="q" OR K$="Q") THEN GO SUB 4500: STOP
 130 GO SUB 6000
 140 IF CH THEN GO SUB 8000
 190 GO SUB 9000: GO TO 100

Include caller, input, routines, clock, dispatch, presentation and optional monitor as one numbered program. Number order determines execution; keep numbers unique and update every target when renumbering. CLEAR 49999 reserves high RAM before creating variables. The optional observer owns 50000–50018; the separate boundary caller uses 50000–50104. Without diagnostics remove MB, line 40, GO SUB 9000 and monitor.bas, then choose a reservation for your project. The countdown itself needs no fixed-address buffer.

Use the countdown in your own game

routines.bas

routines.bas
3000 LET DP=DC: LET DR=0: LET DA=0: LET DX=0: LET DE=0: RETURN
3100 LET DR=DU: LET DA=0: LET DX=0
3110 IF DU>0 THEN LET DA=1
3120 RETURN
3200 LET DX=0: LET DE=DC-DP
3210 IF DE<0 THEN LET DE=DE+65536
3220 LET DP=DC
3230 IF DA=0 THEN RETURN
3240 LET DR=DR-DE: IF DR>0 THEN RETURN
3250 LET DR=0: LET DA=0: LET DX=1: RETURN

Set DU to integer duration 0..65535 and DC to the sampled clock. GO SUB 3000 initialises once and starts disabled. GO SUB 3100 copies duration to remaining and enables only when nonzero. It clears the event but retains the latest baseline: sample/poll immediately before starting if that instant matters.

Each service iteration, including while disabled, supplies DC and calls 3200. Read DX before another poll/start clears it. DR is remaining, DA enabled, DP previous and DE elapsed. DU/DC are inputs; the other five globals belong to the helper. Avoid name collisions. It owns no I/O, array or fixed RAM and uses BASIC’s normal return stack. This is one mutable context, not a re-entrant service.

The algorithm matches the maintained Dragon/MSX countdown; Spectrum’s LET syntax needs an explicit implementation here. Whole values and intermediates within this contract fit Sinclair’s numeric representation exactly. Native allocation uses seven bytes per two-letter helper name, 49 bytes for seven entries; another BASIC has different costs.

Subtraction wraps modulo 65536: 65526 → 10 is twenty ticks. Poll less than a full cycle apart, roughly 21 minutes 51 seconds at 50 Hz. Extra complete cycles are ambiguous. Reinitialise after an external FRAMES reset. Zero duration means off; expiry clamps remaining to zero and disables the helper. A late poll emits once without catch-up. Restarting from observation can drift. Maths for Games explores these same units, ranges and modular arithmetic.

Sample a coherent clock

clock.bas

clock.bas
5000 LET DH=PEEK 23673: LET DL=PEEK 23672
5010 IF DH<>PEEK 23673 THEN GO TO 5000
5020 LET DC=256*DH+DL: RETURN

The adapter reads high byte 23673, low byte 23672, then high again. If it changed, it retries. DH/DL are scratch globals and DC is the result. This avoids combining values across a carry; it does not POKE FRAMES or change interrupts. A full high-byte cycle between checks would be ambiguous too: this is a short foreground adapter under normal firmware service.

The stock IM1 ROM supplies the clock. Keep it available; interrupt suppression and ROM routines can affect clock service. The named PAL rate is nominally 50 ticks/second, not a precision stopwatch. A clock sample does not pace the display.

Keep controls and game policy separate

input.bas

input.bas
4000 LET K$=INKEY$
4010 IF K$="" THEN LET KH=0: RETURN
4020 IF KH THEN LET K$="": RETURN
4030 LET KH=1: RETURN
4500 IF INKEY$<>"" THEN GO TO 4500
4510 RETURN

dispatch.bas

dispatch.bas
6000 LET CH=0
6010 IF DX THEN LET EX=EX+1: IF EX>255 THEN LET EX=0
6020 IF DX THEN LET N=N+1: GO SUB 7000: GO SUB 3100: LET CH=1: RETURN
6030 IF K$="t" OR K$="T" THEN IF DA THEN LET DA=0: LET DR=0: LET CH=1: RETURN
6040 IF K$="t" OR K$="T" THEN GO SUB 3100: LET CH=1: RETURN
6050 IF K$<>" " THEN RETURN
6060 LET N=N+1: GO SUB 7000: LET CH=1
6070 IF DA THEN GO SUB 3100
6080 RETURN
7000 IF N>255 THEN LET N=0
7010 RETURN

INKEY$ samples a current key without waiting for Return. KH accepts one command, then needs an observed release to re-arm. A short press between samples can be missed; changing directly between held keys does not re-arm. Blocking INPUT would stop this caller servicing its deadline while the player thinks. Q waits for release before STOP.

The dispatcher owns N, EX and redraw request CH and performs no I/O. Expiry wins over the sampled command, including T/Q. An action restarts only active timing; untimed play stays untimed. Disable discards the remainder; enable starts a full duration. Change the bounded increment to your own world update. Preserved pause, queued input and carried lateness need their own explicit policy/state.

presentation.bas

presentation.bas
8000 PRINT AT 0,0;"SPACE TURN T TIMER Q STOP"
8010 PRINT AT 1,0;"TURN ";N;" E ";EX;" T ";DA;"      ": RETURN

Optional native observer; omit it from your game if unnecessary

monitor.bas
9000 POKE MB+18,0
9010 POKE MB,N: POKE MB+1,EX: POKE MB+2,DA: POKE MB+3,DX
9020 POKE MB+4,DR-256*INT (DR/256): POKE MB+5,INT (DR/256)
9030 POKE MB+6,DC-256*INT (DC/256): POKE MB+7,INT (DC/256)
9040 POKE MB+8,DP-256*INT (DP/256): POKE MB+9,INT (DP/256)
9050 POKE MB+10,DE-256*INT (DE/256): POKE MB+11,INT (DE/256)
9060 POKE MB+12,DU-256*INT (DU/256): POKE MB+13,INT (DU/256)
9070 POKE MB+18,202: RETURN

The presentation owns the first two rows and redraws on changes. BASIC owns the display, input, variables, stack and normal interrupt service. The caller clears once and leaves its black-paper/white-ink display on STOP. There is no assembly dependency or replacement ISR.

Customise and diagnose

Choose 100 ticks instead of 200

customise.bas
   5 CLEAR 49999
  10 BORDER 0: PAPER 0: INK 7: CLS
  20 LET N=0: LET EX=0: LET DU=100: LET MB=50000: LET KH=0
  30 GO SUB 5000: GO SUB 3000
  40 POKE MB+16,165: POKE MB+17,90
  50 GO SUB 8000
 100 GO SUB 5000: GO SUB 3200
 110 GO SUB 4000
 120 IF DX=0 AND (K$="q" OR K$="Q") THEN GO SUB 4500: STOP
 130 GO SUB 6000
 140 IF CH THEN GO SUB 8000
 190 GO SUB 9000: GO TO 100

Deliberately broken: replace previous immediately before polling

reset-baseline.bas
5000 LET DH=PEEK 23673: LET DL=PEEK 23672
5010 IF DH<>PEEK 23673 THEN GO TO 5000
5020 LET DC=256*DH+DL: LET DP=DC: RETURN

The custom caller halves duration without changing program/helper size. The faulty adapter keeps the clock moving but makes every elapsed interval zero. Remaining stays full and the screen stays at TURN 1 E 0 T 1. Restore clock.bas and rebuild; an independent repaired run reaches TURN 3 E 1 T 1. The fault adds seven tokenised bytes. Never combine both adapters in a working listing.

The faulty native caller displays TURN 1 E 0 T 1 with no automatic turn.
Timing is enabled, but resetting the baseline prevents the countdown from advancing.

Measured costs and qualification

The complete working program contains 1,784 tokenised bytes, including line headers; the helper accounts for 290. The observed variable area including its terminator is 101 bytes, with 49 for the seven helper entries. These are snapshots, not peak heap/stack use. Instrumented service samples commonly span about fifteen ticks, including interpretation, input, policy and serialisation. That is not a helper-only runtime or a whole-game frame budget.

Six independent ROM-tape-loaded cases on Emu198x 0.44.2 and the recorded stock 48K PAL ROM cover working/custom/wrong/repaired callers, boundaries and wrong equality. The full native line inventory and tokenised bytes are compared with the maintained listing/TAP. A second caller supplies twelve clock pairs and three controlled policy cases. Its wrong-equality control completes but fails the unsigned oracle. Supplied samples do not establish an observed 22-minute rollover; live input does not force exact simultaneity.

Native bitmap/font and PNG pixels, real controls, return-to-editor arithmetic and source/tool/ROM identities are retained with deliberately rejected evidence changes. These are named native observations, not physical-hardware or whole-emulator qualification.

Sinclair Research, ZX Spectrum BASIC Programming, chapter 18, explains FRAMES and the interrupted-read hazard; Appendix A documents the workspace. The adapters, countdown and game policy here are our implementation choices.

Maintained source · Native execution record · Independent assembly route · Choosing progression and clocks