Skip to content

Give a BASIC turn a deadline on PET

Use a reusable elapsed-clock helper, keep untimed play valid, and let a deadline expire without blocking on input.

Press Space: one turn. Wait: the world waits with you. Now press T and leave the keyboard alone. After 240 firmware ticks, the next turn arrives by itself. Your adventure might give a player time to escape a flooding room; your board game might put a limit on a move. The turn rule can stay the same while its timing changes.

Native PET BASIC caller showing TURN 3, E 1 and T 1: one automatic expiry and timing enabled.
Two Space actions, then an automatic third turn. Complete caller captured in Emu198x 0.44.2.

Try the complete caller

Run make -C commodore-pet/patterns/basic/framework/turn-deadline in code-samples. Enter the generated build/demo.bas as one numbered program, then type RUN. Space advances, T switches timing on or off, and Q ends the example. Use brief taps at first. The status shows the turn, expiry count and active flag; it is not a live countdown.

Use a 32 KiB 40-column upgrade-ROM PET, with the default PAL timing and the recorded stock firmware. The optional diagnostic observer borrows $033A–$034D (826–845), part of the inactive second-cassette buffer. The native arithmetic fixture borrows ninety-nine bytes from the same buffer; both fit its 192-byte allocation. Do not perform tape I/O or share that buffer while using the observer. It is outside the BASIC program/variable/string heap. Q leaves the screen and observer bytes behind. The reusable helper itself needs no fixed RAM reservation.

The caller owns the game policy

caller.bas
10 PRINT CHR$(147);
20 N=0:S=1:EX=0:DU=240:LC=0:MB=826
30 DC=TI:GOSUB 3000
40 POKE MB+16,165:POKE MB+17,90
50 GOSUB 8000
100 GOSUB 5000
110 GET K$
130 IF DX THEN EX=EX+1:IF EX>255 THEN EX=0
140 IF DX THEN A$="A":GOSUB 1000:GOSUB 3100:GOSUB 8000:GOTO 100
145 IF K$="Q" THEN END
150 IF K$="T" THEN GOSUB 6000:GOSUB 8000:GOTO 100
160 IF K$<>" " THEN GOTO 190
170 A$="A":GOSUB 1000
180 IF DA THEN GOSUB 3100
185 GOSUB 8000
190 GOSUB 9000:GOTO 100
6000 IF DA THEN DA=0:DR=0:RETURN
6010 GOSUB 3100:RETURN

Combine this caller, the existing C64 command-loop rule below, its maintained deadline routines.bas, clock.bas, monitor.bas, and the compact PET/VIC-20 presentation.bas. The Makefile does that for you. BASIC executes by line number, so reserve the shown ranges and update every GOSUB/GOTO target if you renumber. Reset/NEW before entering a complete alternative: old numbered lines must not survive accidentally.

A bounded turn update, independent of time

routines.bas
1000 C=0
1010 IF A$<>"A" THEN RETURN
1020 N=N+S
1030 IF N>255 THEN N=N-256
1040 C=1
1050 RETURN

This is the existing untimed command-loop routine, unchanged. The caller supplies A$=“A” for Space and expiry. S stays in 0..255 and N wraps within 0..255. Both counters are small demonstration state: replace their effects with your own game rules.

Take the reusable helper

The helper does no input, drawing or turn advancement. It accepts two samples of a clock, subtracts the elapsed ticks and reports whether time ran out.

Initialise, start and poll a deadline

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

Set DU to your duration and DC to the current clock, then call 3000 once. This initialises disabled state and its previous sample. Call 3100 to start or restart: it copies duration into remaining time. Zero duration leaves it disabled.

Each service iteration, including while disabled, set DC and call 3200. Read DX immediately afterwards: 1 means this poll expired. The helper disables that deadline and clamps remaining to zero; the next poll clears the event. Your caller decides whether to spend a turn or start another deadline.

Global Contract
DU Caller duration: whole ticks, 0..5183999; 0 disables
DC Caller current clock: whole ticks, 0..5183999
DP Helper-owned previous sample
DR Helper-owned remaining ticks, saturated at zero
DA Helper-owned active flag, 0 or 1
DX Helper-owned expiry event, 0 or 1
DE Helper-owned elapsed ticks from this poll

