Skip to content
Game 1 Unit 2 of 64 1 hr learning time

Claiming Cells

Press Space to claim territory. Cells turn your colour. Take turns with a friend.

3% of Ink War

The board is ready. Now it’s time to claim it.

This unit adds the core gameplay: press Space to claim the cell under your cursor. The cell turns your colour. Then it’s the other player’s turn. Two players, one keyboard, taking turns until the board is full.

Run It

Assemble and run:

pasmonext --tapbas inkwar.asm inkwar.tap

Unit 2 Screenshot

The board looks the same as before - but now press Space.

The cell under your cursor turns red (bright red, actually). A short rising tone plays. Move the cursor and press Space again - now it turns blue. You’re Player 2.

Keep pressing Space. Red, blue, red, blue. The board fills up with colour.

Hand the keyboard to a friend. Now you’re playing Ink War.

Try This: Change Player Colours

Edit P1_ATTR and P2_ATTR to use different colours:

P1_ATTR     equ     %01100000       ; Yellow paper + BRIGHT
P2_ATTR     equ     %01011000       ; Cyan paper + BRIGHT

Remember the colour values:

  • 0 = black, 1 = blue, 2 = red, 3 = magenta
  • 4 = green, 5 = cyan, 6 = yellow, 7 = white

The PAPER bits are 5-3, so shift your colour left by 3. Yellow (6) becomes 110 in bits 5-3, plus BRIGHT (bit 6) = %01110000… wait, that’s white. Let me recalculate.

Actually: %01100000 = BRIGHT + paper 4 (green). For yellow: %01110000 = BRIGHT + paper 6. Try different combinations and see what looks good.

Try This: Change the Sound

Make the claim sound descending instead of ascending:

sound_claim:
            ld      hl, 100         ; Starting pitch (higher)
            ld      b, 20

.loop:
            ; ... same tone generation ...

            ; Decrease pitch (add to delay)
            ld      de, 20
            add     hl, de          ; Changed from sbc to add

            djnz    .loop
            ret

Or make it longer, shorter, different starting pitch. The beeper is primitive but expressive.

What You’ve Learnt

  • Game state arrays - defs 64, 0 reserves 64 bytes initialised to zero
  • Turn-based logic - Track current player, switch after valid moves
  • Input validation - Check cell state before allowing claims
  • XOR toggling - xor 3 switches between 1 and 2
  • Beeper sound - Toggle bit 4 of port $FE at varying speeds for pitch

What’s Next

In Unit 3, you’ll customise the game’s appearance - different colours, different cursor style, making it your own. You’ll learn exactly how attribute bytes work by changing them.

What Changed

