Starting the ZX Spectrum Shooting Gallery In Earnest

07:30 PM · Jul 18, 2026

This week and next we’ll work through the port of my shooting gallery Rosetta-stone program to the ZX Spectrum.

It’s been a long road. We started out knowing how to control text and bitmap output and a decent handle on I/O. From there we designed a reasonably efficient blitting system that operated on 16×16 “sprites” that we carefully prepared for efficient blitting. Even this wasn’t enough to keep our graphics updates smooth, though, so we then had to carefully arrange our bitmap operations to tear instead of flicker. I’ve been putting this project together bit by bit alongside these earlier articles but not really going into it. This week I’ll be covering pretty much the entire program except for the sprite renderer (which we worked through in previous weeks) and the shot management. The shots are complicated enough to be an entire week’s post on their own.

Setup

Most of the work here is routine, but there’s a few spins I got to put on it that I hadn’t done in previous projects. I’ve expanded my usual memory allocation technique in Sjasm to be more predictable and expandable, and the way that color and graphic information on the Spectrum are entirely independent means that the basic screen setup doesn’t really track any of the other ports either.

Managing RAM

I’m actually managing a lot more RAM in this program than usual thanks to the demands of the renderer. At this point I have a well-established technique for setting up a sort of unofficial BSS segment of uninitialized RAM: I define a label bss_start at the very end of the program, and then at the start of the program I set up variable allocation with a MAP bss_start directive. For this project I added two enhancements to this: I added some generic code to initialize all the “uninitialized” variable space to zero, and I let the assembler report how much total RAM I was using. (I was concerned that I might not fit on a 16KB Spectrum given how free I was being with RAM here.)

clear_bss:
        xor     a
        ld      hl,bss_start
        ld      de,bss_start+1
        ld      bc,bss_end - bss_start - 1
        ld      (hl),a
        ldir
        ret

Both of these enhancements are built out of the same adjustment: I allocate a bss_end variable at the very end of the program. The size doesn’t matter; the label it’s allocated to will be one past the last “real” variable. Now I can compute BSS size at assemble time with a simple subtraction, or iterate until reaching it as an end point, or (for my 16KB compatibility case) just look at the listing file to see what address it was assigned. In my case it was assigned a value under $7900; 16K Spectrums will be fine.

The Playfield and Scoring

Our main playfield is defined by large blocks of solid color. On the Spectrum, this translates to a completely empty bitmap backed by large blocks of a single backgroun dcolor in the color atteribue map. There’s one extra, less obvious thing going on here too: our sprite renderer (and the shot renderer alongside it) never touch the attribute map at all. That means that the playfield is responsible not only for the backdrop, but also for the colors of the animated elements themselves. I took inspiration from the TMS9918 ports for the general shape, since it shares the Spectrum’s oveall 256×192 resolution.

  • The bulk of the playfield is the region where the shots and targets move. This region is red graphics on a black background.
  • Below that is 2 color rows of yellow graphics on a black background, where the blaster truck gets drawn.
  • Below that is 3 color rows of green background for the ground itself, which never has any graphics drawn in it.
  • At the very top is a single row of black text on a light gray background for the score display.

Every port before this one so far has used a dividing line to distinguish the header from the rest of the playfield; the way that attribute cells work on the Spectrum left me unable to do this in a way that I felt looked right, so I shifted the background color instead.

Setting all this up is pretty straightforward, and I can lean on the clrto and print functions I defined as part of the text mode investigation to handle the score data and the main playfield. Everything else is just bulk copies into the attribute RAM.

draw_main_screen:
        ld      a,$02                   ;; Black screen, red text
        call    clrto                   ;; for main screen (shots/targets)
        ld      hl,$5a60                ;; Blaster track
        ld      a,$06
        ld      b,64
1       ld      (hl),a
        inc     hl
        djnz    1B
        ld      a,$20                   ;; Ground
        ld      b,96
1       ld      (hl),a
        inc     hl
        djnz    1B
        ld      hl,$5800                ;; Header
        ld      a,$38
        ld      b,32
1       ld      (hl),a
        inc     hl
        djnz    1B
        ld      hl,header
        jp      print

The header string relies heavily on the text control codes we covered in that article as well, setting the text and background color and setting the cursor position before drawing the score. In fact, since we have this score as part of the string, we can use those characters themselves as our score storage:

header: db      $10,0,$11,7,$16,0,20,"SCORE: "
score:  db      "----",$ff

That does mean we have to set the score to zero on program start, though, especially since we might be running the same program more than once and need to reinitialize:

        ld      a,$30
        ld      hl,score
        ld      b,4
1       ld      (hl),a
        inc     hl
        djnz    1B