These are BASIC globals. Rename them consistently to avoid collisions. Only the first two characters distinguish PET names. These ordinary numeric variables use five-byte floating-point values; all the whole numbers in the stated ranges are represented exactly. The helper reserves no fixed RAM, IRQ vector, hardware timer, array or string. It uses BASIC GOSUB/RETURN frames and is one mutable context, rather than a re-entrant service.

Starting preserves the most recent sampled baseline. Poll immediately before starting if that instant matters. Disable by setting DA=0. There is no preserved pause state: the demo clears remaining on disable and gives a fresh duration on re-enable.

Read a clock without waiting for a command

The machine-specific clock adapter

clock.bas
5000 DC=TI:GOSUB 3200:RETURN

TI reads the firmware clock. In the released 40-column default PET, retained samples count one tick per roughly 50 Hz field: 240 ticks are about 4.8 seconds here. The manual’s nominal sixtieth-second conversion is not the observed conversion for this named configuration. See the assembly entry for the independent adapter and timing evidence. The firmware counter resets at 5,184,000 ticks. Keep its normal interrupt service running. Tape I/O can interfere with timing and also conflicts with this observer’s buffer; assigning TI$ changes the shared clock. This adapter never resets it.

Poll before one whole clock cycle passes. 5,183,994 → 5 is eleven ticks; missing an entire extra cycle would give the same samples. A clock reset is not a rollover. Reinitialise your context after another owner changes the clock. This adapter never resets it.

GET K$ reads without waiting for Return. It consumes characters delivered by firmware, including typeahead and repeat. A held key is not promised to count once. The untimed command loop can use blocking INPUT because its world is allowed to wait. Putting blocking INPUT into this caller would also stop deadline service. A clock does not make BASIC run two tasks at once.

The caller polls, reads one character, then handles expiry. Expiry wins if both arrive in the same iteration, discarding that character, including T or Q. Otherwise Space advances once and restarts an already active deadline. An untimed action stays untimed. An overdue deadline produces one turn; it does not replay every turn that might have happened during a delay.

Make it your own

The same helper with half the duration

customise.bas
10 PRINT CHR$(147);
20 N=0:S=1:EX=0:DU=120:LC=0:MB=826
30 DC=TI:GOSUB 3000
40 POKE MB+16,165:POKE MB+17,90
50 GOSUB 8000
100 GOSUB 5000
110 GET K$
130 IF DX THEN EX=EX+1:IF EX>255 THEN EX=0
140 IF DX THEN A$="A":GOSUB 1000:GOSUB 3100:GOSUB 8000:GOTO 100
145 IF K$="Q" THEN END
150 IF K$="T" THEN GOSUB 6000:GOSUB 8000:GOTO 100
160 IF K$<>" " THEN GOTO 190
170 A$="A":GOSUB 1000
180 IF DA THEN GOSUB 3100
185 GOSUB 8000
190 GOSUB 9000:GOTO 100
6000 IF DA THEN DA=0:DR=0:RETURN
6010 GOSUB 3100:RETURN

This changes only DU=240 to DU=120: roughly 4.8 seconds becomes 2.4 in this PET configuration. The units matter as much as the number. Maths for Games explores the same habits: choose a representation, give quantities units and check where arithmetic wraps. Subtraction here is elapsed time, not frame pacing.

Common mistake: resetting the baseline every time

Deliberately broken adapter below. It stores “now” as the previous sample just before asking how much time elapsed. Every subtraction becomes zero.

Broken: throwing away elapsed time before polling

reset-baseline.bas
5000 DC=TI:DP=DC:GOSUB 3200:RETURN
Faulty native PET BASIC caller still showing TURN 1, zero expiries and timing enabled after more than a full duration.
The clock advanced, but the faulty adapter kept replacing the baseline. Remaining time stayed at the full duration and the automatic turn never arrived.

Restore clock.bas to remove the assignment to DP. A separate fresh repaired run advances normally. Keep this replacement out of working builds: its line number intentionally replaces the correct adapter.