Unit 1 → Unit 2
+307-74
11 ; ============================================================================
2-; INK WAR - Unit 1: Hello Spectrum
2+; INK WAR - Unit 2: Claiming Cells
33 ; ============================================================================
4-; A territory control game for the ZX Spectrum
5-; This scaffold provides: board display, cursor, keyboard movement
4+; Two players take turns claiming cells on the board.
5+; Press Space to claim the cell under the cursor.
66 ;
7-; Controls: Q=Up, A=Down, O=Left, P=Right
7+; Controls: Q=Up, A=Down, O=Left, P=Right, SPACE=Claim
88 ; ============================================================================
99
1010 org 32768
...
2222 BORDER_ATTR equ %00000000 ; Black on black (border)
2323 EMPTY_ATTR equ %00111000 ; White paper, black ink (empty cell)
2424 CURSOR_ATTR equ %10111000 ; White paper, black ink + FLASH
25+P1_ATTR equ %01010000 ; Red paper, black ink + BRIGHT (Player 1)
26+P2_ATTR equ %01001000 ; Blue paper, black ink + BRIGHT (Player 2)
27+P1_CURSOR equ %11010000 ; Red paper + FLASH (Player 1 cursor on own cell)
28+P2_CURSOR equ %11001000 ; Blue paper + FLASH (Player 2 cursor on own cell)
2529
2630 ; Keyboard ports (active low)
2731 KEY_PORT equ $fe
28-ROW_QAOP equ $fb ; Q W E R T row (bits: T R E W Q)
29-ROW_ASDF equ $fd ; A S D F G row (bits: G F D S A)
30-ROW_YUIOP equ $df ; Y U I O P row (bits: P O I U Y)
32+ROW_QAOP equ $fb ; Q W E R T row
33+ROW_ASDF equ $fd ; A S D F G row
34+ROW_YUIOP equ $df ; Y U I O P row
35+ROW_SPACE equ $7f ; SPACE SYM M N B row
36+
37+; Game states
38+STATE_EMPTY equ 0
39+STATE_P1 equ 1
40+STATE_P2 equ 2
3141
3242 ; ----------------------------------------------------------------------------
3343 ; Entry Point
...
3545
3646 start:
3747 call init_screen ; Clear screen and set border
48+ call init_game ; Initialise game state
3849 call draw_board ; Draw the game board
3950 call draw_cursor ; Show cursor at starting position
4051
...
4253 halt ; Wait for frame (50Hz timing)
4354
4455 call read_keyboard ; Check for input
45- call move_cursor ; Update cursor if moved
56+ call handle_input ; Process movement or claim
4657
4758 jp main_loop ; Repeat forever
4859
4960 ; ----------------------------------------------------------------------------
5061 ; Initialise Screen
5162 ; ----------------------------------------------------------------------------
52-; Clears the screen to black and sets border colour
5363
5464 init_screen:
55- ; Set border to black
56- xor a ; A = 0 (black)
57- out (KEY_PORT), a ; Set border colour
65+ xor a
66+ out (KEY_PORT), a ; Black border
5867
59- ; Clear attributes to black
60- ld hl, ATTR_BASE ; Start of attributes
68+ ld hl, ATTR_BASE
6169 ld de, ATTR_BASE+1
62- ld bc, 767 ; 768 bytes - 1
63- ld (hl), 0 ; Black on black
64- ldir ; Fill all attributes
70+ ld bc, 767
71+ ld (hl), 0
72+ ldir
73+
74+ ret
75+
76+; ----------------------------------------------------------------------------
77+; Initialise Game State
78+; ----------------------------------------------------------------------------
79+
80+init_game:
81+ ; Clear board state (all empty)
82+ ld hl, board_state
83+ ld b, 64
84+ xor a
85+.clear_loop:
86+ ld (hl), a
87+ inc hl
88+ djnz .clear_loop
89+
90+ ; Player 1 starts
91+ ld a, 1
92+ ld (current_player), a
93+
94+ ; Cursor at top-left
95+ xor a
96+ ld (cursor_row), a
97+ ld (cursor_col), a
6598
6699 ret
67100
68101 ; ----------------------------------------------------------------------------
69102 ; Draw Board
70103 ; ----------------------------------------------------------------------------
71-; Draws the 8x8 game board with border
72104
73105 draw_board:
74- ; Draw border (10x10 area, black)
75- ; Border is already black from init, so we just draw the cells
76-
77- ; Draw the 8x8 playing field (white cells)
78- ld b, BOARD_SIZE ; 8 rows
79- ld c, BOARD_ROW ; Start at row 8
106+ ld b, BOARD_SIZE
107+ ld c, BOARD_ROW
80108
81109 .row_loop:
82110 push bc
83111
84- ld b, BOARD_SIZE ; 8 columns
85- ld d, BOARD_COL ; Start at column 12
112+ ld b, BOARD_SIZE
113+ ld d, BOARD_COL
86114
87115 .col_loop:
88116 push bc
89117
90- ; Calculate attribute address: ATTR_BASE + row*32 + col
91- ld a, c ; Row
118+ ; Calculate attribute address
119+ ld a, c
92120 ld l, a
93121 ld h, 0
94- add hl, hl ; *2
95- add hl, hl ; *4
96- add hl, hl ; *8
97- add hl, hl ; *16
98- add hl, hl ; *32
99- ld a, d ; Column
122+ add hl, hl
123+ add hl, hl
124+ add hl, hl
125+ add hl, hl
126+ add hl, hl
127+ ld a, d
100128 add a, l
101129 ld l, a
102130 ld bc, ATTR_BASE
103- add hl, bc ; HL = attribute address
131+ add hl, bc
104132
105- ld (hl), EMPTY_ATTR ; Set to white (empty cell)
133+ ld (hl), EMPTY_ATTR
106134
107135 pop bc
108- inc d ; Next column
136+ inc d
109137 djnz .col_loop
110138
111139 pop bc
112- inc c ; Next row
140+ inc c
113141 djnz .row_loop
114142
115143 ret
...
117145 ; ----------------------------------------------------------------------------
118146 ; Draw Cursor
119147 ; ----------------------------------------------------------------------------
120-; Shows the cursor at current position with FLASH attribute
148+; Shows cursor with appropriate colour based on cell state
121149
122150 draw_cursor:
123- ; Calculate attribute address for cursor position
151+ ; Get cell state at cursor position
152+ call get_cell_state ; A = state at cursor
153+
154+ ; Determine cursor attribute based on state
155+ cp STATE_P1
156+ jr z, .dc_p1
157+ cp STATE_P2
158+ jr z, .dc_p2
159+
160+ ; Empty cell - use standard cursor
161+ ld a, CURSOR_ATTR
162+ jr .dc_set
163+
164+.dc_p1:
165+ ld a, P1_CURSOR
166+ jr .dc_set
167+
168+.dc_p2:
169+ ld a, P2_CURSOR
170+
171+.dc_set:
172+ push af ; Save attribute
173+
174+ ; Calculate attribute address
124175 ld a, (cursor_row)
125- add a, BOARD_ROW ; Add board offset
176+ add a, BOARD_ROW
126177 ld l, a
127178 ld h, 0
128- add hl, hl ; *32
179+ add hl, hl
129180 add hl, hl
130181 add hl, hl
131182 add hl, hl
132183 add hl, hl
133184 ld a, (cursor_col)
134- add a, BOARD_COL ; Add board offset
185+ add a, BOARD_COL
135186 add a, l
136187 ld l, a
137188 ld bc, ATTR_BASE
138189 add hl, bc
139190
140- ld (hl), CURSOR_ATTR ; Set FLASH attribute
191+ pop af ; Restore attribute
192+ ld (hl), a
141193
142194 ret
143195
144196 ; ----------------------------------------------------------------------------
145197 ; Clear Cursor
146198 ; ----------------------------------------------------------------------------
147-; Removes cursor flash from current position
199+; Restores cell to its proper colour (empty, P1, or P2)
148200
149201 clear_cursor:
150- ; Calculate attribute address for cursor position
202+ ; Get cell state
203+ call get_cell_state
204+
205+ ; Determine attribute based on state
206+ cp STATE_P1
207+ jr z, .cc_p1
208+ cp STATE_P2
209+ jr z, .cc_p2
210+
211+ ; Empty cell
212+ ld a, EMPTY_ATTR
213+ jr .cc_set
214+
215+.cc_p1:
216+ ld a, P1_ATTR
217+ jr .cc_set
218+
219+.cc_p2:
220+ ld a, P2_ATTR
221+
222+.cc_set:
223+ push af
224+
225+ ; Calculate attribute address
151226 ld a, (cursor_row)
152227 add a, BOARD_ROW
153228 ld l, a
...
164239 ld bc, ATTR_BASE
165240 add hl, bc
166241
167- ld (hl), EMPTY_ATTR ; Remove FLASH, back to white
242+ pop af
243+ ld (hl), a
244+
245+ ret
246+
247+; ----------------------------------------------------------------------------
248+; Get Cell State
249+; ----------------------------------------------------------------------------
250+; Returns: A = state at cursor position (0=empty, 1=P1, 2=P2)
168251
252+get_cell_state:
253+ ld a, (cursor_row)
254+ add a, a ; *2
255+ add a, a ; *4
256+ add a, a ; *8
257+ ld hl, board_state
258+ ld b, 0
259+ ld c, a
260+ add hl, bc
261+ ld a, (cursor_col)
262+ ld c, a
263+ add hl, bc ; HL = &board_state[row*8+col]
264+ ld a, (hl)
169265 ret
170266
171267 ; ----------------------------------------------------------------------------
172268 ; Read Keyboard
173269 ; ----------------------------------------------------------------------------
174-; Checks Q/A/O/P keys and sets direction flags
175270
176271 read_keyboard:
177272 xor a
178- ld (key_pressed), a ; Clear previous
273+ ld (key_pressed), a
179274
180- ; Check Q (up) - port $FBFE, bit 0
275+ ; Check Q (up)
181276 ld a, ROW_QAOP
182277 in a, (KEY_PORT)
183- bit 0, a ; Q is bit 0
278+ bit 0, a
184279 jr nz, .not_q
185- ld a, 1 ; Up
280+ ld a, 1
186281 ld (key_pressed), a
187282 ret
188283 .not_q:
189- ; Check A (down) - port $FDFE, bit 0
284+ ; Check A (down)
190285 ld a, ROW_ASDF
191286 in a, (KEY_PORT)
192- bit 0, a ; A is bit 0
287+ bit 0, a
193288 jr nz, .not_a
194- ld a, 2 ; Down
289+ ld a, 2
195290 ld (key_pressed), a
196291 ret
197292 .not_a:
198- ; Check O (left) - port $DFFE, bit 1
293+ ; Check O (left)
199294 ld a, ROW_YUIOP
200295 in a, (KEY_PORT)
201- bit 1, a ; O is bit 1
296+ bit 1, a
202297 jr nz, .not_o
203- ld a, 3 ; Left
298+ ld a, 3
204299 ld (key_pressed), a
205300 ret
206301 .not_o:
207- ; Check P (right) - port $DFFE, bit 0
302+ ; Check P (right)
208303 ld a, ROW_YUIOP
209304 in a, (KEY_PORT)
210- bit 0, a ; P is bit 0
305+ bit 0, a
211306 jr nz, .not_p
212- ld a, 4 ; Right
307+ ld a, 4
213308 ld (key_pressed), a
309+ ret
214310 .not_p:
311+ ; Check SPACE (claim)
312+ ld a, ROW_SPACE
313+ in a, (KEY_PORT)
314+ bit 0, a
315+ jr nz, .not_space
316+ ld a, 5
317+ ld (key_pressed), a
318+.not_space:
215319 ret
216320
217321 ; ----------------------------------------------------------------------------
218-; Move Cursor
322+; Handle Input
219323 ; ----------------------------------------------------------------------------
220-; Moves cursor based on key_pressed value
221324
222-move_cursor:
325+handle_input:
223326 ld a, (key_pressed)
224327 or a
225- ret z ; No key pressed
328+ ret z ; No key
226329
227- call clear_cursor ; Remove old cursor
330+ cp 5 ; Space?
331+ jr z, try_claim
332+
333+ ; Movement key - use existing move logic
334+ call clear_cursor
228335
229336 ld a, (key_pressed)
230337
...
232339 jr nz, .not_up
233340 ld a, (cursor_row)
234341 or a
235- jr z, .done ; Already at top
342+ jr z, .done
236343 dec a
237344 ld (cursor_row), a
238345 jr .done
...
241348 jr nz, .not_down
242349 ld a, (cursor_row)
243350 cp BOARD_SIZE-1
244- jr z, .done ; Already at bottom
351+ jr z, .done
245352 inc a
246353 ld (cursor_row), a
247354 jr .done
...
250357 jr nz, .not_left
251358 ld a, (cursor_col)
252359 or a
253- jr z, .done ; Already at left
360+ jr z, .done
254361 dec a
255362 ld (cursor_col), a
256363 jr .done
...
259366 jr nz, .done
260367 ld a, (cursor_col)
261368 cp BOARD_SIZE-1
262- jr z, .done ; Already at right
369+ jr z, .done
263370 inc a
264371 ld (cursor_col), a
265372
266373 .done:
267- call draw_cursor ; Draw new cursor
374+ call draw_cursor
375+ ret
376+
377+; ----------------------------------------------------------------------------
378+; Try Claim Cell
379+; ----------------------------------------------------------------------------
380+
381+try_claim:
382+ ; Check if cell is empty
383+ call get_cell_state
384+ or a
385+ ret nz ; Not empty - can't claim
386+
387+ ; Claim the cell
388+ call claim_cell
389+
390+ ; Play success sound
391+ call sound_claim
392+
393+ ; Switch player
394+ ld a, (current_player)
395+ xor 3 ; Toggle between 1 and 2
396+ ld (current_player), a
397+
398+ ; Redraw cursor with new state
399+ call draw_cursor
400+
401+ ret
402+
403+; ----------------------------------------------------------------------------
404+; Claim Cell
405+; ----------------------------------------------------------------------------
406+; Claims current cell for current player
407+
408+claim_cell:
409+ ; Calculate board state index
410+ ld a, (cursor_row)
411+ add a, a
412+ add a, a
413+ add a, a
414+ ld hl, board_state
415+ ld b, 0
416+ ld c, a
417+ add hl, bc
418+ ld a, (cursor_col)
419+ ld c, a
420+ add hl, bc ; HL = &board_state[row*8+col]
421+
422+ ; Set to current player
423+ ld a, (current_player)
424+ ld (hl), a
425+
426+ ; Update attribute colour
427+ push af
428+
429+ ld a, (cursor_row)
430+ add a, BOARD_ROW
431+ ld l, a
432+ ld h, 0
433+ add hl, hl
434+ add hl, hl
435+ add hl, hl
436+ add hl, hl
437+ add hl, hl
438+ ld a, (cursor_col)
439+ add a, BOARD_COL
440+ add a, l
441+ ld l, a
442+ ld bc, ATTR_BASE
443+ add hl, bc
444+
445+ pop af
446+ cp 1
447+ jr z, .player1
448+ ld (hl), P2_ATTR
449+ ret
450+.player1:
451+ ld (hl), P1_ATTR
452+ ret
453+
454+; ----------------------------------------------------------------------------
455+; Sound - Claim
456+; ----------------------------------------------------------------------------
457+; Short rising tone for successful claim
458+
459+sound_claim:
460+ ld hl, 400 ; Starting pitch
461+ ld b, 20 ; Duration
462+
463+.loop:
464+ push bc
465+ push hl
466+
467+ ; Generate tone
468+ ld b, h
469+ ld c, l
470+.tone_loop:
471+ ld a, $10 ; Speaker bit on
472+ out (KEY_PORT), a
473+ call .delay
474+ xor a ; Speaker bit off
475+ out (KEY_PORT), a
476+ call .delay
477+ dec bc
478+ ld a, b
479+ or c
480+ jr nz, .tone_loop
481+
482+ pop hl
483+ pop bc
484+
485+ ; Increase pitch (lower delay = higher frequency)
486+ ld de, 20
487+ or a
488+ sbc hl, de
489+
490+ djnz .loop
491+ ret
492+
493+.delay:
494+ push bc
495+ ld b, 5
496+.delay_loop:
497+ djnz .delay_loop
498+ pop bc
268499 ret
269500
270501 ; ----------------------------------------------------------------------------
271502 ; Variables
272503 ; ----------------------------------------------------------------------------
273504
274-cursor_row: defb 0 ; Cursor row (0-7)
275-cursor_col: defb 0 ; Cursor column (0-7)
276-key_pressed: defb 0 ; Last key: 0=none, 1=up, 2=down, 3=left, 4=right
505+cursor_row: defb 0
506+cursor_col: defb 0
507+key_pressed: defb 0
508+current_player: defb 1 ; 1 = Player 1 (Red), 2 = Player 2 (Blue)
509+board_state: defs 64, 0 ; 64 bytes, all initialised to 0
277510
278511 ; ----------------------------------------------------------------------------
279512 ; End