The Rest of the Variables

The score is stored in with the playfield data and gets its own special initialization, but everything else is allocated normally. I do bring in a few extra constraints from the asset baking, though; I have to end my actual program with the graphics data and begin with the reversed versions of any such sprites. I have one:

reversed_blaster # 32

        ld      hl,gfx+32
        ld      de,reversed_blaster
        call    reverse_sprite

That means that all my 16×16 sprites will be contiguous in RAM, which in turn means that when I preprocess all the graphics those can be done in one continuous call too:

target_gfx # 192
blaster_lgfx # 192
blaster_rgfx # 192
blank_gfx # 192

        ;; Create shifted versions of all sprites. The clear_bss
        ;; call has already generated the blank_gfx asset.
        ld      hl,gfx
        ld      ix,target_gfx
        ld      b,3
        call    shift_sprites

Thanks to the earlier generic BSS code, blank_gfx has already been cleared for us. We can erase any sprite by drawing that as a sprite in our target region.

Finally, we have our game logic. There’s a bunch of variables tracking where the game elements are and where they were, as well as one byte to track shot inputs.

shot_requested # 1
blaster_x # 1
blaster_old_x # 1
blaster_facing # 1
blaster_old_facing # 1
target_y # 1
target_x # 3
target_old # 2

Most of these need real values:

        ld      a,64                    ; Blaster is midscreen
        ld      (blaster_x),a
        ld      (blaster_old_x),a
        ld      a,1                     ; Facing left
        ld      (blaster_facing),a
        ld      (blaster_old_facing),a
        ld      a,28                    ; Targets evenly spaced
        ld      (target_x),a
        add     32
        ld      (target_x+1),a
        add     32
        ld      (target_x+2),a
        ld      a,20                    ; Targets near top
        ld      (target_y),a

If we were really tracking modern behavior here, we’d have a parallel “initial data” block that we could just bulk-copy into place; we see things like that on ROM-based systems targeted by C compilers, like the GBA or Sega Genesis. As it is, I think it’s marginally cleaner to have an “Initialize” function in a system like this and just use bespoke code. It’s larger, but it’s harder to accidentally mess up.

Our remaining value here, target_old, is actually a pointer value, initially NULL. We’ll see it in action next.

In-Game Rendering

The draw_sprite command we developed two weeks ago is still our workhorse here. It’s also capable of erasing sprites if we feed it the blank_gfx shape, but as we saw last week this doesn’t scale well. It is still good enough—with some caveats—to handle the blaster, but the targets need special handling.

Drawing the Targets

The big insight, last week, was that we didn’t need to store the old target locations, because they mostly erased their former selves as they were redrawn. The exception was if they moved up or down, where there would then be two rows worth of graphics to clean up. Instead of tracking this one sprite by sprite I decided it would be easier to just blank the whole rows with two LDIR instructions. The target_old variable is 0 if we have nothing to erase; otherwise it clears the row that starts at that address. Happily, we also know that target addresses are always even, so the row below us is always in the same cell and the start of our second row to erase will always be 256 bytes away from the first.

erase_targets:
        ld      hl,(target_old)         ; Get first row
        ld      a,h                     ; Is it 0? (really, 0-255)
        or      a
        ret     z                       ; Do nothing if so
        push    hl                      ; Otherwise save initial point...
        xor     a                       ; Zero the first byte...
        ld      (hl),a
        ld      d,h                     ; And copy it 31 more times for first row
        ld      e,l
        inc     de
        ld      bc,31
        ldir
        pop     hl                      ; Restore the start point
        ld      d,h                     ; And copy the 32 zeros one row down
        ld      e,l
        inc     d
        ld      bc,32
        ldir
        ret

Drawing is less exciting, but the self-erasing part of it makes it even simpler than our original sprite test code; we can simply load the shared Y coordinate and then load up the individual X coordinates in turn, stamping down the sprite in each place:

draw_targets:
        ld      a,(target_y)            ; Load shared Y coordinate
        ld      d,a                     ; and store it where draw_sprite expects
        ld      b,3                     ; 3 sprites to draw
        ld      hl,target_x             ; HL points to X-coord array
1       ld      e,(hl)                  ; Load next X coord into place
        inc     hl                      ; Advance pointer
        push    hl                      ; Save all the registers we'll trash
        push    de
        push    bc
        ld      hl,target_gfx           ; Load the actual graphic shape in HL...
        call    draw_sprite             ; ...and draw it at (E,D)
        pop     bc                      ; Bring back our registers
        pop     de
        pop     hl
        djnz    1B                      ; Loop until done
        ret

Drawing the Blaster