What does it cost?

The routine performs bounded work per poll; it does not loop once per elapsed tick. Interpretation, GOSUB lookup and your other work still determine how often you can service it. A native clock accumulates time while BASIC draws or calculates, but expiry is noticed only when you poll again. Restarting at observation time also introduces drift.

The native interpreter stored 191 bytes of tokenised helper lines, including line headers. The complete instrumented program occupies 1,016 tokenised bytes. Its observed scalar table occupies 133 bytes, with four string bytes at the checked snapshot; the free BASIC gap is 30,590 bytes. These are native pointers and allocations, not peak heap/stack measurements. The helper’s seven numeric globals occupy seven seven-byte variable entries, 49 bytes including names and values.

The optional observer accounts for 274 tokenised bytes inside that program plus its borrowed 20-byte buffer. The recorded instrumented idle caller commonly samples intervals around 17–18 ticks. That includes input and serialisation work; it is not the cost of the helper alone. The shorter-duration program has the same size; the deliberately wrong baseline adapter adds six tokenised bytes.

The optional 20-byte observer adds six scratch/monitor globals and several byte writes every iteration. Remove GOSUB 9000, its guards, MB/LC initialisation and monitor.bas when you do not need it. It is deliberately readable, rather than a fast game implementation. The caller redraws only on actions, toggles and expiry. No whole-game frame budget follows from these measurements.

Optional monitor used by the complete caller

This is diagnostic feedback, separate from the deadline helper. Include it for the complete caller above, or remove the observer calls and reservations as described before omitting it.

Compact presentation shared by these two callers

presentation.bas
8000 PRINT CHR$(19);:PRINT "SPACE TURN T TIMER Q"
8010 PRINT "TURN";N;" E";EX;" T";DA;" "
8020 RETURN

Optional diagnostic snapshot

monitor.bas
9000 POKE MB+19,0
9010 POKE MB,N:POKE MB+1,EX:POKE MB+2,DA
9020 V=DR:O=3:GOSUB 9100
9030 V=DC:O=6:GOSUB 9100
9040 V=DU:O=9:GOSUB 9100
9050 V=DE:O=12:GOSUB 9100
9060 LC=LC+1:IF LC>255 THEN LC=0
9070 POKE MB+18,LC:POKE MB+19,239:RETURN
9100 H=INT(V/65536):V=V-H*65536
9110 M=INT(V/256):V=V-M*256
9120 POKE MB+O,V:POKE MB+O+1,M:POKE MB+O+2,H:RETURN

Longer clocks, several independent deadlines, preserved pause time, carrying lateness into the next turn, queued commands or fixed-step catch-up are useful extensions when a game needs them. Each changes either the policy or the resource cost. See choosing progression and clocks for those distinctions.

What was checked

Code status: emulator-executed BASIC on the named configuration. Six independent stock-firmware boots cover working, half-duration, wrong baseline, repair, boundaries and wrong equality. Source is typed through the real keyboard path. The observer checks coherent guarded state, idle/action, no-input expiry, active restart and disable; it also records the complete native tokenised program, line inventory, BASIC pointers and variable entries.

A second caller supplies twelve DATA cases to the same helper, including zero/one/maximum duration, exact and overdue expiry, an ordinary low-word crossing and the firmware clock modulus. Changing > to >= completes the fixture but fails the independent oracle. Those supplied samples qualify arithmetic, not a real-time wait across a whole rollover.

The retained checker independently decodes the visible native digits and rejects 28 falsified records across PET and VIC-20. The maintained source and evidence bind source, tools and firmware hashes. The shared helper remains maintained once. ROMs are not redistributed. No original-hardware, other-ROM/region, peak-memory or whole-game performance claim follows from these runs.

For buffer ownership and BASIC pointers, see the PET User Manual’s cassette guidance and memory map, and the VIC-20 Programmer’s Reference Guide memory map. A borrowed firmware buffer is useful only while its original owner is inactive.

Compare the separate PET assembly deadline for its different state representation, integration and cost. The untimed PET command loop remains useful without a clock.