Skip to content

Chain storms into a voyage

  • Sinclair ZX Spectrum
  • Assembly

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
ZX Spectrum
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

Meteor Storm between storms. The HUD reads TIME 20.88, the boost line and SCORE 0160, with STORM 1/5 at the top right. In the middle of the empty playfield, CLEAR SPACE is green, FINISH BONUS 0800 yellow and NEXT STORM red. The white ship is at the bottom.
Clear space after the first storm, crossed in 20.88 seconds with boost held. The bonus counts the 80 whole seconds under 100. It is already in the score; the HUD shows the new total when the next storm starts.

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.

Meteor Storm in the second storm. The HUD reads TIME 08.00, SCORE 1000 and STORM 2/5. Five meteors are spread across the cyan, green and yellow bands, with a green star at the left. The ship is at the bottom.
Eight seconds into the second storm. Its meteors arrive at different places and times from the first storm's, with the same speeds and spacing.

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:

meteor-storm.asm, excerpt
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,de

The 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:

meteor-storm.asm, excerpt
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,de

The 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:

meteor-storm.asm, excerpt
; The voyage: one event table per storm, in order.
courses: defw course_1,course_2,course_3,course_4,course_5

defw 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:

meteor-storm.asm, excerpt
; 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
 ret

Each 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:

meteor-storm.asm, excerpt
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),hl

The 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:

meteor-storm.asm, excerpt
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_loop

After 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:

meteor-storm.asm, excerpt
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:

meteor-storm.asm, excerpt
storm_text: defb "STORM "
storm_digit: defb "1/",'0'+STORMS,0

The /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)

Download meteor-storm.tap