Our first dodge ends with a word in the corner and R to try again. A game needs clearer moments than that: a title that waits for the player, a flight, and a result that says what happened. We will give the one-meteor game those phases before it grows into a storm.
The same key can mean different things at different moments. Space launches from the title; R retries and Q returns to the title from a result. We will make those moments explicit.
phase now uses 0 for the title, 1 for flight, 2 for a destroyed ship and 3 for clear space. Phase is useful for inspection, but it does not dispatch the game by itself: jumps and calls still decide which code runs.
Source · view and edit
Companion file: assets.inc
The include line reads this editable data when you assemble. Keep it beside your source for a local build.
Edit the source, then assemble.
Edits are saved in this browser. Keep a downloaded copy of work you want to keep.
Focus the Spectrum screen to use the keyboard, or hold these buttons. Assemble again for a fresh machine.
Run the program to inspect it. Editing source does not change the running machine.
phase- —
hull- —
ship_visible- —
meteor_y- —
Run it and read the title. Press Space to launch. Leave the ship still and the meteor hits it on the 44th update, when meteor_y reaches 156, about 1.8 seconds after launch. R at the result flies again; Q returns to the title. During flight Q also returns to the title, and R does nothing.
Follow one whole attempt
start now ends with jp title. The title sets phase 0, clears the screen and draws four lines with text, which takes B=character row and C=column. ld bc,$030A therefore means row 3, column 10. There are 32 columns, and METEOR STORM has twelve characters including its space, so (32-12)/2 puts it at column 10. We counted each title line by hand in the same way.
The title then waits for Space. Space is bit 0 of port $7FFE. As with O and P, a pressed key clears its bit. bit 0,a tests that one bit and sets the zero flag when it is clear, so jr nz keeps waiting while Space is up. After release, the title calls new_game and falls into main_loop.
During flight, each update first erases the ship, then reads the Q W E R T row at $FBFE. Q is bit 0 there, and pressing it jumps to title. A meteor that reaches y=174 jumps to won, which selects phase 3. A contact reaches lost, which selects phase 2. Both outcomes enter the shared result screen.
At a result, R waits for key release, calls new_game and jumps to the main loop. Q goes to the title instead.
One flag says the ship is alive
Unit 10 froze the game by setting phase 2 at the moment of contact. Now the contact test clears a separate byte, hull: 1 means the ship is alive and 0 means it has been destroyed. The test does not jump away in the middle of drawing. It finishes the update, redraws the meteor, and then the loop checks hull once. Zero leaves for lost.
Keeping “alive” as its own state means the collision code only records what happened. The decision about which screen comes next lives in one place at the end of the update. The storm we build next will check many meteors before that single check.
Reset a run as one block
Every byte a run changes sits between the labels state_start and state_end. new_game clears the screen, then zeroes that whole range with the LDIR fill from unit 1: write zero to the first byte, then copy each byte onto the next. Afterwards it sets only the values that must not be zero.
new_game:
call clear
xor a
ld hl,state_start
ld de,state_start+1
ld bc,state_end-state_start-1
ld (hl),a
ldir
ld a,1
ld (phase),a
ld a,116
ld (ship_x),a
ld a,1
ld (hull),a
ld a,116
ld (meteor_x),a
ld a,24
ld (meteor_y),a
call meteor
ld bc,$1702
ld de,controls
call text
call draw_ship
retA retry therefore cannot inherit a position or a destroyed hull from the last run. A new run variable placed between the labels is reset without another instruction. The frame byte, frames, sits outside the range: the interrupt keeps counting through every phase.
Predict, then check: delete the ld a,1 and ld (hull),a pair that follows ld (ship_x),a. The block leaves hull at zero, so the first update reads zero and the result says SHIP DESTROYED at once. Restore the two lines.
Erase the ship only when it is there
Unit 5 moved the ship by calling one XOR routine, ship, before and after steering: the first call removed the old image and the second drew the new one. An XOR only erases when the image is on the screen. Now clear can wipe the display between phases without telling the ship code, so the program splits ship into erase_ship and draw_ship and keeps a flag, ship_visible.
erase_ship:
ld a,(ship_visible)
or a
ret z
xor a
ld (ship_visible),a
ld a,(ship_x)
ld e,a
ld d,SHIP_Y
ld hl,ship_sprites
ld a,16
jp draw_sprite
draw_ship:
ld a,1
ld (ship_visible),a
ld a,(ship_x)
ld e,a
ld d,SHIP_Y
ld hl,ship_sprites
ld a,16
jp draw_spritedraw_ship draws and sets the flag to 1. erase_ship returns at once when the flag is 0; otherwise it clears the flag and XORs the image away. The block reset in new_game sets the flag to 0 after clear, and then draw_ship puts the ship back.
Try deleting call draw_ship from new_game. Predict first: the first update’s erase_ship finds the flag at 0 and does nothing, then draw_ship draws the ship. It appears one update late but correctly. Without the flag, that first erase would have drawn a ship and the following draw would have removed it. The same swap would repeat on every update, so the screen would never match ship_x. Restore the call.
Release is part of a button press
A held key must not trigger the next phase. Space held at the title should launch one flight, not count as pressed again later, and an R held from the last retry should not skip past the next result before the player reads it. release_keys waits until Space, Q and R are all up. The title calls it before and after waiting for Space, and the result calls it before and after reading R. It changes AF and BC, and nothing else.
release_keys:
halt
ld bc,$7FFE
in a,(c)
bit 0,a
jr z,release_keys
ld bc,$FBFE
in a,(c)
and 9
cp 9
jr nz,release_keys
retand 9 keeps bits 0 and 3 of the Q W E R T row: Q and R. Only when both are released is the result 9. HALT inside that wait keeps it responsive to the frame clock. The private interrupt must therefore remain enabled on title and result screens too. Waiting for a released key with interrupts disabled would strand the program at HALT.
Why can meteor_y freeze on the result screen while the program still responds to R?
Show the explanation
The meteor is moved by the flight loop, which is no longer running. The interrupt handler and the result’s input loop still run. Freezing one piece of game state does not require stopping the CPU.
Test launch while holding Space longer than usual: the title should remain until release. Lose, retry, then return to the title with Q. Steer clear once to reach CLEAR SPACE. This sequence tests transitions that a single flight would miss.
If a retry inherits a position or a destroyed hull, inspect the reset range and the values set after it. If a control triggers too soon, inspect press and release handling. Keeping these failures distinct makes the phase structure easier to investigate.
Sources
Sinclair Research, ZX Spectrum BASIC Programming, chapter 23, the keyboard input ports. Zilog, Z80 CPU User Manual, UM008011-0816, IN, BIT, AND, CP, LDIR and conditional branch entries.
Run it here
This unit's program, built for you.
- Program
- meteor-storm.tap
- Machine
- ZX Spectrum 48K (PAL)