The blaster is more interesting when it’s the higher-res blaster-truck image, because we have to track its facing and flip it around as needed. I first used this graphic on the Amiga, and part of the logic there was that the blaster’s “X coordinate” was actually the coordinate of the cannon: that is, it’s where the shots would appear. That’s a pretty nice property to have, and I kept it as best I could. Looking at my graphics, the blaster’s aperture is at bit $0020 when the truck is facing left (blaster_facing=1), and at bit $0400 when it it’s facing right (blaster_facing=0). This ends up being more awkward than on the Amiga because our graphic resolution is twice the resolution of our sprite placement, and the aperture is on an even or odd pixel depending on the facing. That means the motion of the blaster aperture will not be perfectly smooth when we change directions, but it’s not too bad because at least the directions will stay constant.

I match as best I can; when facing left, I subtract 5 from blaster_x before drawing, and when facing right, I subtract 2. That means the cannon itself is one pixel “too far” to the right when facing right, but that does at least keep the blaster from jerking backwards when turning.

Drawing the blaster is a matter of erasing the old version and drawing the new one. We need the last frame’s info for this because which parts get erased is unpredictable if we’ve flipped orientation. Of course, if we haven’t moved at all, that isn’t a problem, so that’s the first thing we check:

update_blaster:
        ld      a,(blaster_old_x)
        ld      hl,blaster_x
        cp      (hl)
        jr      z,.draw_blaster

Now we need to load our old X position into the E register after accounting for facing. This is a little awkward because it’s in A right now, our only option for 8-bit math, so I check the facing by bouncing it through HL with a bit check:

        ld      hl,blaster_old_facing
        sub     2
        bit     0,(hl)
        jr      z,1F
        sub     3
1       ld      e,a

The Y coordinate and graphic, at least, are constant, so that part of erasing the blaster is easy.

        ld      d,152
        ld      hl,blank_gfx
        call    draw_sprite

I need to pick the right graphics in HL for the drawing part, though, so when checking the main graphics I end up starting by assuming that I’ll be facing left, and then correcting HL and E if I’m wrong.

.draw_blaster:
        ld      a,(blaster_x)
        sub     5
        ld      e,a
        ld      d,152
        ld      a,(blaster_facing)
        ld      hl,blaster_lgfx
        or      a
        jr      nz,1F
        ld      hl,blaster_rgfx
        ld      a,e
        add     3
        ld      e,a
1       call    draw_sprite
        ret

A little more juggling than I’d like, but not too bad.

Game Logic

As a rule, the core game logic doesn’t change much from platform to platform. However, it’s often the case that the game logic in this project is so tightly tied to the graphics systems on the platform that they end up sort of tied together at the hip anyway. With all the unusual approaches we’ve taken to screen rendering on the Spectrum, that ends up quite true here. Input tracking needs to precompute rendering addresses along the way, and I mostly found myself interleaving those things with the logic because I could do it “in passing”.

All the same, there are very few surprises here in the main game elements.

Reading Player Input

We covered the basics of keyboard and joystick input in both the BASIC and machine language cases already, and they’re pretty close to each other. We made a simple read routine for the Kempston joystick back then, and then we built a keyboard reader for our sprite tests. That routine is generic enough that we can just use it outright.

The bizarre QA/OP split, with SPACE as a normal-sized key in the lower-right of the Spectrum keyboard, turns out to be a neat match to our actual controls here: the left hand moves the targets and the right hand controls the blaster. The basic motion is very simple, because we can just take our read_key function from two weeks ago and apply its delta-y in H and delta-x in L and then bounds-check the results.

More interesting is that we also need to check for a key to quit the game, and we have a couple of options for that: BREAK is the obvious one, but pressing the Escape key in the FUSE emulator renders as the EDIT key. We’ll check both, and, happily, there’s some commonality: BREAK appears as CAPS SHIFT-SPACE, and EDIT as CAPS SHIFT-1, so we can start with a shift check and only then move on to the keys themselves:

check_quit:
        ld      bc,$fefe                ; Check SHIFT
        in      a,(c)
        and     1
        ret     nz                      ; If no SHIFT, done.
        ld      b,$f7                   ; Check EDIT
        in      a,(c)
        and     1
        ret     z                       ; If EDIT/ESC, done.
        ld      b,$7f                   ; Check BREAK
        in      a,(c)
        and     1                       ; Return Z/NZ on BREAK
        ret

One fun side effect of this is that when we do quit, we quit into BASIC, which starts scanning the keys again itself, so it’s pretty likely that quitting with BREAK will end not with 0 OK but with L BREAK into program.

Processing Player Input