We added three things:

  1. Board state - An array tracking who owns each cell
  2. Claiming - Press Space to take ownership
  3. Turn switching - Players alternate automatically

Player Colours

Each player gets a distinct colour:

; Attribute colours (FBPPPIII format)
EMPTY_ATTR  equ     %00111000       ; White paper, black ink (empty cell)
CURSOR_ATTR equ     %10111000       ; White paper, black ink + FLASH
P1_ATTR     equ     %01010000       ; Red paper, black ink + BRIGHT (Player 1)
P2_ATTR     equ     %01001000       ; Blue paper, black ink + BRIGHT (Player 2)
P1_CURSOR   equ     %11010000       ; Red paper + FLASH (Player 1 cursor on own cell)
P2_CURSOR   equ     %11001000       ; Blue paper + FLASH (Player 2 cursor on own cell)

Player 1 is red (%01010000), Player 2 is blue (%01001000). Both use BRIGHT for vivid colours that stand out from the white empty cells.

The cursor attributes (P1_CURSOR, P2_CURSOR) combine the player colour with FLASH, so when you move onto your own cell, it still flashes.

Game State

We need to remember who owns each cell:

; Game states
STATE_EMPTY equ     0
STATE_P1    equ     1
STATE_P2    equ     2

; ----------------------------------------------------------------------------
; Variables
; ----------------------------------------------------------------------------

