Skip to content

Keep an Effect on a Timer in C64 BASIC

Track a finite duration and pulse interval, including a possible pulse on the final active tick.

A poison effect needs both a lifetime and a rule for when it hurts. Keeping those counters separate prevents a final partial interval from extending the effect forever. This helper returns pulses and remaining duration; your game supplies the consequence.

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

Include and initialise

Reserve R/H/D/P/A/F/O/E and lines 3000–3230. There are no arrays or private scratch globals. Combine numbered source with build.py and prevent fall-through using END, STOP or a jump. C64/BBC use GOSUB; Spectrum uses GO SUB.

Set D to an integral duration from zero to 255 and P to an integral period from one to 255. Call 3000 to clear remaining R, phase H, apply A, pulse F and O/E, retaining D/P. The effect starts inactive. Each independent effect needs separate state and numbered references, or a deliberate wrapper that loads/stores its context.

Apply and service

Set A=1 to apply or refresh, then call 3100. It reloads R from D and H=0. The same call consumes the first active tick. Set A=0 on ordinary later services. An active service decrements R, advances H and returns F=1 at a period boundary. Expiration clears H. A period-boundary pulse on the final active tick is valid, even though the returned R is zero.

For duration six and period two:

Service Remaining Pulse
Apply 5 0
2 4 1
3 3 0
4 2 1
5 1 0
6 0 1

Process the fresh F result rather than requiring R to remain positive after service; that would lose the final pulse. D=0 with A=1 cancels the effect without a pulse. Valid inactive calls clear H and return F=0. O=1,E=0 means the call is valid, not that the effect is still active.

R/D must be integral bytes, P positive, H in 0..P-1 and A exactly zero or one. E=1 rejects timer/configuration state; E=2 rejects an invalid apply flag. Invalid calls preserve R/H and clear F/O. Configuration is validated before refresh, so changing P below the old phase needs an explicit phase/reset policy rather than expecting apply to repair inconsistent state.

The reusable routine

Reusable numbered routines

routines.bas
3000 LET R=0: LET H=0: LET A=0: LET F=0: LET O=0: LET E=0: RETURN
3100 LET F=0: LET O=0: LET E=1
3110 IF R<0 OR R>255 OR R<>INT (R) THEN RETURN
3120 IF D<0 OR D>255 OR D<>INT (D) THEN RETURN
3130 IF P<1 OR P>255 OR P<>INT (P) THEN RETURN
3140 IF H<0 OR H>=P OR H<>INT (H) THEN RETURN
3150 LET E=2
3160 IF A<>0 AND A<>1 THEN RETURN
3170 LET O=1: LET E=0
3180 IF A=1 THEN LET R=D: LET H=0
3190 IF R=0 THEN LET H=0: RETURN
3200 LET R=R-1: LET H=H+1
3210 IF H=P THEN LET H=0: LET F=1
3220 IF R=0 THEN LET H=0
3230 RETURN

Duration decides how many active ticks remain. Phase decides whether this tick emits a pulse. Testing the period boundary before clearing expired phase keeps a valid final pulse while preventing a leftover partial interval from becoming extra lifetime.

Run through expiry

The working caller applies once and services eight times. It checks that the duration expires and that three pulses occur. The custom duration five/period three produces one pulse; its final partial interval expires without an extra pulse. The earlier table also tests cancellation, period one, maximum values, final pulses and invalid state.

Example calls from the maintained caller

demo.bas, excerpt
70 LET D=6: LET P=2: GOSUB 3000: LET A=1: LET NN=0: LET AA=0: LET BB=0: FOR JJ=1 TO 8
80 GOSUB 3100: GOSUB 2000: LET NN=NN+F: LET A=0: NEXT JJ
90 IF R=0 THEN LET AA=1
100 IF NN=3 THEN LET BB=1
110 PRINT "TIME RESULTS";AA;BB
120 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

Line 2000, the mailbox, NN/AA/BB and JJ (Z on Spectrum) are diagnostic machinery. A game calls 3100, consumes the fresh F result once and uses R for its own status display. The helper neither changes health nor plays sound.

Customise the effect

Choose D and P for a poison pulse, blinking effect or another periodic event. Reapplying resets both duration and phase here. Stacking durations, preserving phase on refresh or limiting refreshes are different rules and need different state/tests. Changing D alone affects a later application; it does not truncate an already-running R.

The units are service calls. They can be turns, simulation updates or clock-driven ticks if that is what the caller supplies. Withholding calls pauses the effect. An elapsed-time deadline or accumulator suits variable update spacing; a timed turn still needs its chosen clock policy. There is no installed interrupt, hidden frame wait or real-time guarantee.

P=1 can pulse on every active call, including an apply with D=1 that also expires immediately. A nonzero duration shorter than the period can expire with no pulse. Neither case is an error; the game chooses whether that behaviour fits the effect.

Mistake and repair

The wrong caller sets A=1 before every service. Remaining duration repeatedly reloads, while phase repeatedly restarts at zero. With these periods, no pulse arrives and the duration never finishes. Both saved checks print zero.

The repair applies once and retains timer state through later A=0 updates. Repaired source and loaded tokenised program match the working caller exactly.

C64 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 further steps

Each service performs a bounded sequence of scalar checks/arithmetic with no array scan. BASIC scalar storage is not eight machine-code bytes. There is no measured hardware-cycle claim. A single expiry deadline is cheaper when no periodic pulses are needed; several independently stacked effects require more state and a deliberate ownership policy.

Maths for Games connects countdowns, modular phase and discrete intervals. The Craft helps choose how pulses and expiry are communicated so the player can understand the rule.

Machine and measured size

C64 BASIC V2 recognises only the first two significant characters of numeric variable names. Short names make the ownership visible; check for collisions before including another component. This helper uses scalar numeric fields and allocates no arrays. The diagnostic uses a mailbox at $C000, outside the normal BASIC heap.

The numbered helper occupies 368 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, JJ and RAM mailbox are observation machinery, not required game state.

Execution evidence

Working, custom, deliberately wrong and exact repaired callers executed through C64’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-timelines.py timed-status commodore-64 --tools /path/to/extracted/releases and inspect with python3 scripts/check-basic-timelines.py timed-status.

Explore further

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