No posts yet. Check back soon!
Finishing the ZX Spectrum Shooting Gallery
07:30 PM · Jul 25, 2026
This week I’ll be finishing up the port of my Shooting Gallery Rosetta Stone program to the ZX Spectrum. After last week, the only thing left was shots and the logic surrounding them.

This is a feature that needs a lot of work at all levels, from design through implementation through consideration of the top-level program organization.
Designing the Data
There’s a lot of things we need to do with the shots, so we’ll need to define the space to handle it for us. One of them is pretty trivial; we’ll want a convenient place to store the highest Y coordinate (that is, the point lowest on the screen) that any live shots are. We measure when to allow the next shot by making sure this value is below some threshold.
max_shot_y # 1
The individual shots themselves have to track their location and also whether they hit something during the render phase. We know that we’re going to be completely separating rendering from game logic, so while the pixel-intersection operations happen during rendering, everything else happens during frame logic. That gives us three bytes worth of data to hold: the two coordinates and a collision value.
On the TMS9918-and-Z80 systems, I used the hardware collision detection register and then used my CPU-side mirror of sprite VRAM as an array of 4-byte structures. This time around I decided to flip the script a little, working a bit more like I would on the 6502 and used a “structure of arrays” approach instead:
num_shots # 1 shot_y # 16 shot_x # 16 shot_collide # 16
Without hardware sprites, I decided that this time I would keep the array continuous as well; hence we’ll need a length variable num_shots to go with it. My older code just processed all possible shots at all times; this one will need to compact the array the way the Amiga code did.
The last bit of information we need is the data we pass on to rendering. We had three steps in our plan two weeks ago:
- Erase out the bottom two pixels of every shot. We missed a spot here: in this phase we also need to erase any shot that participated in a collision last frame entirely.
- Check the pixels that we’re about to extend our active shots to; if there’s a pixel drawn there then the shot we’re about to draw is colliding with a target.
- Draw all the remaining pixels of all our shots; it’ll be easier to just redraw them all out then try to figure out if a moving target has swept away its old pixels.
Erasing is easy; all we need is the memory address to start clearing from. Since all erasing happens before any drawing we don’t even need to do any bit-level silliness; we can just wipe out the whole byte. I decide that this will be an array named shot_tails that gives us the place to start our erasing. Much like the targets last week, every point we ever erase from will be on an even pixel row, which makes erasing its successor basically free.
I also decide, at this point, that I’ll handle the deletion of entire shots (each six pixels tall) by just putting three entries in this table. That means I’ll want to have more than 16 entries (my maximum allocation for shots) in this table; in principle only one shot should be getting fully erased each frame, so doubling my array to 32 entries seems sufficient. The size of this array might vary from frame to frame, thanks to these collisions. I decide that I’ll be using null pointers as a terminator for this list instead of having an explicit size counter.
Drawing shots is a little more exciting. In addition to the screen memory byte to start at, we’ll need a bitmask to identify how to composit the shot into the screen. Since that will be happening during collision detection, we will also need some way to propagate collision results back to the shot_collide array. In the end, I make this another null-terminated list, this time of four-byte structures: the two-byte address, a one-byte bitmask for drawing the shot, and an index callback. With 16 shots in the array and the potential need for a terminator, we’ll ultimately need 17×4=68 bytes.
shot_tails # 64 shot_heads # 68
(Why can’t I just use the indices of the arrays for the head? Shots leaving the screen, as we’ll see, are still being actively updated but won’t be redrawn.)
Autonomous Movement
Much like the targets, updating the “automatic” part of the shots is very easy; we just subtract 2 from each active Y coordinate if it isn’t already at zero.
ld hl,shot_y
ld a,(num_shots)
or a
ret z ; Return immediately if no shots
ld b,a
1 ld a,(hl) ; Check current Y coord
or a ; Already zero?
jr z,2F ; If so, on to next shot
sub 2 ; If not, subtract 2
ld (hl),a
2 inc hl
djnz 1B
Retiring Shots
I’ll need to keep the shot array compacted as I go, and that means removing shots that have left play. I need some way of determining which shots count as dead, and we have an obvious candidate for that from our previous code: we treat a Y coordinate of 0 as being a shot that needs to be collected. This scan through the shot array is also as good a time as any to actually compute our maximum Y coordinate, so let’s do that.
Also, at this point, I decided to be cowardly expedient about my implementation: the rest of this code doesn’t really require the shots to be in any kind of sorted Y order like the Atari and Amiga versions did, so I could use the constant-time array-deletion mechanism of simply copying the last element over whatever element I delete. However, I’d done that in Simulated Evolution already and I had found to my considerable distaste that it was quite tricky to make sure that you didn’t accidentally skip over elements as you copied them. Even the original code I was adapting had some bugs of this nature, where some elements would be processed twice, or not at all! Simpler to just scan and compact. The core algorithm is classic:
- Start your read and write pointer at the beginning of the array.
- If the read pointer does not point to a deleted entry, copy it to the write pointer and advance the write pointer.
- Advance the read pointer unconditionally.
- Repeat steps 2 and 3 until the read pointer hits the end of the array.
- The final number of elements in the array is the number of times the write pointer was advanced.
My structure-of-arrays approach makes the Z80’s index registers a natural fit for the task of accessing all elements at once. If (IX) reads an element from shot_y, (IX+16) reads the corresponding shot_x and (IX+32) the corresponding shot_collide. The Spectrum reserves IY for the system variables, but I think it’s honestly worth it to temporarily disable interrupts and take over IY as well so that you can use one as the read pointer and one as the write pointer.
I didn’t do that, though, so now we get to see why structure-of-arrays isn’t always a great idea on the Z80. I use IX as my read pointer, but then I need three different write pointers. As it happens, I use DE to point to the relevant element of shot_x while HL alternates between the other two fields. Setup is pretty straightforward, initializing the max shot length and making sure we have any other work to do at all before preparing the registers and the stack values:
compact_shots:
xor a ; Max Shot Y starts at 0
ld (max_shot_y),a
ld a,(num_shots)
or a ; No shots? Nothing to compact.
ret z
ld ix,shot_y ; Read ptr for all fields
ld hl,shot_collide ; Write ptr for collision
push hl ; Store on the stack for later exchange
ld hl,shot_y ; Write ptr for Y
ld de,shot_x ; Write ptr for X
I only have the A, B, and C registers left. I’ll be using A as my workhorse register as usual, taking advantage of the flexibility of (HL) as a dereference point when I can, and B lets me do the standard DJNZ-based loops. That leaves C, which I decide will hold the final count of the number of elements copied. The loop begins normally enough; almost all the work of the loop is basically inside a single IF Y != 0 conditional.
ld b,a ; Loop var
ld c,0 ; Start with no shots written
.lp: ld a,(ix) ; Load next shot Y
or a ; If it's nonzero, don't delete...
jr z,.skip
If we are indeed doing a copy, we start by copying the Y coordinate over (HL is pointing to that), and then we use that register’s flexibility so let us easily also update the maximum value:
ld a,(ix) ; Copy its Y coordinate
ld (hl),a
ld a,(max_shot_y) ; Compare Y coord to max
cp (hl)
jr nc,1F
ld a,(hl) ; New max!
ld (max_shot_y),a
1
The X and collision bits are simpler.
ld a,(ix+16) ; Copy X coordinate
ld (de),a
ex (sp),hl ; Swap in collision write pointer
ld a,(ix+32) ; Copy collision bit
ld (hl),a
We don’t want to swap back to the Y coordinate yet, though, because we have to advance all three pointers, so we need to finish our work with the collision pointer before swapping back.
inc hl
ex (sp),hl
inc hl
inc de
We also did actually write a shot there, so we bump our shots-written count.
inc c
Advancing all the read pointers at once is much easier. I made IX the read pointer here specifically since we’ll be doing this operation more often.
.skip: inc ix
djnz .lp
After the loop is done, all we have to do is save out our values and fix our stack.
ld a,c ; Store out the number of shots
ld (num_shots),a
pop hl ; Remove our temp value from the stack
ret
This code is not remotely optimized, but it keeps to itself and it fits within my time budgets, so I’m letting it slide.
Spawning New Shots
Now that we’ve handled deleting shots, let’s create some. There’s a lot of ways this can fail, so let’s get them out of the way first.
new_shot:
ld a,(shot_requested) ; Are we shooting at all?
or a
ret z ; If not, nothing to do.
ld a,(max_shot_y) ; Lowest shot too low?
cp 124
ret nc ; If so, nothing to do.
ld hl,num_shots ; Too many shots already?
ld a,(hl)
cp 16
ret z ; If so, nothing to do.
At this point A holds our total active shots and HL is a pointer to the location we store it, so we can increment it in memory:
inc a
ld (hl),a
Now we need a pointer to the location of the new shot. That’s going to be a pointer to the last element in the live array in shot_y. Shots always start at the same Y coordinate, so we can load that into place right afterwards.
ld l,a
ld h,0
ld de,shot_y-1
add hl,de
ld (hl),146
The X coordinate is more interesting. We have to point to it first, of course:
ld de,16
add hl,de
The actual value will be based on the blaster’s, and we intended to use the blaster cannon location as our main blaster location. In most ports we could just copy that value out of blaster_x and be done with it. Not here, though: the shots use the full 256-pixel horizontal resolution, so we have to double the value after reading it. We also have to add an extra pixel to the X coordinate if we’re facing right (blaster_facing=0), because of the way we can’t be perfectly symmetrical.
ld a,(blaster_x)
add a
ld (hl),a
ld a,(blaster_facing) ; Adjust shot location
or a ; if facing right
jr nz,1F
inc (hl)
1
Finally, we zero out whatever was in shot_collide.
add hl,de
ld (hl),0
ret
Generating the Display Lists
Translating the shot information out of our three arrays and into instructions for the renderer is the job of the record_display_list function. This routine has several responsibilities:
- Identifying the bottom two pixels of each visible shot from the previous frame.
- Identifying the entirety of any shot that collided in the previous frame and marking its pixels and its entry in the shot array for deletion.
- Identifying the two pixels to which each active shot will be advancing to and determining whether the pixel head is still on the screen. Pixels in the process of leaving the screen are erased in the first step but not redrawn in this one.
This turns out to be another affair of managing three pointers: IX as the pointer into the shot array and its three fields, and HL swapping between shot_tails and shot_heads in what is now a familiar pattern. We begin by preparing the registers and ensuring that there is in fact any work to do in the first place:
record_display_list:
ld ix,shot_y
ld hl,shot_tails
push hl
ld hl,shot_heads
ld a,(num_shots)
or a ; Any shots at all?
jp z,.done
ld b,a
Yes, that’s JP Z,.DONE, not JR. This function is long. Each iteration starts by filling in the structure for shot_heads if needed. It might not be needed; we don’t draw if a collision was flagged last frame, nor do we draw if the head of the shot has left the playfield. (Note that the rest of the shot will still be there in this case, left over from previous frames; we just aren’t drawing it to extend beyond the playfield!)
.shotplotlp:
ld a,(ix+32) ; Don't keep drawing the head on collision
or a
jr nz,.checkdel
ld a,(ix) ; Also don't draw if we're scrolling off the top
cp 8
jr c,.checkdel
At this point A holds the shot’s Y coordinate. We convert that to a screen address in DE by calling our row_addr helper function, and then we handle the X coordinate by reading its value out of shot_x, storing that value in B, shifting it right 3, and adding it to E. Given how the screen memory is laid out, we know that this cannot overflow. This puts the final screen address for the shot byte in DA, which can write through HL into the shot_heads array, advancing as we go.
The row_addr helper trashes BC, so to preserve our loop variable we’ll need to save it off to the stack first. We’ll delay popping it until we need it, though.
push bc
call row_addr
ld a,(ix+16)
ld b,a
rrca
rrca
rrca
and $1f
add e
ld (hl),a
inc hl
ld (hl),d
inc hl
We also need a bitmask to identify the exact pixel that we’ll be modifying when we composit the shot into place. In my PLOT routine I actually used a loop to rotate the bit into place. I didn’t really like that idea here so instead I created a lookup table. In the interests of not needing to do 16-bit math, I made sure that I never crossed a page boundary by putting it right next to the beginning of my program and jumping past it with a jump instruction:
;; Put our mask list at the top so we know it's all on one
;; memory page
jr 1F ; ... and jump past it
x_mask: defb $80,$40,$20,$10,$08,$04,$02,$01
1
That means I can take the remaining bits of the X coordinate (which we stashed in B, recall) and add it to this address to get the mask that will be the third byte of our shot_heads entry.
ld de,x_mask
ld a,b
and 7
add e
ld e,a
ld a,(de)
ld (hl),a
inc hl
If I were feeling very clever indeed, I could have instead arranged things so that x_mask started on a page boundary, and I wouldn’t even need to do the addition; I’d just load the high byte of the table into D and the index itself into E. That saves us all of 2 bytes and 7 cycles, though. I won’t weep.
The last byte is the index in the array that we’re updating. We haven’t been keeping track of that exactly, but we can compute it by subtracting the loop counter from the total number of shots. This is the point where we restore our loop variables to BC:
pop bc ; Record shot index
ld a,(num_shots)
sub b
ld (hl),a
inc hl
Even if we didn’t need the value, we’d still need to pop here, because the next instruction is the .checkdel label and we need the stack to look like it did during the jumps at the top of the iteration. In particular, we need the top value of the stack to be the pointer to shot_tails, because it’s time to swap the write pointers. We’ll be calling row_addr again, too, so we’ll save this off here and keep it on the stack until the very end of the iteration now.
.checkdel:
ex (sp),hl ; Now consider tails
push bc
We do need to make sure that the tail of the shot is still in the playfield before deciding to erase it. This happens even if the top is already off the playfield: in this way we’ll gradually delete shots that are out of play so it looks like they fly off instead of just vanishing.
1 ld a,(ix)
add 6
cp 8
jr c,.checkhit
Shots are 6 pixels tall and our Y value has the value that held after the shots were all moved up by the code above; The pixels we want to erase are thus 6 down from the recorded value. This works broadly like it did for the shot heads, but we keep our shifted row from above to hit the tail.
call row_addr
ld a,(ix+16)
rrca
rrca
rrca
and $1f
add e
ld (hl),a
inc hl
ld (hl),d
inc hl
Now it’s time to process collisions! We haven’t actually looked into the rendering code that sets shot_collide yet, but we do know that the value will be zero if no collision happened with this shot, and that’s very easy to check.
.checkhit:
ld a,(ix+32)
or a
jr z,.nextshot
If there is a collision, we need to delete the other four pixels of last frame’s shot. We’ve already not added it to shot_heads.
ld a,(ix) ; Delete the rest of the shot too
add 2 ; Don't bother erasing the head we skipped
call row_addr
ld a,(ix+16)
rrca
rrca
rrca
and $1f
add e
ld e,a
ld (hl),e
inc hl
ld (hl),d
inc hl
inc d ; Advance first row the easy way
inc d ; Advance the next row the hard way
ld a,7
and d
jr nz,1F
ld a,e
add 32
ld e,a
jr c,1F
ld a,d
sub 8
ld d,a
1 ld (hl),e ; Write result to shot_tails array
inc hl
ld (hl),d
inc hl
Yeah, that code could probably be cleaner, and there’s a lot inlined that I could take out into their own function, but let’s just say I knew I had the space for inlining and wanted to be fast and not that I was kind of getting tired of this function and started copy-pasting in code from the earlier parts of this series.
There’s two remaining things to do with a shot that collided: grant the point for it (preserving the register it trashes that we need), and then clearing out the colliding shot and marking it for deletion.
push hl
call award_point
pop hl
ld (ix+32),0 ; Mark the shot as no longer colliding
ld (ix),0 ; Mark the shot for deletion/compaction
At the end of the loop we increment our iterator, restore our stack, and swap HL back to shot_heads before looping back. This is large enough that it’s out of range for DJNZ, so we’ll need to do the loopback the hard way.
.nextshot:
inc ix
pop bc
ex (sp),hl ; HL back to pointing at heads
dec b
jp nz,.shotplotlp
The very last thing we need to do—even if we had no shots to render at all—is write out the terminating null pointers into both lists.
.done: xor a
ld (hl),a
inc hl
ld (hl),a
pop hl
ld (hl),a
inc hl
ld (hl),a
ret
Now that we’ve made the list, we can work out how to process it.
Rendering the Shots and Detecting Hits
Rendering shots has two phases: erasing and drawing. These happen in different phases, and they each get their own functions. Erasing shots is much simpler, so we can cover that first.
Each entry in the shot_tails array specifies a location in screen memory to zero out, along with the byte of screen memory rendered immediately below it. Because all specified pixel rows are even, the the lower row will always be exactly 256 bytes further into memory, which we may reach via a single INC instruction. The erase_shot_tails function steps through its array, using HL as a pointer in the list and DE as the pointer to screen memory, until it reaches a point where the high byte in the array is zero (conveniently entirely out of screen memory) at which point it finishes.
erase_shot_tails:
ld hl,shot_tails
1 ld e,(hl)
inc hl
ld a,(hl)
or a
ret z
ld d,a
inc hl
xor a
ld (de),a
inc d
ld (de),a
jr 1B
Drawing is rather more complicated. We could erase bits of the shots by erasing a full byte of screen memory at a time, while we need to set only individual pixels on the shots. We also need to carry out collision detection at this time, which as we have seen bounces us through multiple levels of indirection. That will have some consequences for the details of our implementation.
We go off script almost immediately by using our DE as our source pointer instead of HL. That’s because when interacting with screen memory we’ll need the extra flexibility that HL provides. Beyond that the opening is pretty similar to erasure, where we load the starting screen address into our pointer and quit if we’ve loaded a null pointer.
draw_shots:
ld de,shot_heads
.shotlp:
;; Load next shot into HL
ld a,(de) ; Low byte of shot address
inc de
ld l,a
ld a,(de) ; High byte of shot address (0 = end)
inc de
or a
ret z
ld h,a
At this point we’ve advanced DE to point to the bitmask corresponding to the shot’s location within the screen byte. We’ll need to reload that value several times, so we will not advance DE until it’s time to move on.
Collision detection is our first stop. By ANDing the screen memory with the mask, we’ll get a zero value if the shot is advancing into empty space. If it’s nonzero, then there’s a hit.
ld a,(de) ; mask for shot location in byte
and (hl) ; Collision?
jr z,.no_collision
If there is a hit, we’ll need to set the appropriate entry in shot_collide to that value. This means advancing DE, adding the value we find there to shot_collide to get the final byte to update, setting that byte to 1, and then restoring DE and HL the way we found them.
inc de ; Point to shot index
push hl ; Save screen memory location
ld hl,shot_collide ; Add (DE) to (HL) for appropriate
ld a,(de) ; entry with 16+8-bit math
add l
jr nc,1F
inc h
1 ld l,a
ld (hl),1 ; Set shot_collide[DE] to 1
pop hl ; Return HL to screen memory location
dec de ; Point DE back to bitmask
Now it’s time to actually draw the shots. Several rendering steps before this could have erased parts (or all!) of our leftover shot from the previous frame, so we’ll be drawing the whole thing. Compositing the shot is basically collision detection in reverse: instead of ANDing the mask with the screen memory and checking for zero, we are ORing the mask with the screen memory and storing it back.
Like the erasure case, we always start on even-numbered pixels, which means that we can treat this as three groups of two instead of one group of 6. Setting up the loop is fine, and compositing the pixels is actually faster to write in assembly language than it is in English:
.no_collision:
;; Draw the entire shot in case something erased it
ld b,3
.drawlp:
ld a,(de) ; Composite shot pixel
or (hl)
ld (hl),a
inc h ; Shots always on even rows,
ld a,(de) ; so alternating advances are free
or (hl)
ld (hl),a
Then we need another more complex chunk of logic to advance from the odd row.
inc h
ld a,7
and h
jr nz,1F
ld a,l
add 32
ld l,a
jr c,1F
ld a,h
sub 8
ld h,a
1 djnz .drawlp
Since we’re looping to a terminator instead of a fixed number of times, the end of the loop is just advancing DE two bytes and looping back unconditionally.
inc de ; Advance to next shot element
inc de
jr .shotlp
Weaving It All Together
We got the rendering order nailed down before we even started properly coding the gallery: erase whatever parts of the targets and shots need erasing, draw the targets, redraw the targets, redraw the blaster, then draw the shots over the updated display. The game logic is going to be a bit more finicky. There are a lot of pieces of the system here that touch the shots array and we need to be very careful to make sure that nothing uses or persists stale or mis-assigned values. Messing this up will tend to cause collisions to do things like delete the wrong shots off the screen.
The basic steps in the game logic are straightforward: read input; update blaster, target, and shot locations; delete any shots that need deleting; generate the display lists for erasing shots and drawing them in their new locations; and spawn any new shots. The trick is sorting out the constraints between these steps so that we can get them into a reasonable order.
shot_collideshould be all zeroes at frame start. This should be easy to ensure; these values are written bydraw_shotsin the rendering step before any logic, andrecord_display_listin the frame logic zeroes out every collision it finds. By the time a shot flies to the top of the screen, it can no longer collide with anything and should guarantee a zero of its own.- Shot indices cannot mutate between
record_display_listanddraw_shots. This means we have to putcompact_shotsearlier on in the frame logic, as it’s the only operation that mutates indices. - New shots should appear the frame they are generated. This means that
new_shotshould also be beforerecord_display_list. It should also probably be aftercompact_shotsso that we have as good a chance as possible to store the new shot. - Shots should be deallocated at the first opportunity. That means that
move_autonomousshould happen beforecompact_shotsbecause moving to a Y coordinate of zero means the shot is already fully invisible. Collision is a bit trickier; the shot has been seen to collide at the point whereshot_collideis set, but we hang on to the shot at least until the shot’s full erasure is processed.
In the end, our main program loop will look something like this:
;; Initialize
call clear_bss
call create_sprites
call init_game_state
call draw_main_screen
main: halt
;; Render
call erase_targets
call erase_shot_tails
call draw_targets
call update_blaster
call draw_shots
;; Frame logic
call handle_input
call move_autonomous
call compact_shots
call new_shot
call record_display_list
;; End-of-frame quit check
call check_quit
jr nz,main
Putting it all together completes the project! The final code is in the GitHub repository as gallery.asm and zxsprites.asm and prebuilt versions may be downloaded from the collection on the Project Page.
Measuring Our Efficiency
The animation in the program looks pretty clean as I watch it, but it’s not easy to tell whether I’ve really got a solid 50 FPS or if I’m even seriously pushing the system. Fortunately, there’s a classic technique for measuring this without even digging under the emulator’s skin the way we did when optimizing C64 BASIC code in one of our recent interludes. The main insight here is that most of our trouble has come from the fact that the screen frame starts drawing before we’ve finished doing our actual rendering work. We can take advantage of this: By turning the border one color at the start of the frame and another once the frame is done, we can produce what is essentially an on-screen meter showing how much of the frame is spent processing. We can even do this multiple times to split it up; for my test I turned the border cyan for the rendering phase and magenta for the frame processing. The results are pretty reassuring.