cursor_row:     defb    0
cursor_col:     defb    0
key_pressed:    defb    0
current_player: defb    1           ; 1 = Player 1 (Red), 2 = Player 2 (Blue)
board_state:    defs    64, 0       ; 64 bytes, all initialised to 0

The board_state array has 64 bytes - one per cell. Each byte is:

  • 0 = empty
  • 1 = Player 1 (red)
  • 2 = Player 2 (blue)

The current_player variable tracks whose turn it is, starting with Player 1.

Initialisation

At startup, we clear the board and set Player 1 to go first:

; ----------------------------------------------------------------------------
; Initialise Game State
; ----------------------------------------------------------------------------

init_game:
            ; Clear board state (all empty)
            ld      hl, board_state
            ld      b, 64
            xor     a
.clear_loop:
            ld      (hl), a
            inc     hl
            djnz    .clear_loop

            ; Player 1 starts
            ld      a, 1
            ld      (current_player), a

            ; Cursor at top-left
            xor     a
            ld      (cursor_row), a
            ld      (cursor_col), a

            ret

The djnz loop writes zero to all 64 cells. Simple and fast.

Trying to Claim

When you press Space, we check if the claim is valid:

; ----------------------------------------------------------------------------
; Try Claim Cell
; ----------------------------------------------------------------------------

