A good run through the storm takes about forty seconds, and then it is over. In this lesson clear space stops being the end. It becomes a short breather before the next storm, and the game becomes a voyage through five of them. One hit still ends everything, so the fifth storm is worth reaching.
Each new storm is a new course, not the first one again. Unit 14 made the storm a table of events read one at a time, so a different storm is a different table. Most of this lesson is about how the program chooses which table to read: a second table, holding the addresses of the five courses, and one two-byte variable pointing at the current one.
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- —
storm- —
score- —
elapsed- —
course- —
Space launches; O/P steer; hold Space for 2X. One hit ends the voyage: the border flashes, the ship breaks apart, then the result appears. At the result R retries from the first storm and Q returns to the title.
To see one storm lead into the next, cross the first. The arrival fanfare plays, the bonus is shown for two seconds, and the second storm begins with the clock back at 00.00 and the ship back in the centre.
What you should see

The top line of the HUD now ends with STORM 1/5. After the first storm the playfield empties and three lines appear: CLEAR SPACE, the storm’s finish bonus and NEXT STORM, each in the colour of the band it is printed in. Two seconds later they vanish, the HUD shows STORM 2/5 and a time of 00.00, and meteors start to fall in a new pattern.

After the fifth storm the result screen appears as it always has, with CLEAR SPACE at the top.
Five courses
The first storm’s course is the one we have flown since unit 16: 120 events, each five bytes long (X, speed, delay, drift and kind), prepared on a computer by assets.py from a fixed random seed, 1986. The other four storms come from the same generator with seeds 1987 to 1990. The rules are the same: every event picks an X from 8 to 224, a speed of 2 to 5 pixels per update, a delay of 6 to 10 updates and a drift, and every sixth event is a star. So each storm has a different layout with the same pace and density. Making later storms harder is a separate change.
A random layout is not automatically a fair one. Two meteors could fall side by side with no gap the ship can reach in time. The same host program that checks these lessons steers an imaginary ship through each table, two pixels per update like ours, and only accepts a course with a route through every event. All four new seeds pass, and the storms keep at most ten objects in flight, well inside the pool’s twenty slots.
In assets.inc the five tables are labelled course_1 to course_5. Each takes 600 bytes, so the voyage’s courses take 3,000 bytes, where the single storm took 600.
A table of addresses
Here is how unit 14 found an event. With the two fields we have added since, waves now does this:
waves:
ld a,(wave)
cp WAVES
ret z
ld a,(wave_timer)
dec a
ld (wave_timer),a
ret nz
ld a,(wave)
ld e,a
ld d,0
ld hl,meteor_events
add hl,de
add hl,de
add hl,de
add hl,de
add hl,deThe event’s address is meteor_events plus five times wave. meteor_events is a fixed address: the assembler replaces the label with a number, so ld hl,meteor_events loads that number into HL. This program has five tables, and waves must read from whichever belongs to the current storm.
The answer is to keep the table’s address in memory, where the program can change it. This program adds a two-byte variable, course, and waves reads it instead:
waves:
ld a,(wave)
cp WAVES
ret z
ld a,(wave_timer)
dec a
ld (wave_timer),a
ret nz
ld a,(wave)
ld e,a
ld d,0
ld hl,(course)
add hl,de
add hl,de
add hl,de
add hl,de
add hl,deThe first excerpt above is unit 28’s; the second is this lesson’s. ld hl,meteor_events and ld hl,(course) look alike, but they do different things. Without brackets, the number itself goes into HL. With brackets, the number is an address, and HL is loaded from the two bytes stored there: L from course and H from course+1. The Z80 keeps the low byte of a 16-bit value first, the same order defw writes and unit 18’s elapsed uses. Nothing else in waves changes. Point course at a different table and the same code reads a different storm.
The five addresses are listed in order with the game’s other data:
; The voyage: one event table per storm, in order.
courses: defw course_1,course_2,course_3,course_4,course_5defw assembles each label as a two-byte word, so courses is ten bytes: the address of course_1, then of course_2, and so on. A new byte, storm, counts the storms from 0 to 4. Choosing a course means finding entry storm in courses and copying it into course.
Start a storm
That is the first job of the new routine start_storm:
; Start storm number (storm), counted from 0: point `course` at its event
; table from `courses`, then start that course from the beginning. The first
; spawn comes after 20 updates; the course step, the clock and the event
; count restart, and the ship returns to the centre. Score, phase and hull
; carry on. Changes AF/DE/HL.
start_storm:
ld a,(storm)
add a,a
ld e,a
ld d,0
ld hl,courses
add hl,de
ld e,(hl)
inc hl
ld d,(hl)
ld (course),de
xor a
ld (wave),a
ld hl,0
ld (ticks),hl
ld (elapsed),hl
ld a,20
ld (wave_timer),a
ld a,116
ld (ship_x),a
retEach entry in courses is two bytes, so entry storm is at courses plus twice storm. add a,a doubles A, and the doubled value goes into DE with D at 0, as in waves. add hl,de gives the entry’s address. The Z80 has no instruction to load DE from the address in HL, so the routine takes the two bytes one at a time: ld e,(hl) the low byte, inc hl, ld d,(hl) the high byte. ld (course),de stores the pair at course, low byte first again.
`storm` is 3. Which bytes of `courses` does start_storm read, and which table does the next storm use?
Show the explanation
Twice 3 is 6, so it reads bytes 6 and 7 of courses: the fourth entry, the address of course_4. Counting from 0 is what makes this work. Storm 0 reads bytes 0 and 1, the first entry.
The rest of start_storm starts that course from its beginning. wave goes back to 0, so the next event read is the table’s first. wave_timer gets 20, the same wait before the first spawn that new_game always set. ticks, the course step from unit 15, and elapsed, the clock, go back to 0, and the ship goes back to X 116, the centre. Every storm therefore starts the way the first one does, and a route worked out from the centre is good for every storm.
new_game used to set ship_x and wave_timer itself. Now, after clearing the run’s block of memory, it calls start_storm. storm and course are in that block, so a new run clears storm to 0 and start_storm picks the first course. A retry always starts again from storm 1.
Clear space
The main loop still decides when a storm is crossed: every event read and no object left. Where it used to jump to won, it now jumps to storm_cleared. The first part is won’s old finish:
storm_cleared:
; Finish bonus: ten points for each second below 100 seconds.
ld hl,(elapsed)
call seconds
ld b,a
ld a,100
sub b
jr nc,finish_bonus
xor a
finish_bonus:
ld (finish_points),a
ld b,a
ld a,(score)
add a,b
ld (score),a
ld hl,(elapsed)
ld de,(best_time)
or a
sbc hl,de
jr nc,finish_recorded
ld hl,(elapsed)
ld (best_time),hlThe bonus is unchanged: one point for each second under 100, shown with the score’s fixed extra 0 as ten. It is worked out for each storm, from that storm’s time, and added to the score. A storm faster than best_time replaces it. Because the clock restarts with every storm, best_time is now the fastest single storm, and the result screen’s label changes from BEST TIME to BEST STORM to say so.
Then the routine decides what comes next:
finish_recorded:
ld hl,arrival_sound
call play_sound
ld a,(storm)
cp STORMS-1
jr z,won
ld b,$08
ld de,won_text
call text_center
call bonus_line
ld b,$0F
ld de,next_text
call text_center
ld b,INTERLUDE_FRAMES
interlude_wait:
halt
djnz interlude_wait
; Spaces overwrite the three lines; the playfield is otherwise empty.
ld bc,$0800
ld de,blank_text
call text
ld bc,$0C00
ld de,blank_text
call text
ld bc,$0F00
ld de,blank_text
call text
call erase_ship
ld a,(storm)
inc a
ld (storm),a
call start_storm
call draw_ship
xor a
ld (hud_drawn),a
call hud
ld a,(frames)
ld (last_frame),a
jp main_loopAfter the fifth storm storm is 4, which is STORMS-1, and jr z,won goes to the result screen. won now only saves the score, sets phase 3 and shows the result.
Before that, the routine shows an interlude. It draws three lines with text_center; bonus_line, the result screen’s own routine, prints the finish bonus between them. Then it waits for 100 frame interrupts, two seconds, with halt and djnz. Nothing moves and no keys are read. To remove the lines it prints 32 spaces from column 0 over each one. A space in the ROM font is eight empty rows, so printing it clears the cell. The playfield has nothing else on it, because the storm only ends when every object has gone.
Then the next storm begins: erase the ship, add 1 to storm, call start_storm, draw the ship at the centre, and redraw the whole HUD by setting hud_drawn to 0 first. The last two instructions before jp main_loop copy frames into last_frame.
What would go wrong if storm_cleared jumped back to main_loop without copying `frames` into `last_frame`?
Show the explanation
The next storm’s clock would start more than two seconds late. Since unit 18, each update adds frames minus last_frame to elapsed. The arrival sound takes about 15 frames and the interlude 100, and last_frame would still hold the frame of the previous storm’s last update. The first update of the new storm would add all of those frames to an elapsed that start_storm had just set to 0, so the player would lose time they spent waiting. The difference fits in the byte frame_delta because it is under 256 frames.
Which storm
The HUD’s top line now ends with the storm number. When the whole HUD is drawn, hud makes the digit first:
hud_full_time:
ld bc,$0001
ld de,time_text
call text
ld a,(storm)
add a,'1'
ld (storm_digit),a
ld bc,$0016
ld de,storm_text
call text'1' is the character code of the digit 1, 49. Adding storm gives 49 for storm 0 and 53, the code of 5, for storm 4. The digit is written into the string at storm_digit before text prints it:
storm_text: defb "STORM "
storm_digit: defb "1/",'0'+STORMS,0The /5 is not typed in. '0'+STORMS is worked out by the assembler, so changing STORMS changes what the HUD says the voyage’s length is. One digit is enough for up to nine storms.
The title now says what the voyage asks of you: CROSS 5 STORMS. ONE HIT ENDS IT.
The score runs out
Fly a whole voyage and watch the score at the start of each storm. On a route the checks fly with Space held, it reads:
| Storm | Score at the start |
|---|---|
| 1 | 0000 |
| 2 | 0960 |
| 3 | 1940 |
| 4 | 0500 |
| 5 | 1490 |
The voyage ends showing 0020. Something went wrong between the third and fourth storms, and again during the fifth.
The score is one byte holding tens of points. What is the most it can show, and what happens to a byte when you add past that?
Show the explanation
A byte holds 0 to 255, so the score can show 2550 at most. add a,b keeps only the low eight bits of the sum and sets the carry flag for the bit that does not fit, and score stores only the eight bits. 194 plus a third storm’s 112 is 306, and 306 minus 256 is 50, so the fourth storm starts at 0500. The true total at the end of that route is 5,140 points, which is 514 tens, two times 256 plus 2: the HUD shows 0020.
A single storm could never get this far: about 40 tens for stars and at most 100 for the bonus. Five storms can. The score needs a second byte, and so does best_score. Until it gets them, the scores this program shows after a long voyage are wrong.
Experiments
Keep a working copy before each change, then press Revert when you have finished.
Change `STORMS equ 5` to `STORMS equ 2`. What changes, and does the `courses` table need to change too?
Show the explanation
The HUD shows STORM 1/2, then STORM 2/2, and crossing the second storm leads to the result screen. The table can stay as it is: storm never gets past 1, so start_storm never reads beyond the second entry. The last three courses are still assembled into the program, but nothing reads them.
Put STORMS back, then make `courses` list `course_1` five times. How will the voyage play?
Show the explanation
Five times through the first storm, exactly the same each time: the ship starts in the centre, the first spawn comes after 20 updates and every event follows in the same order. Only the HUD’s storm number and the score show that anything has moved on. The program does not care which tables the entries point at. It only reads the address it finds.
Then try changing the order of courses, to start the voyage with course_5, or shorten the interlude by changing INTERLUDE_FRAMES. Predict what each change does to the time on the HUD before you run it.
The game now lasts five storms, and its score cannot count that far. The next lesson gives it a second byte.
Sources
Zilog, Z80 CPU User Manual, UM008011-0816: LD HL,(nn) loads L from address nn and H from nn+1, and LD (nn),DE stores E at nn and D at nn+1; ADD A,r sets the carry flag when the sum passes 255. The maintained Meteor Storm source linked through this page supplies the five courses, courses, start_storm and storm_cleared, and its verification flies a keyboard route through every storm. The screenshots are native Emu198x captures of this program.
Run it here
This unit's program, built for you.
- Program
- meteor-storm.tap
- Machine
- ZX Spectrum 48K (PAL)