There are a number of things I do in tandem with reading the input as part of the same handle_input function. I start by stashing the previous frame’s values for the blaster state; we need the old values for erasing and the new values for the next draw. Those are, as you recall, declared in a row like this:

blaster_x # 1
blaster_old_x # 1
blaster_facing # 1
blaster_old_facing # 1

And that lets us do the backup with a simple loop to kick off input handling:

handle_input:
        ld      hl,blaster_x
        ld      b,2
1       ld      a,(hl)
        inc     hl
        ld      (hl),a
        inc     hl
        djnz    1B

Now we can read the keyboard, putting the delta-x value into L and the delta-Y into H. We exiting with the fire state in the accumulator, with 0 meaning fire is pressed and 1 means it is not. This is inconvenient enough that I store out a real value into a shot_requested variable for later use before doing anything else.

        call    read_keys
        xor     1                       ; make '1' mean 'shot requested'
        ld      (shot_requested),a

Now I can process and bounds-check the blaster’s motion…

        ld      de,blaster_x            ; Process left and right
        ld      a,(de)
        add     l
        cp      4                       ; Bounds-check
        jr      z,1F
        cp      123
        jr      nz,2F
1       sub     l
2       ld      (de),a

… and then we need to update the facing. blaster_facing is 1 if we’re moving left and 0 if we’re moving right, so I convert the sign-bit of L into the facing value but only if we pressed a direction so that the blaster stays still if we release all keys.

        ld      a,l                     ; Did we press a direction at all?
        or      a
        jr      z,1F
        rlca                            ; If so, convert sign to 0/1 and
        and     1                       ; use it as our blaster facing
        ld      (blaster_facing),a
1

Moving the targets is very similar, though since we move two at a time here we need to apply (or cancel out) H twice:

        ld      de,target_y
        ld      a,(de)
        add     h
        add     h
        cp      $08                     ; Bounds-check the targets
        jr      z,1F
        cp      $8a
        jr      nz,2F
1       sub     h
        sub     h
2       ld      (de),a

But here is also where we need to handle the optimized renderer for erasing leftover target graphics. I’ve built a row_addr function that is basically a stripped-down version of PLOT from my bitmap work earlier; it takes a Y coordinate in A and returns the address of the first byte in that pixel row. From this we can compute the necessary delta from our new target by looking at the delta-y value in H, then compute from there.

        ld      de,0                    ; Default to no clearing
        ld      l,a                     ; Store new target_y in L
        ld      a,h                     ; Was DY 0?
        or      a
        jr      z,1F                    ; If so, just write out the 0
        ld      a,l                     ; Otherwise, put target_y back in a...
        jp      m,.up                   ; Subtract 2 if we moved down...
        sub     2
        jr      2F
.up:    add     16                      ; and add 16 if we moved up...
2       call    row_addr                ; and compute the clear address from that.
1       ld      (target_old),de
        ret

This paves the way for the erase_targets function we saw above.

Handling Autonomous Motion

I went out of my way to make this as simple as possible. We move each element of target_x left 1, wrapping around if it’s far enough off-screen, and we move each shot up 2 if it isn’t already at location 0.

        ld      hl,target_x
        ld      b,3
1       ld      a,(hl)
        dec     a
        cp      -32                     ; Time to wrap around?
        jr      nz,2F
        ld      a,128                   ; If so, bring it around
2       ld      (hl),a
        inc     hl
        djnz    1B

Shots will be a little more interesting, but the part where they go up won’t be.

Awarding Points

Our score is stored as, essentially, a four-character string, so we can’t do normal math to it. Actually awarding a point is very similar to manually propagating a carry through multiple bytes, just with weird definitions of overflow and zero values.

award_point:
        ld      hl,score+3
        ld      b,4
.lp:    ld      a,(hl)
        inc     a
        cp      $3a
        jr      z,.carry
        ld      (hl),a
        jr      .disp
.carry: ld      (hl),$30
        dec     hl
        djnz    .lp
.disp:  ld      hl,header
        jp      print                   ; Tail call into PRINT

This broadly tracks the technique I used, albeit with the 6502, on the Atari 800 port. In that case we had a true text mode, so we could edit the text buffer directly. No such luck here.

What’s Left

We now have a gallery! We can drive our blaster and place our targets, and they fly around on their own. What we do not have is a shooting gallery, and that’s because shots are more complicated than all the rest of this logic combined. They must be created and destroyed, they use a different coordinate system than our sprites and need an entirely different rendering technique, and they need to participate in collision detection systems as well as help govern their own autofire mechanism.

All the same, we should be able to wrap this up next week, as a worked example of almost everything the ZX Spectrum has to offer. See you then!