try_claim:
            ; Check if cell is empty
            call    get_cell_state
            or      a
            ret     nz              ; Not empty - can't claim

            ; Claim the cell
            call    claim_cell

            ; Play success sound
            call    sound_claim

            ; Switch player
            ld      a, (current_player)
            xor     3               ; Toggle between 1 and 2
            ld      (current_player), a

            ; Redraw cursor with new state
            call    draw_cursor

            ret

If the cell isn’t empty (or a / ret nz), we ignore the press. No stealing opponent cells - not yet, anyway.

The player switch uses a clever trick: xor 3 toggles between 1 and 2. Binary 01 XOR 11 = 10, and 10 XOR 11 = 01.

The Claim Itself

; ----------------------------------------------------------------------------
; Claim Cell
; ----------------------------------------------------------------------------
; Claims current cell for current player

claim_cell:
            ; Calculate board state index
            ld      a, (cursor_row)
            add     a, a
            add     a, a
            add     a, a
            ld      hl, board_state
            ld      b, 0
            ld      c, a
            add     hl, bc
            ld      a, (cursor_col)
            ld      c, a
            add     hl, bc          ; HL = &board_state[row*8+col]

            ; Set to current player
            ld      a, (current_player)
            ld      (hl), a

            ; Update attribute colour
            push    af

            ld      a, (cursor_row)
            add     a, BOARD_ROW
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      a, (cursor_col)
            add     a, BOARD_COL
            add     a, l
            ld      l, a
            ld      bc, ATTR_BASE
            add     hl, bc

            pop     af
            cp      1
            jr      z, .player1
            ld      (hl), P2_ATTR
            ret
