← Back to Courses
module
26

The heartbeat

Why this matters

Every program since S21 has kept time the same way: a pause loop burning a guessed number of counts. It worked, but look at what it cost. The rocket's speed is really "whatever is left over after the code runs" - add an asteroid and everything slows; trim a routine and everything lurches forward. The flame flickers at "about" some rate. Nothing in the game moves at a speed you could write down.

The screen itself keeps better time than any of this. Fifty times a second, every second, it finishes drawing a frame - and the VDP will tell you each time it happens. Lock the loop to that, and every rate in the game becomes a number: fifty pixels a second, a flip every eighth of a... this section is where the game stops guessing.

The flag

The VDP has a status register, read from port 9 - the same port the addresses go out on, but reading instead of writing. Its top bit is the one that matters today:

Bit 7 rises when the VDP finishes drawing a frame, fifty times a second - and reading the register puts it back down.

That second half is not a nuisance, it is the design: seeing the flag and consuming it are the same act, so two reads never count one frame twice. One IN, one frame, armed again for the next.

The wait

waitframe:
        IN A,(9)
        BIT 7,A
        JR Z,waitframe  ; not yet - the read that finally sees it clears it
        RET

Three lines, and they replace the pause loop for good. Call it at the top of the main loop and the loop runs exactly once per frame: never faster, because the flag has not risen; never slower, as long as the pass fits inside a frame's worth of time. The spins around waitframe are the loop absorbing however long the rest of the pass took - fast passes wait longer, slow passes wait less, and the tick lands on the frame either way. That is the property no pause loop can offer: the pace belongs to the clock, not to the code.

One more thing the machine will NOT do for you: its own interrupt - the signal that S12's HALT waits for - ticks about once per second. A wall clock, not a game clock. Frame timing on this machine is polled.

Speeds become numbers

With one pass per frame, read the game's constants again and notice they have become facts:

  • The rocket moves 1 pixel per pass = 50 pixels a second. Crossing the screen takes five seconds, and will take five seconds forever, on every machine, whatever else the loop is doing.
  • The flame flips every 8 passes = 6.25 flips a second.
  • S23's bullet at 3 per pass = 150 pixels a second, a screen-height per 1.3 seconds.

And new rates are now designed, not tuned: want an enemy at half the rocket's speed? Move it every second frame. A one-second countdown? Fifty frames. The frame is the game's unit of time, and everything else is counting.

The code

        ORG 256

        ; --- three patterns: body, flame A, flame B
        LD A,0
        OUT (9),A
        LD A,88
        OUT (9),A
        LD HL,rocket
        LD B,24
pat:    LD A,(HL)
        OUT (8),A
        INC HL
        DJNZ pat

        ; --- close the sprite list after our two
        LD A,8
        OUT (9),A
        LD A,123
        OUT (9),A
        LD A,208
        OUT (8),A

        ; --- AY: port A output, port B input
        LD A,7
        OUT (2),A
        LD A,127
        OUT (3),A

main:   CALL waitframe
        CALL input
        CALL animate
        CALL place
        JR main

; --- the heartbeat: return when the next frame begins
waitframe:
        IN A,(9)
        BIT 7,A
        JR Z,waitframe  ; not yet - the read that finally sees it clears it
        RET

; --- matrix input, as S25
input:  LD C,223
        CALL readrow
        BIT 5,A
        CALL Z,up
        LD C,191
        CALL readrow
        LD D,A
        BIT 6,D
        CALL Z,left
        BIT 5,D
        CALL Z,down
        BIT 4,D
        CALL Z,right
        RET

readrow:
        LD A,14
        OUT (2),A
        LD A,C
        OUT (3),A
        LD A,15
        OUT (2),A
        IN A,(2)
        RET

place:  LD A,0
        OUT (9),A
        LD A,123
        OUT (9),A
        LD A,(rockY)
        OUT (8),A
        LD A,(rockX)
        OUT (8),A
        LD A,0
        OUT (8),A
        LD A,15
        OUT (8),A
        LD A,(rockY)
        ADD A,8
        OUT (8),A
        LD A,(rockX)
        OUT (8),A
        LD A,(flameP)
        OUT (8),A
        LD A,11
        OUT (8),A
        RET

