Complete Two-Player Game
Polish pass. Smooth cursor control, better feedback. Hand it to a friend and play.
The game is functionally complete. Two players can compete, see who wins, and play again. But there are rough edges — the cursor races across the board if you hold a key, and the results screen doesn’t tell you what to do next.
This unit is a polish pass. Small changes that transform a working prototype into a comfortable game.
Run It
pasmonext --sna inkwar.asm inkwar.sna

Play a game. Notice how the cursor moves at a controlled pace even when holding a direction key. When the game ends, you’ll see “PRESS ANY KEY” so players know what to do.
The Key Repeat Problem
In Unit 7, holding a direction key made the cursor fly across the board. The main loop runs 50 times per second, and every frame with a key held triggered movement. Too fast for precise control.
The solution: key repeat delay. When you press a direction, move immediately. If you hold the key, wait several frames before moving again.
; ----------------------------------------------------------------------------
; Key Repeat Constants
; ----------------------------------------------------------------------------
; Controls how quickly the cursor moves when holding a direction key
; Input timing
KEY_DELAY equ 8 ; Frames between key repeats
; Variables needed:
; last_key: defb 0 ; Previous frame's key for repeat detection
; key_timer: defb 0 ; Countdown for key repeat delay
KEY_DELAY sets how many frames to wait between repeats. At 8 frames (about 160ms at 50Hz), the cursor moves at a comfortable pace.
Implementing Key Repeat
The logic tracks the previous key and a countdown timer:
; ----------------------------------------------------------------------------
; Key Repeat Logic (in handle_input)
; ----------------------------------------------------------------------------
; Prevents rapid cursor movement when holding a key
handle_input:
ld a, (key_pressed)
or a
jr nz, .hi_have_key
; No key pressed - reset tracking
xor a
ld (last_key), a
ld (key_timer), a
ret
.hi_have_key:
; Space (claim) always works immediately
cp 5
jr z, try_claim
; Check if same key as last frame
ld b, a ; Save current key
ld a, (last_key)
cp b
jr nz, .hi_new_key
; Same key - check timer
ld a, (key_timer)
or a
jr z, .hi_allow ; Timer expired, allow repeat
dec a
ld (key_timer), a
ret ; Still waiting
.hi_new_key:
; New key pressed - save it and reset timer
ld a, b
ld (last_key), a
ld a, KEY_DELAY
ld (key_timer), a
.hi_allow:
; Process movement (cursor update code follows)
; ...
Three cases:
- No key pressed: Reset tracking variables
- New key pressed: Move immediately, start the timer
- Same key held: Decrement timer, only move when it hits zero
Note that Space (claim) bypasses this entirely — you want instant response when claiming a cell.
Results Screen Prompt
Players shouldn’t wonder what to do after the game ends. A simple prompt helps:
; ----------------------------------------------------------------------------
; Results Screen with Continue Prompt
; ----------------------------------------------------------------------------
; Shows winner message and "PRESS ANY KEY" prompt
; Constants
CONTINUE_ROW equ 22 ; "PRESS ANY KEY" after results
CONTINUE_COL equ 9 ; (32-13)/2 = 9.5
; Message
msg_continue: defb "PRESS ANY KEY", 0
; After displaying winner message, show continue prompt:
.sr_continue:
; Display "PRESS ANY KEY" prompt
ld b, CONTINUE_ROW
ld c, CONTINUE_COL
ld hl, msg_continue
ld e, TEXT_ATTR
call print_message
ret
Now the flow is clear: see who won, press any key, return to title.
What Makes Polish Matter
These changes are small in code but significant in feel:
- Controlled movement prevents frustrating overshoots
- Clear prompts eliminate confusion about what to do next
- Consistent timing makes the game feel intentional, not accidental
Professional games get these details right. Now yours does too.
Phase 1 Midpoint
Congratulations — you’ve built a complete two-player game. Two humans can sit down, play Ink War competitively, and have a good time. That’s a real achievement.
The remaining Phase 1 units add an AI opponent. When you’re done, you’ll be able to play solo.
The Complete Code
; ============================================================================
; INK WAR - Unit 8: Complete Two-Player Game
; ============================================================================
; Polish pass for smooth two-player experience. Adds key repeat delay for
; controlled cursor movement and "PRESS ANY KEY" prompt after results.
;
; Controls: Q=Up, A=Down, O=Left, P=Right, SPACE=Claim
; ============================================================================
org 32768
; ----------------------------------------------------------------------------
; Constants
; ----------------------------------------------------------------------------
ATTR_BASE equ $5800
DISPLAY_FILE equ $4000
CHAR_SET equ $3C00 ; ROM character set base address
BOARD_ROW equ 8
BOARD_COL equ 12
BOARD_SIZE equ 8
; Display positions
SCORE_ROW equ 2 ; Score display row
P1_SCORE_COL equ 10 ; "P1: nn" column
P2_SCORE_COL equ 18 ; "P2: nn" column
TURN_ROW equ 4 ; Turn indicator row
TURN_COL equ 14 ; Turn indicator column
RESULT_ROW equ 20 ; Winner message row
RESULT_COL equ 11 ; Winner message column
; Game constants
TOTAL_CELLS equ 64 ; 8x8 board
; Customised colours (from Unit 3)
EMPTY_ATTR equ %01101000 ; Cyan paper + BRIGHT
BORDER_ATTR equ %01110000 ; Yellow paper + BRIGHT
CURSOR_ATTR equ %01111000 ; White paper + BRIGHT
P1_ATTR equ %01010000 ; Red paper + BRIGHT
P2_ATTR equ %01001000 ; Blue paper + BRIGHT
P1_CURSOR equ %01111010 ; White paper + Red ink + BRIGHT
P2_CURSOR equ %01111001 ; White paper + Blue ink + BRIGHT
; Text display attributes
TEXT_ATTR equ %00111000 ; White paper, black ink
P1_TEXT equ %01010111 ; Red paper, white ink + BRIGHT
P2_TEXT equ %01001111 ; Blue paper, white ink + BRIGHT
P1_BORDER equ 2
P2_BORDER equ 1
ERROR_BORDER equ 2 ; Red border for errors
; Keyboard ports
KEY_PORT equ $fe
ROW_QAOP equ $fb
ROW_ASDF equ $fd
ROW_YUIOP equ $df
ROW_SPACE equ $7f
; Cell states
STATE_EMPTY equ 0
STATE_P1 equ 1
STATE_P2 equ 2
; Game states (state machine)
GS_TITLE equ 0
GS_PLAYING equ 1
GS_RESULTS equ 2
; Title screen positions
TITLE_ROW equ 8
TITLE_COL equ 12 ; "INK WAR" (7 chars) centred: (32-7)/2=12.5
PROMPT_ROW equ 16
PROMPT_COL equ 5 ; "PRESS ANY KEY TO START" (22 chars): (32-22)/2=5
; Results screen positions
CONTINUE_ROW equ 22 ; "PRESS ANY KEY" after results
CONTINUE_COL equ 9 ; (32-13)/2 = 9.5
; Input timing
KEY_DELAY equ 8 ; Frames between key repeats
; ----------------------------------------------------------------------------
; Entry Point
; ----------------------------------------------------------------------------
start:
; Start at title screen
ld a, GS_TITLE
ld (game_state), a
call init_screen
call draw_title_screen
; Black border for title
xor a
out (KEY_PORT), a
main_loop:
halt
; Dispatch based on game state
ld a, (game_state)
or a
jr z, state_title ; GS_TITLE = 0
cp GS_PLAYING
jr z, state_playing
; Must be GS_RESULTS - handled inline after game over
jp main_loop
; ----------------------------------------------------------------------------
; State: Title
; ----------------------------------------------------------------------------
state_title:
; Wait for any key press
xor a
in a, (KEY_PORT)
cpl
and %00011111
jr z, main_loop ; No key - keep waiting
; Key pressed - start game
call start_game
jp main_loop
; ----------------------------------------------------------------------------
; State: Playing
; ----------------------------------------------------------------------------
state_playing:
call read_keyboard
call handle_input
jp main_loop
; ----------------------------------------------------------------------------
; Start Game
; ----------------------------------------------------------------------------
; Transitions from title to playing state
start_game:
ld a, GS_PLAYING
ld (game_state), a
call init_screen
call init_game
call draw_board_border
call draw_board
call draw_ui
call draw_cursor
call update_border
; Wait for key release before playing
call wait_key_release
ret
; ----------------------------------------------------------------------------
; Initialise Screen
; ----------------------------------------------------------------------------
init_screen:
xor a
out (KEY_PORT), a
; Clear display file (pixels)
ld hl, DISPLAY_FILE
ld de, DISPLAY_FILE+1
ld bc, 6143
ld (hl), 0
ldir
; Clear all attributes to white paper, black ink
ld hl, ATTR_BASE
ld de, ATTR_BASE+1
ld bc, 767
ld (hl), TEXT_ATTR ; White background for text areas
ldir
ret
; ----------------------------------------------------------------------------
; Draw UI
; ----------------------------------------------------------------------------
; Draws score display and turn indicator
draw_ui:
call draw_scores
call draw_turn_indicator
ret
; ----------------------------------------------------------------------------
; Draw Scores
; ----------------------------------------------------------------------------
; Displays "P1: nn P2: nn" with player colours
draw_scores:
; Count cells for each player
call count_cells
; Draw P1 label "P1:"
ld b, SCORE_ROW
ld c, P1_SCORE_COL
ld a, 'P'
call print_char
inc c
ld a, '1'
call print_char
inc c
ld a, ':'
call print_char
inc c
; Print P1 score
ld a, (p1_count)
call print_two_digits
; Set P1 colour attribute
ld a, SCORE_ROW
ld c, P1_SCORE_COL
ld b, 5 ; "P1:nn" = 5 characters
ld e, P1_TEXT
call set_attr_range
; Draw P2 label "P2:"
ld b, SCORE_ROW
ld c, P2_SCORE_COL
ld a, 'P'
call print_char
inc c
ld a, '2'
call print_char
inc c
ld a, ':'
call print_char
inc c
; Print P2 score
ld a, (p2_count)
call print_two_digits
; Set P2 colour attribute
ld a, SCORE_ROW
ld c, P2_SCORE_COL
ld b, 5
ld e, P2_TEXT
call set_attr_range
ret
; ----------------------------------------------------------------------------
; Draw Turn Indicator
; ----------------------------------------------------------------------------
; Shows "TURN" with current player's colour
draw_turn_indicator:
; Print "TURN"
ld b, TURN_ROW
ld c, TURN_COL
ld a, 'T'
call print_char
inc c
ld a, 'U'
call print_char
inc c
ld a, 'R'
call print_char
inc c
ld a, 'N'
call print_char
; Set attribute based on current player
ld a, (current_player)
cp 1
jr z, .dti_p1
ld e, P2_TEXT
jr .dti_set
.dti_p1:
ld e, P1_TEXT
.dti_set:
ld a, TURN_ROW
ld c, TURN_COL
ld b, 4 ; "TURN" = 4 chars
call set_attr_range
ret
; ----------------------------------------------------------------------------
; Print Character
; ----------------------------------------------------------------------------
; A = ASCII character (32-127), B = row (0-23), C = column (0-31)
; Writes character directly to display file using ROM character set
print_char:
push bc
push de
push hl
push af
; Calculate character data address: CHAR_SET + char*8
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl ; HL = char * 8
ld de, CHAR_SET
add hl, de ; HL = source address
push hl ; Save character data address
; Calculate display file address
; Screen address: high byte varies with row, low byte = column
ld a, b ; A = row (0-23)
and %00011000 ; Get which third (0, 8, 16)
add a, $40 ; Add display file base high byte
ld d, a
ld a, b ; A = row
and %00000111 ; Get line within character row
rrca
rrca
rrca ; Shift to bits 5-7
add a, c ; Add column
ld e, a ; DE = screen address
pop hl ; HL = character data
; Copy 8 bytes (8 pixel rows of character)
ld b, 8
.pc_loop:
ld a, (hl)
ld (de), a
inc hl
inc d ; Next screen line (add 256)
djnz .pc_loop
pop af
pop hl
pop de
pop bc
ret
; ----------------------------------------------------------------------------
; Print Two Digits
; ----------------------------------------------------------------------------
; A = number (0-99), B = row, C = column (will advance by 2)
; Prints number as two digits
print_two_digits:
push bc
; Calculate tens digit
ld d, 0 ; Tens counter
.ptd_tens:
cp 10
jr c, .ptd_print
sub 10
inc d
jr .ptd_tens
.ptd_print:
push af ; Save units digit
; Print tens digit
ld a, d
add a, '0'
call print_char
inc c
; Print units digit
pop af
add a, '0'
call print_char
inc c
pop bc
ret
; ----------------------------------------------------------------------------
; Count Cells
; ----------------------------------------------------------------------------
; Counts cells owned by each player
count_cells:
xor a
ld (p1_count), a
ld (p2_count), a
ld hl, board_state
ld b, 64 ; 64 cells
.cc_loop:
ld a, (hl)
cp STATE_P1
jr nz, .cc_not_p1
ld a, (p1_count)
inc a
ld (p1_count), a
jr .cc_next
.cc_not_p1:
cp STATE_P2
jr nz, .cc_next
ld a, (p2_count)
inc a
ld (p2_count), a
.cc_next:
inc hl
djnz .cc_loop
ret
; ----------------------------------------------------------------------------
; Set Attribute Range
; ----------------------------------------------------------------------------
; A = row, C = start column, B = count, E = attribute
set_attr_range:
push bc
push de
; Calculate start address: ATTR_BASE + row*32 + col
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
add hl, hl
add hl, hl ; HL = row * 32
ld a, c
add a, l
ld l, a
ld bc, ATTR_BASE
add hl, bc ; HL = attribute address
pop de ; E = attribute
pop bc ; B = count
.sar_loop:
ld (hl), e
inc hl
djnz .sar_loop
ret
; ----------------------------------------------------------------------------
; Update Border
; ----------------------------------------------------------------------------
update_border:
ld a, (current_player)
cp 1
jr z, .ub_p1
ld a, P2_BORDER
jr .ub_set
.ub_p1:
ld a, P1_BORDER
.ub_set:
out (KEY_PORT), a
ret
; ----------------------------------------------------------------------------
; Initialise Game State
; ----------------------------------------------------------------------------
init_game:
ld hl, board_state
ld b, 64
xor a
.ig_loop:
ld (hl), a
inc hl
djnz .ig_loop
ld a, 1
ld (current_player), a
xor a
ld (cursor_row), a
ld (cursor_col), a
ld (p1_count), a
ld (p2_count), a
ld (last_key), a ; No previous key
ld (key_timer), a ; No delay active
ret
; ----------------------------------------------------------------------------
; Draw Board Border
; ----------------------------------------------------------------------------
draw_board_border:
ld c, BOARD_ROW-1
ld d, BOARD_COL-1
ld b, BOARD_SIZE+2
call draw_border_row
ld c, BOARD_ROW+BOARD_SIZE
ld d, BOARD_COL-1
ld b, BOARD_SIZE+2
call draw_border_row
ld c, BOARD_ROW
ld d, BOARD_COL-1
ld b, BOARD_SIZE
call draw_border_col
ld c, BOARD_ROW
ld d, BOARD_COL+BOARD_SIZE
ld b, BOARD_SIZE
call draw_border_col
ret
draw_border_row:
push bc
.dbr_loop:
push bc
push de
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), BORDER_ATTR
pop de
pop bc
inc d
djnz .dbr_loop
pop bc
ret
draw_border_col:
push bc
.dbc_loop:
push bc
push de
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), BORDER_ATTR
pop de
pop bc
inc c
djnz .dbc_loop
pop bc
ret
; ----------------------------------------------------------------------------
; Draw Board
; ----------------------------------------------------------------------------
draw_board:
ld b, BOARD_SIZE
ld c, BOARD_ROW
.db_row:
push bc
ld b, BOARD_SIZE
ld d, BOARD_COL
.db_col:
push bc
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 .db_col
pop bc
inc c
djnz .db_row
ret
; ----------------------------------------------------------------------------
; Draw Cursor
; ----------------------------------------------------------------------------
draw_cursor:
call get_cell_state
cp STATE_P1
jr z, .dc_p1
cp STATE_P2
jr z, .dc_p2
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
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
; ----------------------------------------------------------------------------
; Clear Cursor
; ----------------------------------------------------------------------------
clear_cursor:
call get_cell_state
cp STATE_P1
jr z, .clc_p1
cp STATE_P2
jr z, .clc_p2
ld a, EMPTY_ATTR
jr .clc_set
.clc_p1:
ld a, P1_ATTR
jr .clc_set
.clc_p2:
ld a, P2_ATTR
.clc_set:
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
ld (hl), a
ret
; ----------------------------------------------------------------------------
; Get Cell State
; ----------------------------------------------------------------------------
get_cell_state:
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
ld a, (hl)
ret
; ----------------------------------------------------------------------------
; Read Keyboard
; ----------------------------------------------------------------------------
read_keyboard:
xor a
ld (key_pressed), a
ld a, ROW_QAOP
in a, (KEY_PORT)
bit 0, a
jr nz, .rk_not_q
ld a, 1
ld (key_pressed), a
ret
.rk_not_q:
ld a, ROW_ASDF
in a, (KEY_PORT)
bit 0, a
jr nz, .rk_not_a
ld a, 2
ld (key_pressed), a
ret
.rk_not_a:
ld a, ROW_YUIOP
in a, (KEY_PORT)
bit 1, a
jr nz, .rk_not_o
ld a, 3
ld (key_pressed), a
ret
.rk_not_o:
ld a, ROW_YUIOP
in a, (KEY_PORT)
bit 0, a
jr nz, .rk_not_p
ld a, 4
ld (key_pressed), a
ret
.rk_not_p:
ld a, ROW_SPACE
in a, (KEY_PORT)
bit 0, a
jr nz, .rk_not_space
ld a, 5
ld (key_pressed), a
.rk_not_space:
ret
; ----------------------------------------------------------------------------
; Handle Input
; ----------------------------------------------------------------------------
; Implements key repeat delay for smooth cursor movement
handle_input:
ld a, (key_pressed)
or a
jr nz, .hi_have_key
; No key pressed - reset tracking
xor a
ld (last_key), a
ld (key_timer), a
ret
.hi_have_key:
; Space (claim) always works immediately
cp 5
jr z, try_claim
; Check if same key as last frame
ld b, a ; Save current key
ld a, (last_key)
cp b
jr nz, .hi_new_key
; Same key - check timer
ld a, (key_timer)
or a
jr z, .hi_allow ; Timer expired, allow repeat
dec a
ld (key_timer), a
ret ; Still waiting
.hi_new_key:
; New key pressed - save it and reset timer
ld a, b
ld (last_key), a
ld a, KEY_DELAY
ld (key_timer), a
.hi_allow:
; Process movement
call clear_cursor
ld a, (key_pressed)
cp 1
jr nz, .hi_not_up
ld a, (cursor_row)
or a
jr z, .hi_done
dec a
ld (cursor_row), a
jr .hi_done
.hi_not_up:
cp 2
jr nz, .hi_not_down
ld a, (cursor_row)
cp BOARD_SIZE-1
jr z, .hi_done
inc a
ld (cursor_row), a
jr .hi_done
.hi_not_down:
cp 3
jr nz, .hi_not_left
ld a, (cursor_col)
or a
jr z, .hi_done
dec a
ld (cursor_col), a
jr .hi_done
.hi_not_left:
cp 4
jr nz, .hi_done
ld a, (cursor_col)
cp BOARD_SIZE-1
jr z, .hi_done
inc a
ld (cursor_col), a
.hi_done:
call draw_cursor
ret
; ----------------------------------------------------------------------------
; Try Claim Cell
; ----------------------------------------------------------------------------
try_claim:
call get_cell_state
or a
jr z, .tc_valid
; Cell already claimed - error feedback
call sound_error
call flash_border_error
call update_border ; Restore correct border colour
ret
.tc_valid:
; Valid move - claim the cell
call claim_cell
call sound_claim
ld a, (current_player)
xor 3
ld (current_player), a
call draw_ui ; Update scores and turn indicator
; Check if game is over
call check_game_over
or a
jr z, .tc_continue
; Game over - show results and return to title
call show_results
call victory_celebration
call wait_for_key
; Return to title screen
ld a, GS_TITLE
ld (game_state), a
call init_screen
call draw_title_screen
xor a
out (KEY_PORT), a ; Black border for title
ret
.tc_continue:
call update_border
call draw_cursor
ret
; ----------------------------------------------------------------------------
; Claim Cell
; ----------------------------------------------------------------------------
claim_cell:
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
ld a, (current_player)
ld (hl), a
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, .clm_is_p1
ld (hl), P2_ATTR
ret
.clm_is_p1:
ld (hl), P1_ATTR
ret
; ----------------------------------------------------------------------------
; Sound - Claim
; ----------------------------------------------------------------------------
sound_claim:
ld hl, 400
ld b, 20
.scl_loop:
push bc
push hl
ld b, h
ld c, l
.scl_tone:
ld a, $10
out (KEY_PORT), a
call .scl_delay
xor a
out (KEY_PORT), a
call .scl_delay
dec bc
ld a, b
or c
jr nz, .scl_tone
pop hl
pop bc
ld de, 20
or a
sbc hl, de
djnz .scl_loop
ret
.scl_delay:
push bc
ld b, 5
.scl_delay_loop:
djnz .scl_delay_loop
pop bc
ret
; ----------------------------------------------------------------------------
; Sound - Error
; ----------------------------------------------------------------------------
; Harsh buzz for invalid move
sound_error:
ld b, 30 ; Duration
.se_loop:
push bc
; Low frequency buzz (longer delay = lower pitch)
ld a, $10
out (KEY_PORT), a
ld c, 80 ; Longer delay for low pitch
.se_delay1:
dec c
jr nz, .se_delay1
xor a
out (KEY_PORT), a
ld c, 80
.se_delay2:
dec c
jr nz, .se_delay2
pop bc
djnz .se_loop
ret
; ----------------------------------------------------------------------------
; Flash Border Error
; ----------------------------------------------------------------------------
; Flash border red briefly to indicate error
flash_border_error:
; Flash red 3 times
ld b, 3
.fbe_loop:
push bc
; Red border
ld a, ERROR_BORDER
out (KEY_PORT), a
; Short delay (about 3 frames)
ld bc, 8000
.fbe_delay1:
dec bc
ld a, b
or c
jr nz, .fbe_delay1
; Black border (brief off)
xor a
out (KEY_PORT), a
; Short delay
ld bc, 4000
.fbe_delay2:
dec bc
ld a, b
or c
jr nz, .fbe_delay2
pop bc
djnz .fbe_loop
ret
; ----------------------------------------------------------------------------
; Check Game Over
; ----------------------------------------------------------------------------
; Returns A=1 if game is over (board full), A=0 otherwise
check_game_over:
; Game is over when p1_count + p2_count == 64
ld a, (p1_count)
ld b, a
ld a, (p2_count)
add a, b
cp TOTAL_CELLS
jr z, .cgo_over
xor a ; Not over
ret
.cgo_over:
ld a, 1 ; Game over
ret
; ----------------------------------------------------------------------------
; Show Results
; ----------------------------------------------------------------------------
; Displays winner message based on scores
show_results:
; Clear turn indicator
ld a, TURN_ROW
ld c, TURN_COL
ld b, 4
ld e, TEXT_ATTR
call set_attr_range
; Determine winner
ld a, (p1_count)
ld b, a
ld a, (p2_count)
cp b
jr c, .sr_p1_wins ; p2 < p1, so p1 wins
jr z, .sr_draw ; p1 == p2, draw
; p2 > p1, p2 wins
jr .sr_p2_wins
.sr_p1_wins:
; Display "P1 WINS!"
ld b, RESULT_ROW
ld c, RESULT_COL
ld hl, msg_p1_wins
ld e, P1_TEXT
call print_message
jr .sr_continue
.sr_p2_wins:
; Display "P2 WINS!"
ld b, RESULT_ROW
ld c, RESULT_COL
ld hl, msg_p2_wins
ld e, P2_TEXT
call print_message
jr .sr_continue
.sr_draw:
; Display "DRAW!"
ld b, RESULT_ROW
ld c, RESULT_COL + 2 ; Centre "DRAW!" better
ld hl, msg_draw
ld e, TEXT_ATTR
call print_message
.sr_continue:
; Display "PRESS ANY KEY" prompt
ld b, CONTINUE_ROW
ld c, CONTINUE_COL
ld hl, msg_continue
ld e, TEXT_ATTR
call print_message
ret
; ----------------------------------------------------------------------------
; Print Message
; ----------------------------------------------------------------------------
; HL = pointer to null-terminated string
; B = row, C = starting column, E = attribute for message area
print_message:
; Save parameters we need later
push de ; Save attribute in E
push bc ; Save row (B) and start column (C)
; Print characters
.pm_loop:
ld a, (hl)
or a
jr z, .pm_done
call print_char
inc hl
inc c
jr .pm_loop
.pm_done:
; C now has end column
; Calculate length: end_col - start_col
ld a, c ; A = end column
pop bc ; B = row, C = start column
sub c ; A = length
ld d, a ; D = length (save it)
; Set up for set_attr_range: A=row, C=start_col, B=count, E=attr
ld a, b ; A = row
ld b, d ; B = count (length)
pop de ; E = attribute
call set_attr_range
ret
; ----------------------------------------------------------------------------
; Victory Celebration
; ----------------------------------------------------------------------------
; Flashes border in winner's colour
victory_celebration:
; Determine winner's border colour
ld a, (p1_count)
ld b, a
ld a, (p2_count)
cp b
jr c, .vc_p1 ; p2 < p1
jr z, .vc_draw ; draw - use white
ld d, P2_BORDER ; p2 wins
jr .vc_flash
.vc_p1:
ld d, P1_BORDER
jr .vc_flash
.vc_draw:
ld d, 7 ; White for draw
.vc_flash:
; Flash border 5 times
ld b, 5
.vc_loop:
push bc
; Winner's colour
ld a, d
out (KEY_PORT), a
; Delay
ld bc, 15000
.vc_delay1:
dec bc
ld a, b
or c
jr nz, .vc_delay1
; Black
xor a
out (KEY_PORT), a
; Delay
ld bc, 10000
.vc_delay2:
dec bc
ld a, b
or c
jr nz, .vc_delay2
pop bc
djnz .vc_loop
ret
; ----------------------------------------------------------------------------
; Draw Title Screen
; ----------------------------------------------------------------------------
; Displays game title and prompt
draw_title_screen:
; Draw "INK WAR" title
ld b, TITLE_ROW
ld c, TITLE_COL
ld hl, msg_title
ld e, TEXT_ATTR
call print_message
; Draw "PRESS ANY KEY TO START" prompt
ld b, PROMPT_ROW
ld c, PROMPT_COL
ld hl, msg_prompt
ld e, TEXT_ATTR
call print_message
ret
; ----------------------------------------------------------------------------
; Wait Key Release
; ----------------------------------------------------------------------------
; Waits until all keys are released
wait_key_release:
.wkr_loop:
xor a
in a, (KEY_PORT)
cpl ; Invert (keys are active low)
and %00011111 ; Mask to key bits
jr nz, .wkr_loop ; Still holding a key
ret
; ----------------------------------------------------------------------------
; Wait For Key
; ----------------------------------------------------------------------------
; Waits until any key is pressed
wait_for_key:
; First wait for all keys to be released
.wfk_release:
xor a
in a, (KEY_PORT)
cpl ; Invert (keys are active low)
and %00011111 ; Mask to key bits
jr nz, .wfk_release
; Now wait for a key press
.wfk_wait:
halt ; Wait for interrupt
xor a
in a, (KEY_PORT)
cpl
and %00011111
jr z, .wfk_wait
ret
; ----------------------------------------------------------------------------
; Messages
; ----------------------------------------------------------------------------
msg_p1_wins: defb "P1 WINS!", 0
msg_p2_wins: defb "P2 WINS!", 0
msg_draw: defb "DRAW!", 0
msg_title: defb "INK WAR", 0
msg_prompt: defb "PRESS ANY KEY TO START", 0
msg_continue: defb "PRESS ANY KEY", 0
; ----------------------------------------------------------------------------
; Variables
; ----------------------------------------------------------------------------
game_state: defb 0 ; 0=title, 1=playing, 2=results
cursor_row: defb 0
cursor_col: defb 0
key_pressed: defb 0
last_key: defb 0 ; Previous frame's key for repeat detection
key_timer: defb 0 ; Countdown for key repeat delay
current_player: defb 1
p1_count: defb 0
p2_count: defb 0
board_state: defs 64, 0
; ----------------------------------------------------------------------------
; End
; ----------------------------------------------------------------------------
end start
Try This: Adjust Key Repeat Speed
KEY_DELAY equ 4 ; Faster repeat
KEY_DELAY equ 12 ; Slower repeat
Find the speed that feels right to you.
Try This: Initial Delay vs Repeat Delay
Many games use a longer delay before the first repeat, then faster repeats after:
KEY_INITIAL_DELAY equ 15 ; First repeat wait
KEY_REPEAT_DELAY equ 4 ; Subsequent repeats
Modify the key handling to implement this pattern.
What You’ve Learnt
- Key repeat delay — Control input timing for smooth movement
- Timer-based input — Use frame counting to gate actions
- User feedback — Clear prompts guide players through the game
- Polish matters — Small details create a professional feel
What’s Next
In Unit 9, we’ll add an AI framework. The computer will start making moves — randomly at first, but it’s the foundation for smarter opponents.
What Changed
| 1 | 1 | ; ============================================================================ | |
| 2 | - | ; INK WAR - Unit 7: Title Screen | |
| 2 | + | ; INK WAR - Unit 8: Complete Two-Player Game | |
| 3 | 3 | ; ============================================================================ | |
| 4 | - | ; Adds title screen with state machine. Game starts at title, transitions | |
| 5 | - | ; to playing, shows results, then returns to title. | |
| 4 | + | ; Polish pass for smooth two-player experience. Adds key repeat delay for | |
| 5 | + | ; controlled cursor movement and "PRESS ANY KEY" prompt after results. | |
| 6 | 6 | ; | |
| 7 | 7 | ; Controls: Q=Up, A=Down, O=Left, P=Right, SPACE=Claim | |
| 8 | 8 | ; ============================================================================ | |
| ... | |||
| 74 | 74 | TITLE_COL equ 12 ; "INK WAR" (7 chars) centred: (32-7)/2=12.5 | |
| 75 | 75 | PROMPT_ROW equ 16 | |
| 76 | 76 | PROMPT_COL equ 5 ; "PRESS ANY KEY TO START" (22 chars): (32-22)/2=5 | |
| 77 | + | | |
| 78 | + | ; Results screen positions | |
| 79 | + | CONTINUE_ROW equ 22 ; "PRESS ANY KEY" after results | |
| 80 | + | CONTINUE_COL equ 9 ; (32-13)/2 = 9.5 | |
| 81 | + | | |
| 82 | + | ; Input timing | |
| 83 | + | KEY_DELAY equ 8 ; Frames between key repeats | |
| 77 | 84 | | |
| 78 | 85 | ; ---------------------------------------------------------------------------- | |
| 79 | 86 | ; Entry Point | |
| ... | |||
| 475 | 482 | ld (cursor_col), a | |
| 476 | 483 | ld (p1_count), a | |
| 477 | 484 | ld (p2_count), a | |
| 485 | + | ld (last_key), a ; No previous key | |
| 486 | + | ld (key_timer), a ; No delay active | |
| 478 | 487 | | |
| 479 | 488 | ret | |
| 480 | 489 | | |
| ... | |||
| 769 | 778 | ; ---------------------------------------------------------------------------- | |
| 770 | 779 | ; Handle Input | |
| 771 | 780 | ; ---------------------------------------------------------------------------- | |
| 781 | + | ; Implements key repeat delay for smooth cursor movement | |
| 772 | 782 | | |
| 773 | 783 | handle_input: | |
| 774 | 784 | ld a, (key_pressed) | |
| 775 | 785 | or a | |
| 776 | - | ret z | |
| 786 | + | jr nz, .hi_have_key | |
| 787 | + | | |
| 788 | + | ; No key pressed - reset tracking | |
| 789 | + | xor a | |
| 790 | + | ld (last_key), a | |
| 791 | + | ld (key_timer), a | |
| 792 | + | ret | |
| 777 | 793 | | |
| 794 | + | .hi_have_key: | |
| 795 | + | ; Space (claim) always works immediately | |
| 778 | 796 | cp 5 | |
| 779 | 797 | jr z, try_claim | |
| 798 | + | | |
| 799 | + | ; Check if same key as last frame | |
| 800 | + | ld b, a ; Save current key | |
| 801 | + | ld a, (last_key) | |
| 802 | + | cp b | |
| 803 | + | jr nz, .hi_new_key | |
| 804 | + | | |
| 805 | + | ; Same key - check timer | |
| 806 | + | ld a, (key_timer) | |
| 807 | + | or a | |
| 808 | + | jr z, .hi_allow ; Timer expired, allow repeat | |
| 809 | + | dec a | |
| 810 | + | ld (key_timer), a | |
| 811 | + | ret ; Still waiting | |
| 812 | + | | |
| 813 | + | .hi_new_key: | |
| 814 | + | ; New key pressed - save it and reset timer | |
| 815 | + | ld a, b | |
| 816 | + | ld (last_key), a | |
| 817 | + | ld a, KEY_DELAY | |
| 818 | + | ld (key_timer), a | |
| 780 | 819 | | |
| 820 | + | .hi_allow: | |
| 821 | + | ; Process movement | |
| 781 | 822 | call clear_cursor | |
| 782 | 823 | | |
| 783 | 824 | ld a, (key_pressed) | |
| ... | |||
| 1084 | 1125 | ld hl, msg_p1_wins | |
| 1085 | 1126 | ld e, P1_TEXT | |
| 1086 | 1127 | call print_message | |
| 1087 | - | ret | |
| 1128 | + | jr .sr_continue | |
| 1088 | 1129 | | |
| 1089 | 1130 | .sr_p2_wins: | |
| 1090 | 1131 | ; Display "P2 WINS!" | |
| ... | |||
| 1093 | 1134 | ld hl, msg_p2_wins | |
| 1094 | 1135 | ld e, P2_TEXT | |
| 1095 | 1136 | call print_message | |
| 1096 | - | ret | |
| 1137 | + | jr .sr_continue | |
| 1097 | 1138 | | |
| 1098 | 1139 | .sr_draw: | |
| 1099 | 1140 | ; Display "DRAW!" | |
| 1100 | 1141 | ld b, RESULT_ROW | |
| 1101 | 1142 | ld c, RESULT_COL + 2 ; Centre "DRAW!" better | |
| 1102 | 1143 | ld hl, msg_draw | |
| 1144 | + | ld e, TEXT_ATTR | |
| 1145 | + | call print_message | |
| 1146 | + | | |
| 1147 | + | .sr_continue: | |
| 1148 | + | ; Display "PRESS ANY KEY" prompt | |
| 1149 | + | ld b, CONTINUE_ROW | |
| 1150 | + | ld c, CONTINUE_COL | |
| 1151 | + | ld hl, msg_continue | |
| 1103 | 1152 | ld e, TEXT_ATTR | |
| 1104 | 1153 | call print_message | |
| 1105 | 1154 | ret | |
| ... | |||
| 1111 | 1160 | ; B = row, C = starting column, E = attribute for message area | |
| 1112 | 1161 | | |
| 1113 | 1162 | print_message: | |
| 1114 | - | push bc | |
| 1115 | - | push de | |
| 1163 | + | ; Save parameters we need later | |
| 1164 | + | push de ; Save attribute in E | |
| 1165 | + | push bc ; Save row (B) and start column (C) | |
| 1116 | 1166 | | |
| 1117 | - | ; First pass: print characters | |
| 1167 | + | ; Print characters | |
| 1118 | 1168 | .pm_loop: | |
| 1119 | 1169 | ld a, (hl) | |
| 1120 | 1170 | or a | |
| ... | |||
| 1125 | 1175 | jr .pm_loop | |
| 1126 | 1176 | | |
| 1127 | 1177 | .pm_done: | |
| 1128 | - | ; Calculate message length | |
| 1129 | - | pop de ; E = attribute | |
| 1178 | + | ; C now has end column | |
| 1179 | + | ; Calculate length: end_col - start_col | |
| 1180 | + | ld a, c ; A = end column | |
| 1130 | 1181 | pop bc ; B = row, C = start column | |
| 1131 | - | push bc | |
| 1132 | - | | |
| 1133 | - | ; Count characters | |
| 1134 | - | ld a, c ; Current column (after printing) | |
| 1135 | - | pop bc ; Get start column back | |
| 1136 | 1182 | sub c ; A = length | |
| 1137 | - | ld b, a ; B = count | |
| 1183 | + | ld d, a ; D = length (save it) | |
| 1138 | 1184 | | |
| 1139 | - | ; Set attributes | |
| 1140 | - | ld a, RESULT_ROW | |
| 1185 | + | ; Set up for set_attr_range: A=row, C=start_col, B=count, E=attr | |
| 1186 | + | ld a, b ; A = row | |
| 1187 | + | ld b, d ; B = count (length) | |
| 1188 | + | pop de ; E = attribute | |
| 1141 | 1189 | call set_attr_range | |
| 1142 | 1190 | | |
| 1143 | 1191 | ret | |
| ... | |||
| 1269 | 1317 | msg_draw: defb "DRAW!", 0 | |
| 1270 | 1318 | msg_title: defb "INK WAR", 0 | |
| 1271 | 1319 | msg_prompt: defb "PRESS ANY KEY TO START", 0 | |
| 1320 | + | msg_continue: defb "PRESS ANY KEY", 0 | |
| 1272 | 1321 | | |
| 1273 | 1322 | ; ---------------------------------------------------------------------------- | |
| 1274 | 1323 | ; Variables | |
| ... | |||
| 1278 | 1327 | cursor_row: defb 0 | |
| 1279 | 1328 | cursor_col: defb 0 | |
| 1280 | 1329 | key_pressed: defb 0 | |
| 1330 | + | last_key: defb 0 ; Previous frame's key for repeat detection | |
| 1331 | + | key_timer: defb 0 ; Countdown for key repeat delay | |
| 1281 | 1332 | current_player: defb 1 | |
| 1282 | 1333 | p1_count: defb 0 | |
| 1283 | 1334 | p2_count: defb 0 |