.player1:
            ld      (hl), P1_ATTR
            ret

Two things happen:

  1. We store the player number in board_state
  2. We change the attribute colour on screen

The array index is row * 8 + col. We calculate it using shifts: add a, a three times multiplies by 8.

Sound Feedback

A claim should feel satisfying:

; ----------------------------------------------------------------------------
; Sound - Claim
; ----------------------------------------------------------------------------
; Short rising tone for successful claim

sound_claim:
            ld      hl, 400         ; Starting pitch
            ld      b, 20           ; Duration

.loop:
            push    bc
            push    hl

            ; Generate tone
            ld      b, h
            ld      c, l
.tone_loop:
            ld      a, $10          ; Speaker bit on
            out     (KEY_PORT), a
            call    .delay
            xor     a               ; Speaker bit off
            out     (KEY_PORT), a
            call    .delay
            dec     bc
            ld      a, b
            or      c
            jr      nz, .tone_loop

            pop     hl
            pop     bc

            ; Increase pitch (lower delay = higher frequency)
            ld      de, 20
            or      a
            sbc     hl, de

            djnz    .loop
            ret

.delay:
            push    bc
            ld      b, 5
.delay_loop:
            djnz    .delay_loop
            pop     bc
            ret

This generates a rising tone by decreasing the delay between speaker toggles. The Spectrum’s beeper is just a single bit - toggle it fast for high pitch, slow for low pitch.

The speaker is bit 4 of port $FE (the same port as border colour). We alternate between $10 (speaker on) and $00 (speaker off).

The Complete Code

; ============================================================================
; INK WAR - Unit 2: Claiming Cells
; ============================================================================
; Two players take turns claiming cells on the board.
; Press Space to claim the cell under the cursor.
;
; Controls: Q=Up, A=Down, O=Left, P=Right, SPACE=Claim
; ============================================================================

            org     32768

; ----------------------------------------------------------------------------
; Constants
; ----------------------------------------------------------------------------

ATTR_BASE   equ     $5800           ; Start of attribute memory
BOARD_ROW   equ     8               ; Board starts at row 8
BOARD_COL   equ     12              ; Board starts at column 12
BOARD_SIZE  equ     8               ; 8x8 playing field

; Attribute colours (FBPPPIII format)
BORDER_ATTR equ     %00000000       ; Black on black (border)
EMPTY_ATTR  equ     %00111000       ; White paper, black ink (empty cell)
CURSOR_ATTR equ     %10111000       ; White paper, black ink + FLASH
P1_ATTR     equ     %01010000       ; Red paper, black ink + BRIGHT (Player 1)
P2_ATTR     equ     %01001000       ; Blue paper, black ink + BRIGHT (Player 2)
P1_CURSOR   equ     %11010000       ; Red paper + FLASH (Player 1 cursor on own cell)
P2_CURSOR   equ     %11001000       ; Blue paper + FLASH (Player 2 cursor on own cell)

; Keyboard ports (active low)
KEY_PORT    equ     $fe
ROW_QAOP    equ     $fb             ; Q W E R T row
ROW_ASDF    equ     $fd             ; A S D F G row
ROW_YUIOP   equ     $df             ; Y U I O P row
ROW_SPACE   equ     $7f             ; SPACE SYM M N B row

; Game states
STATE_EMPTY equ     0
STATE_P1    equ     1
STATE_P2    equ     2

; ----------------------------------------------------------------------------
; Entry Point
; ----------------------------------------------------------------------------

start:
            call    init_screen     ; Clear screen and set border
            call    init_game       ; Initialise game state
            call    draw_board      ; Draw the game board
            call    draw_cursor     ; Show cursor at starting position

main_loop:
            halt                    ; Wait for frame (50Hz timing)

            call    read_keyboard   ; Check for input
            call    handle_input    ; Process movement or claim

            jp      main_loop       ; Repeat forever

; ----------------------------------------------------------------------------
; Initialise Screen
; ----------------------------------------------------------------------------