animate:
        LD A,(flick)
        DEC A
        LD (flick),A
        RET NZ
        LD A,8          ; every 8 frames: the flame flips at 6.25 a second
        LD (flick),A
        LD A,(flameP)
        XOR 3
        LD (flameP),A
        RET

up:     LD A,(rockY)
        OR A
        RET Z
        DEC A
        LD (rockY),A
        RET

down:   LD A,(rockY)
        CP 175
        RET Z
        INC A
        LD (rockY),A
        RET

left:   LD A,(rockX)
        OR A
        RET Z
        DEC A
        LD (rockX),A
        RET

right:  LD A,(rockX)
        CP 248
        RET Z
        INC A
        LD (rockX),A
        RET

rockX:  DEFB 60
rockY:  DEFB 88
flameP: DEFB 1
flick:  DEFB 8

rocket: ; body
        DEFB 00011000b
        DEFB 00111100b
        DEFB 00111100b
        DEFB 01111110b
        DEFB 01111110b
        DEFB 11111111b
        DEFB 11011011b
        DEFB 10011001b
        ; flame frame A
        DEFB 01011010b
        DEFB 01111110b
        DEFB 01111110b
        DEFB 00111100b
        DEFB 00111100b
        DEFB 00011000b
        DEFB 00010000b
        DEFB 00001000b
        ; flame frame B
        DEFB 00111100b
        DEFB 01111110b
        DEFB 00111100b
        DEFB 00111100b
        DEFB 00011000b
        DEFB 00000000b
        DEFB 00000000b
        DEFB 00000000b

W, A, S, D to fly.

What you should see

The same rocket - and the difference is felt rather than seen. Movement is perfectly even: no surge when the loop has little to do, no drag when it has much. Hold D and the rocket crosses the screen in almost exactly five seconds, every time. The pause routine is gone; nothing guesses.

Change one thing

  • Put the old CALL pause back IN ADDITION to waitframe, with BC = 30000. What happens to the rocket's speed, and every how many frames does the loop now actually run?
  • Replace CALL waitframe with HALT. Fly. Measure the rocket's new speed in pixels per second by counting along with it, and say what clock the game is now wearing.
  • Call waitframe twice. What is the game's frame rate now, and what game design would want exactly this?
  • Add a frame counter: a byte incremented every pass, and when it reaches 50 reset it and swap the backdrop between two colours (register 7, as S24). You have built a metronome - check it against a real clock.

When it goes wrong

Symptom Cause
The game runs wildly fast waitframe tests the wrong bit, or tests with the wrong sense - a poll that never waits returns instantly. Bit 7, wait for NOT zero.
Movement is even, but slower than the arithmetic says Something else also reads port 9 in the loop, consuming flags this pass then waiting out the next. One reader: waitframe.
Speeds still wander A pause loop survives somewhere, adding dead time after the frame lands. The frame wait replaces the pause; it does not join it.
Everything is glacial - one step a second The wait was built on HALT or on the machine's interrupt. That tick is a wall clock. Poll the flag.

Summary

  • The VDP's status register (read port 9) carries a frame flag in bit 7: up at the end of each frame, fifty a second, cleared by the very read that sees it - observation and re-arm in one act.
  • A three-line poll turns the main loop into a frame-locked loop: one pass per frame, fast passes absorbed, pace owned by the screen.
  • The machine's own interrupt is a ~1 Hz wall clock. Games poll.
  • Under frame lock, every speed is a number - px/s, flips/s, frames per second of countdown - designed once and true everywhere.

Next

S27. Giving the game a voice. Every part of a game is on screen now, and the machine has stayed silent through all of it - the same chip that reads the keyboard also makes sound, and it has not yet been asked to.

Get the Newsletter

New guides, disk images and community finds, roughly once a quarter. No spam, we promise, this isn't Tatung's marketing department.
Your subscription could not be saved. Please try again.
Your subscription has been successful.

Newsletter

Subscribe to our newsletter and stay updated.