The rendering part tends to stay pretty much near the top; any tearing we encounter is pretty much guaranteed to only be near the very top of the screen. On the other hand, the frame logic has enormous spikes whenever we actually hit a target.
Are we paying a cost for our inefficient shot array management from before? We are not! That code is actually faster when shots are being removed, and shots leaving the screen from the top don’t produce the spikes. This is actually the cost of reprinting the scoring header after points are awarded. Even with those spikes, though, we do still seem to be staying within a single frame, and this game ends up running at a rock-solid 50 FPS.
Taking Stock
I sort of expected to blast through this project as an afterthought while talking about spritework on the ZX Spectrum. That… didn’t really work out. This has ended up being the most involved port since the original Atari ones.
Some of that was because of the much more aggressive attention I paid to instruction selection and micro-level code organization on the Z80 this time. I focused really heavily on the IX register early on in the project, and we got another experiment in this article too: using IX as a structure-of-arrays system instead of a more traditional array-of-structures. Overall, I think this worked pretty well, but I also think I made my life a lot harder on myself than I needed to. I should have either taken over IY temporarily, or used an array-of-structures that meant that I didn’t have to keep adding vast strides to HL or DE when syncing values across the overall shot array. All the same, it works, and we’ve determined after measurement that it’s more than fast enough. There are definitely some lessons learned over the course of this project that I’ll be able to take to other systems.
But I’ll need time to digest that, and this was all a bit of a detour anyway; we actually haven’t finished our tour of the ZX Spectrum yet. Next week, we’ll return to that.