init_screen:
            xor     a
            out     (KEY_PORT), a   ; Black border

            ld      hl, ATTR_BASE
            ld      de, ATTR_BASE+1
            ld      bc, 767
            ld      (hl), 0
            ldir

            ret

; ----------------------------------------------------------------------------
; Initialise Game State
; ----------------------------------------------------------------------------

init_game:
            ; Clear board state (all empty)
            ld      hl, board_state
            ld      b, 64
            xor     a
.clear_loop:
            ld      (hl), a
            inc     hl
            djnz    .clear_loop

            ; Player 1 starts
            ld      a, 1
            ld      (current_player), a

            ; Cursor at top-left
            xor     a
            ld      (cursor_row), a
            ld      (cursor_col), a

            ret

; ----------------------------------------------------------------------------
; Draw Board
; ----------------------------------------------------------------------------

draw_board:
            ld      b, BOARD_SIZE
            ld      c, BOARD_ROW

.row_loop:
            push    bc

            ld      b, BOARD_SIZE
            ld      d, BOARD_COL

.col_loop:
            push    bc

            ; Calculate attribute address
            ld      a, c
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      a, d
            add     a, l
            ld      l, a
            ld      bc, ATTR_BASE
            add     hl, bc

            ld      (hl), EMPTY_ATTR

            pop     bc
            inc     d
            djnz    .col_loop

            pop     bc
            inc     c
            djnz    .row_loop

            ret

; ----------------------------------------------------------------------------
; Draw Cursor
; ----------------------------------------------------------------------------
; Shows cursor with appropriate colour based on cell state

draw_cursor:
            ; Get cell state at cursor position
            call    get_cell_state  ; A = state at cursor

            ; Determine cursor attribute based on state
            cp      STATE_P1
            jr      z, .dc_p1
            cp      STATE_P2
            jr      z, .dc_p2

            ; Empty cell - use standard cursor
            ld      a, CURSOR_ATTR
            jr      .dc_set

.dc_p1:
            ld      a, P1_CURSOR
            jr      .dc_set

.dc_p2:
            ld      a, P2_CURSOR

.dc_set:
            push    af              ; Save attribute

            ; Calculate attribute address
            ld      a, (cursor_row)
            add     a, BOARD_ROW
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      a, (cursor_col)
            add     a, BOARD_COL
            add     a, l
            ld      l, a
            ld      bc, ATTR_BASE
            add     hl, bc

            pop     af              ; Restore attribute
            ld      (hl), a

            ret

; ----------------------------------------------------------------------------
; Clear Cursor
; ----------------------------------------------------------------------------
; Restores cell to its proper colour (empty, P1, or P2)

clear_cursor:
            ; Get cell state
            call    get_cell_state

            ; Determine attribute based on state
            cp      STATE_P1
            jr      z, .cc_p1
            cp      STATE_P2
            jr      z, .cc_p2

            ; Empty cell
            ld      a, EMPTY_ATTR
            jr      .cc_set

.cc_p1:
            ld      a, P1_ATTR
            jr      .cc_set

.cc_p2:
            ld      a, P2_ATTR

.cc_set:
            push    af

            ; Calculate attribute address
            ld      a, (cursor_row)
            add     a, BOARD_ROW
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      a, (cursor_col)
            add     a, BOARD_COL
            add     a, l
            ld      l, a
            ld      bc, ATTR_BASE
            add     hl, bc

            pop     af
            ld      (hl), a

            ret

; ----------------------------------------------------------------------------
; Get Cell State
; ----------------------------------------------------------------------------
; Returns: A = state at cursor position (0=empty, 1=P1, 2=P2)

get_cell_state:
            ld      a, (cursor_row)
            add     a, a            ; *2
            add     a, a            ; *4
            add     a, a            ; *8
            ld      hl, board_state
            ld      b, 0
            ld      c, a
            add     hl, bc
            ld      a, (cursor_col)
            ld      c, a
            add     hl, bc          ; HL = &board_state[row*8+col]
            ld      a, (hl)
            ret

; ----------------------------------------------------------------------------
; Read Keyboard
; ----------------------------------------------------------------------------

read_keyboard:
            xor     a
            ld      (key_pressed), a

            ; Check Q (up)
            ld      a, ROW_QAOP
            in      a, (KEY_PORT)
            bit     0, a
            jr      nz, .not_q
            ld      a, 1
            ld      (key_pressed), a
            ret
.not_q:
            ; Check A (down)
            ld      a, ROW_ASDF
            in      a, (KEY_PORT)
            bit     0, a
            jr      nz, .not_a
            ld      a, 2
            ld      (key_pressed), a
            ret
.not_a:
            ; Check O (left)
            ld      a, ROW_YUIOP
            in      a, (KEY_PORT)
            bit     1, a
            jr      nz, .not_o
            ld      a, 3
            ld      (key_pressed), a
            ret
.not_o:
            ; Check P (right)
            ld      a, ROW_YUIOP
            in      a, (KEY_PORT)
            bit     0, a
            jr      nz, .not_p
            ld      a, 4
            ld      (key_pressed), a
            ret
.not_p:
            ; Check SPACE (claim)
            ld      a, ROW_SPACE
            in      a, (KEY_PORT)
            bit     0, a
            jr      nz, .not_space
            ld      a, 5
            ld      (key_pressed), a
.not_space:
            ret

; ----------------------------------------------------------------------------
; Handle Input
; ----------------------------------------------------------------------------

handle_input:
            ld      a, (key_pressed)
            or      a
            ret     z               ; No key

            cp      5               ; Space?
            jr      z, try_claim

            ; Movement key - use existing move logic
            call    clear_cursor

            ld      a, (key_pressed)

            cp      1               ; Up?
            jr      nz, .not_up
            ld      a, (cursor_row)
            or      a
            jr      z, .done
            dec     a
            ld      (cursor_row), a
            jr      .done
.not_up:
            cp      2               ; Down?
            jr      nz, .not_down
            ld      a, (cursor_row)
            cp      BOARD_SIZE-1
            jr      z, .done
            inc     a
            ld      (cursor_row), a
            jr      .done
.not_down:
            cp      3               ; Left?
            jr      nz, .not_left
            ld      a, (cursor_col)
            or      a
            jr      z, .done
            dec     a
            ld      (cursor_col), a
            jr      .done
.not_left:
            cp      4               ; Right?
            jr      nz, .done
            ld      a, (cursor_col)
            cp      BOARD_SIZE-1
            jr      z, .done
            inc     a
            ld      (cursor_col), a

.done:
            call    draw_cursor
            ret

; ----------------------------------------------------------------------------
; Try Claim Cell
; ----------------------------------------------------------------------------

try_claim:
            ; Check if cell is empty
            call    get_cell_state
            or      a
            ret     nz              ; Not empty - can't claim

            ; Claim the cell
            call    claim_cell

            ; Play success sound
            call    sound_claim

            ; Switch player
            ld      a, (current_player)
            xor     3               ; Toggle between 1 and 2
            ld      (current_player), a

            ; Redraw cursor with new state
            call    draw_cursor

            ret

; ----------------------------------------------------------------------------
; Claim Cell
; ----------------------------------------------------------------------------
; Claims current cell for current player

claim_cell:
            ; Calculate board state index
            ld      a, (cursor_row)
            add     a, a
            add     a, a
            add     a, a
            ld      hl, board_state
            ld      b, 0
            ld      c, a
            add     hl, bc
            ld      a, (cursor_col)
            ld      c, a
            add     hl, bc          ; HL = &board_state[row*8+col]

            ; Set to current player
            ld      a, (current_player)
            ld      (hl), a

            ; Update attribute colour
            push    af

            ld      a, (cursor_row)
            add     a, BOARD_ROW
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      a, (cursor_col)
            add     a, BOARD_COL
            add     a, l
            ld      l, a
            ld      bc, ATTR_BASE
            add     hl, bc

            pop     af
            cp      1
            jr      z, .player1
            ld      (hl), P2_ATTR
            ret
.player1:
            ld      (hl), P1_ATTR
            ret

; ----------------------------------------------------------------------------
; Sound - Claim
; ----------------------------------------------------------------------------
; Short rising tone for successful claim

sound_claim:
            ld      hl, 400         ; Starting pitch
            ld      b, 20           ; Duration

.loop:
            push    bc
            push    hl

            ; Generate tone
            ld      b, h
            ld      c, l
.tone_loop:
            ld      a, $10          ; Speaker bit on
            out     (KEY_PORT), a
            call    .delay
            xor     a               ; Speaker bit off
            out     (KEY_PORT), a
            call    .delay
            dec     bc
            ld      a, b
            or      c
            jr      nz, .tone_loop

            pop     hl
            pop     bc

            ; Increase pitch (lower delay = higher frequency)
            ld      de, 20
            or      a
            sbc     hl, de

            djnz    .loop
            ret

.delay:
            push    bc
            ld      b, 5
.delay_loop:
            djnz    .delay_loop
            pop     bc
            ret

; ----------------------------------------------------------------------------
; Variables
; ----------------------------------------------------------------------------

cursor_row:     defb    0
cursor_col:     defb    0
key_pressed:    defb    0
current_player: defb    1           ; 1 = Player 1 (Red), 2 = Player 2 (Blue)
board_state:    defs    64, 0       ; 64 bytes, all initialised to 0

; ----------------------------------------------------------------------------
; End
; ----------------------------------------------------------------------------

            end     start