Edges
Complete the movement engine: up and down as well as left and right. Moving between rows brings back Unit 2's full screen-address arithmetic, and the four walls bound him — he roams the whole square, properly shut inside it.
The lamplighter walks left and right, stopped by the side walls. This unit gives him the other two directions — up and down — and shuts every edge. By the end he roams the whole square and can't leave it. The cell-sprite technique, four units in the making, is finished here.
The controls become the classic QAOP: Q up, A down, O left, P right. But the real work is underneath. The moment he can change rows, the address shortcut we've leaned on since Unit 5 breaks — and Unit 2's awkward screen layout comes back to be dealt with properly.
Where we start
Unit 7's walker — penned left and right by the side walls, but stuck on a single row, with a one-row address shortcut baked in. We generalise the addressing and open up the other two directions.
Why one row was a shortcut
While he stayed on one row, every cell he touched shared the same screen "third" and the same row-within-third — so his address was ROW_SCR + col, a constant plus the column. The instant he moves to a different row, that constant is wrong, because the Spectrum stores rows in three separate bands of memory (the thirds from Unit 2). We need the full address of any cell, worked out fresh each time.
It splits into two clean bytes. For a cell at column col, row row (0–23):
high byte = $40 + (row AND $18) ; which third, ×8 in the high byte
low byte = ((row AND 7) << 5) OR col ; row-within-third ×32, plus the column
row AND $18keeps just the two bits that say which third he's in — and those bits already sit in the right place to add to$40for the high byte.row AND 7is the row within that third (0–7). Shifting it left 5 multiplies by 32 — and a left-shift-by-5 of a 3-bit value is threeRRCAs, rotating those three bits up into positions 5, 6 and 7.ORthe column into the gap below and that's the low byte.
scr_addr_cr is that recipe. The attribute address stays the linear one — $5800 + row*32 + col — built with five ADD HL,HL doublings.
Collision didn't change at all
Here's the quiet reward for doing Unit 7 properly: none of the collision code changes. wall_at already took an arbitrary target cell and BIT-tested its attribute. Now the target can be a row above or below instead of a column left or right — same test, same refusal. The top and bottom walls stop him exactly as the sides do, because they're made of the same solid cells. Four directions, one collision rule, no special cases for "the edge".
Milestone — complete the engine
The loop reads QAOP in turn, nudges a target cell (tcol/trow) one step in the chosen direction, then runs the unchanged test-and-commit: if the target is a wall, stay put; otherwise restore, adopt the target, save, draw. Save, restore and draw all route through the new (col, row) address routines now, so they follow him between rows. Holding two keys means the first one checked wins — clean four-way movement, no diagonals to reason about.
| 1 | 1 | ; Gloaming — Unit 8: Edges | |
| 2 | 2 | ; Cumulative build; every step runs on its own. Narrative: the unit page. | |
| 3 | - | ; step-00 is Unit 7's walker — left/right only, bounded by the side walls. | |
| 3 | + | ; step-01 completes the engine: up/down (QAOP) + full (col,row) addressing. | |
| 4 | 4 | | |
| 5 | 5 | org 32768 | |
| 6 | 6 | | |
| ... | |||
| 9 | 9 | LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the figure | |
| 10 | 10 | WALL_BIT equ 3 ; PAPER bit 0: set on walls, clear on floor | |
| 11 | 11 | | |
| 12 | - | ROW equ 11 | |
| 13 | - | ROW_THIRD equ ROW / 8 | |
| 14 | - | ROW_CHARROW equ ROW - ROW_THIRD * 8 | |
| 15 | - | ROW_SCR equ $4000 + ROW_THIRD * $0800 + ROW_CHARROW * 32 | |
| 16 | - | ROW_ATTR equ $5800 + ROW * 32 | |
| 17 | 12 | START_COL equ 15 | |
| 13 | + | START_ROW equ 11 | |
| 18 | 14 | | |
| 19 | - | KEYS_OP equ $DFFE | |
| 15 | + | KEYS_OP equ $DFFE ; O (bit1) left, P (bit0) right | |
| 16 | + | KEYS_Q equ $FBFE ; Q (bit0) up | |
| 17 | + | KEYS_A equ $FDFE ; A (bit0) down | |
| 20 | 18 | | |
| 21 | 19 | ; ============================================================================ | |
| 22 | 20 | ; SETUP — runs once. | |
| ... | |||
| 62 | 60 | call draw_lamp | |
| 63 | 61 | | |
| 64 | 62 | ; ============================================================================ | |
| 65 | - | ; THE HEARTBEAT — test the target cell, move only if it isn't a wall. | |
| 63 | + | ; THE HEARTBEAT — pick a direction (QAOP), test the target, move if clear. | |
| 66 | 64 | ; ============================================================================ | |
| 67 | 65 | im 1 | |
| 68 | 66 | ei | |
| ... | |||
| 70 | 68 | game_loop: | |
| 71 | 69 | halt | |
| 72 | 70 | | |
| 73 | - | ld bc, KEYS_OP | |
| 71 | + | ld a, (lamp_col) ; start the target at his current cell | |
| 72 | + | ld (tcol), a | |
| 73 | + | ld a, (lamp_row) | |
| 74 | + | ld (trow), a | |
| 75 | + | | |
| 76 | + | ld bc, KEYS_OP ; O / P | |
| 74 | 77 | in a, (c) | |
| 75 | - | bit 1, a ; O (left)? | |
| 76 | - | jr z, .step_left | |
| 77 | - | bit 0, a ; P (right)? | |
| 78 | - | jr z, .step_right | |
| 79 | - | jr game_loop | |
| 78 | + | bit 1, a ; O — left | |
| 79 | + | jr z, .left | |
| 80 | + | bit 0, a ; P — right | |
| 81 | + | jr z, .right | |
| 82 | + | ld bc, KEYS_Q ; Q — up | |
| 83 | + | in a, (c) | |
| 84 | + | bit 0, a | |
| 85 | + | jr z, .up | |
| 86 | + | ld bc, KEYS_A ; A — down | |
| 87 | + | in a, (c) | |
| 88 | + | bit 0, a | |
| 89 | + | jr z, .down | |
| 90 | + | jr game_loop ; nothing held | |
| 80 | 91 | | |
| 81 | - | .step_left: | |
| 82 | - | ld a, (lamp_col) | |
| 83 | - | dec a ; A = the cell he wants to enter | |
| 84 | - | call wall_at ; NZ = wall, Z = clear (A preserved) | |
| 85 | - | jr nz, game_loop ; wall — refuse the step | |
| 86 | - | ld (target_col), a | |
| 87 | - | call commit_move | |
| 88 | - | jr game_loop | |
| 92 | + | .left: | |
| 93 | + | ld hl, tcol | |
| 94 | + | dec (hl) | |
| 95 | + | jr .try | |
| 96 | + | .right: | |
| 97 | + | ld hl, tcol | |
| 98 | + | inc (hl) | |
| 99 | + | jr .try | |
| 100 | + | .up: | |
| 101 | + | ld hl, trow | |
| 102 | + | dec (hl) | |
| 103 | + | jr .try | |
| 104 | + | .down: | |
| 105 | + | ld hl, trow | |
| 106 | + | inc (hl) | |
| 107 | + | .try: | |
| 108 | + | ld a, (trow) ; test the target cell | |
| 109 | + | ld b, a | |
| 110 | + | ld a, (tcol) | |
| 111 | + | ld c, a | |
| 112 | + | call wall_at ; NZ = wall | |
| 113 | + | jr nz, game_loop ; blocked — stay put | |
| 89 | 114 | | |
| 90 | - | .step_right: | |
| 91 | - | ld a, (lamp_col) | |
| 92 | - | inc a | |
| 93 | - | call wall_at | |
| 94 | - | jr nz, game_loop | |
| 95 | - | ld (target_col), a | |
| 96 | - | call commit_move | |
| 115 | + | call restore_under ; clear — commit the move | |
| 116 | + | ld a, (tcol) | |
| 117 | + | ld (lamp_col), a | |
| 118 | + | ld a, (trow) | |
| 119 | + | ld (lamp_row), a | |
| 120 | + | call save_under | |
| 121 | + | call draw_lamp | |
| 97 | 122 | jr game_loop | |
| 98 | 123 | | |
| 99 | 124 | ; ---------------------------------------------------------------------------- | |
| 100 | - | ; wall_at — A = column to test. Reads that cell's attribute and BIT-tests it. | |
| 101 | - | ; Returns NZ if it's a wall, Z if walkable. Leaves A unchanged. | |
| 125 | + | ; scr_addr_cr — B=row(0-23), C=col(0-31) -> HL = top-scanline screen address. | |
| 126 | + | ; high = $40 + (row AND $18) (the third, ×8 in the high byte) | |
| 127 | + | ; low = ((row AND 7) << 5) OR col (row-within-third ×32, plus column) | |
| 128 | + | ; Preserves BC. | |
| 102 | 129 | ; ---------------------------------------------------------------------------- | |
| 103 | - | wall_at: | |
| 104 | - | ld e, a | |
| 105 | - | ld d, 0 | |
| 106 | - | ld hl, ROW_ATTR | |
| 107 | - | add hl, de | |
| 108 | - | bit WALL_BIT, (hl) ; set = wall, clear = floor | |
| 130 | + | scr_addr_cr: | |
| 131 | + | ld a, b | |
| 132 | + | and %00011000 ; bits that select the third (= third*8) | |
| 133 | + | or %01000000 ; + $40 screen base | |
| 134 | + | ld h, a | |
| 135 | + | ld a, b | |
| 136 | + | and %00000111 ; row within third (0-7) | |
| 137 | + | rrca ; ×32: rotate the 3 bits up into 5-6-7 | |
| 138 | + | rrca | |
| 139 | + | rrca | |
| 140 | + | or c ; | column | |
| 141 | + | ld l, a | |
| 109 | 142 | ret | |
| 110 | 143 | | |
| 111 | 144 | ; ---------------------------------------------------------------------------- | |
| 112 | - | ; commit_move — the move is clear: restore old cell, adopt target_col, save and | |
| 113 | - | ; draw the new one. | |
| 145 | + | ; attr_addr_cr — B=row, C=col -> HL = attribute address ($5800 + row*32 + col). | |
| 146 | + | ; Preserves BC. | |
| 114 | 147 | ; ---------------------------------------------------------------------------- | |
| 115 | - | commit_move: | |
| 116 | - | call restore_under | |
| 117 | - | ld a, (target_col) | |
| 118 | - | ld (lamp_col), a | |
| 119 | - | call save_under | |
| 120 | - | call draw_lamp | |
| 148 | + | attr_addr_cr: | |
| 149 | + | ld a, b | |
| 150 | + | ld l, a | |
| 151 | + | ld h, 0 | |
| 152 | + | add hl, hl ; ×2 | |
| 153 | + | add hl, hl ; ×4 | |
| 154 | + | add hl, hl ; ×8 | |
| 155 | + | add hl, hl ; ×16 | |
| 156 | + | add hl, hl ; ×32 -> HL = row*32 | |
| 157 | + | ld de, $5800 | |
| 158 | + | add hl, de | |
| 159 | + | ld a, c | |
| 160 | + | ld e, a | |
| 161 | + | ld d, 0 | |
| 162 | + | add hl, de ; + col | |
| 121 | 163 | ret | |
| 122 | 164 | | |
| 123 | 165 | ; ---------------------------------------------------------------------------- | |
| 124 | - | ; Address helpers (Unit 6). | |
| 166 | + | ; wall_at — B=row, C=col of the target. NZ if it's a wall, Z if walkable. | |
| 125 | 167 | ; ---------------------------------------------------------------------------- | |
| 126 | - | scr_addr: | |
| 127 | - | ld a, (lamp_col) | |
| 128 | - | ld e, a | |
| 129 | - | ld d, 0 | |
| 130 | - | ld hl, ROW_SCR | |
| 131 | - | add hl, de | |
| 168 | + | wall_at: | |
| 169 | + | call attr_addr_cr | |
| 170 | + | bit WALL_BIT, (hl) | |
| 132 | 171 | ret | |
| 133 | 172 | | |
| 134 | - | attr_addr: | |
| 173 | + | ; ---------------------------------------------------------------------------- | |
| 174 | + | ; pos_bc — load BC with his current position (B=row, C=col). | |
| 175 | + | ; ---------------------------------------------------------------------------- | |
| 176 | + | pos_bc: | |
| 177 | + | ld a, (lamp_row) | |
| 178 | + | ld b, a | |
| 135 | 179 | ld a, (lamp_col) | |
| 136 | - | ld e, a | |
| 137 | - | ld d, 0 | |
| 138 | - | ld hl, ROW_ATTR | |
| 139 | - | add hl, de | |
| 180 | + | ld c, a | |
| 140 | 181 | ret | |
| 141 | 182 | | |
| 142 | 183 | ; ---------------------------------------------------------------------------- | |
| 143 | - | ; save_under / restore_under / draw_lamp (Unit 6). | |
| 184 | + | ; save_under / restore_under / draw_lamp — now position-general (Unit 6 logic, | |
| 185 | + | ; using the (col,row) address routines). | |
| 144 | 186 | ; ---------------------------------------------------------------------------- | |
| 145 | 187 | save_under: | |
| 146 | - | call scr_addr | |
| 188 | + | call pos_bc | |
| 189 | + | call scr_addr_cr | |
| 147 | 190 | ld de, under_lamp | |
| 148 | 191 | ld b, 8 | |
| 149 | 192 | .su: | |
| ... | |||
| 152 | 195 | inc de | |
| 153 | 196 | inc h | |
| 154 | 197 | djnz .su | |
| 155 | - | call attr_addr | |
| 198 | + | call pos_bc | |
| 199 | + | call attr_addr_cr | |
| 156 | 200 | ld a, (hl) | |
| 157 | 201 | ld (under_lamp + 8), a | |
| 158 | 202 | ret | |
| 159 | 203 | | |
| 160 | 204 | restore_under: | |
| 161 | - | call scr_addr | |
| 205 | + | call pos_bc | |
| 206 | + | call scr_addr_cr | |
| 162 | 207 | ld de, under_lamp | |
| 163 | 208 | ld b, 8 | |
| 164 | 209 | .ru: | |
| ... | |||
| 167 | 212 | inc de | |
| 168 | 213 | inc h | |
| 169 | 214 | djnz .ru | |
| 170 | - | call attr_addr | |
| 215 | + | call pos_bc | |
| 216 | + | call attr_addr_cr | |
| 171 | 217 | ld a, (under_lamp + 8) | |
| 172 | 218 | ld (hl), a | |
| 173 | 219 | ret | |
| 174 | 220 | | |
| 175 | 221 | draw_lamp: | |
| 176 | - | call attr_addr | |
| 222 | + | call pos_bc | |
| 223 | + | call attr_addr_cr | |
| 177 | 224 | ld (hl), LAMP_ATTR | |
| 178 | - | call scr_addr | |
| 225 | + | call pos_bc | |
| 226 | + | call scr_addr_cr | |
| 179 | 227 | ld de, lamplighter | |
| 180 | 228 | ld b, 8 | |
| 181 | 229 | .dl: | |
| ... | |||
| 191 | 239 | ; ---------------------------------------------------------------------------- | |
| 192 | 240 | lamp_col: | |
| 193 | 241 | defb START_COL | |
| 194 | - | target_col: | |
| 195 | - | defb 0 | |
| 242 | + | lamp_row: | |
| 243 | + | defb START_ROW | |
| 244 | + | tcol: | |
| 245 | + | defb 0 ; target column being tested | |
| 246 | + | trow: | |
| 247 | + | defb 0 ; target row being tested | |
| 196 | 248 | | |
| 197 | 249 | under_lamp: | |
| 198 | 250 | defb 0, 0, 0, 0, 0, 0, 0, 0, 0 |
The complete program
; Gloaming — Unit 8: Edges
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 completes the engine: up/down (QAOP) + full (col,row) addressing.
org 32768
COBBLE equ %00000001 ; PAPER black, INK blue — floor
WALL equ %00001111 ; PAPER blue, INK white — solid
LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the figure
WALL_BIT equ 3 ; PAPER bit 0: set on walls, clear on floor
START_COL equ 15
START_ROW equ 11
KEYS_OP equ $DFFE ; O (bit1) left, P (bit0) right
KEYS_Q equ $FBFE ; Q (bit0) up
KEYS_A equ $FDFE ; A (bit0) down
; ============================================================================
; SETUP — runs once.
; ============================================================================
start:
ld a, 0 ; border black
out ($FE), a
ld hl, $5800 ; wash the grid in cobbles
ld de, $5801
ld (hl), COBBLE
ld bc, 767
ldir
ld hl, $5800 ; wall frame — top row
ld b, 32
.top:
ld (hl), WALL
inc hl
djnz .top
ld hl, $5AE0 ; bottom row
ld b, 32
.bottom:
ld (hl), WALL
inc hl
djnz .bottom
ld hl, $5800 ; left and right columns
ld b, 24
.sides:
ld (hl), WALL
push hl
ld de, 31
add hl, de
ld (hl), WALL
pop hl
ld de, 32
add hl, de
djnz .sides
call save_under
call draw_lamp
; ============================================================================
; THE HEARTBEAT — pick a direction (QAOP), test the target, move if clear.
; ============================================================================
im 1
ei
game_loop:
halt
ld a, (lamp_col) ; start the target at his current cell
ld (tcol), a
ld a, (lamp_row)
ld (trow), a
ld bc, KEYS_OP ; O / P
in a, (c)
bit 1, a ; O — left
jr z, .left
bit 0, a ; P — right
jr z, .right
ld bc, KEYS_Q ; Q — up
in a, (c)
bit 0, a
jr z, .up
ld bc, KEYS_A ; A — down
in a, (c)
bit 0, a
jr z, .down
jr game_loop ; nothing held
.left:
ld hl, tcol
dec (hl)
jr .try
.right:
ld hl, tcol
inc (hl)
jr .try
.up:
ld hl, trow
dec (hl)
jr .try
.down:
ld hl, trow
inc (hl)
.try:
ld a, (trow) ; test the target cell
ld b, a
ld a, (tcol)
ld c, a
call wall_at ; NZ = wall
jr nz, game_loop ; blocked — stay put
call restore_under ; clear — commit the move
ld a, (tcol)
ld (lamp_col), a
ld a, (trow)
ld (lamp_row), a
call save_under
call draw_lamp
jr game_loop
; ----------------------------------------------------------------------------
; scr_addr_cr — B=row(0-23), C=col(0-31) -> HL = top-scanline screen address.
; high = $40 + (row AND $18) (the third, ×8 in the high byte)
; low = ((row AND 7) << 5) OR col (row-within-third ×32, plus column)
; Preserves BC.
; ----------------------------------------------------------------------------
scr_addr_cr:
ld a, b
and %00011000 ; bits that select the third (= third*8)
or %01000000 ; + $40 screen base
ld h, a
ld a, b
and %00000111 ; row within third (0-7)
rrca ; ×32: rotate the 3 bits up into 5-6-7
rrca
rrca
or c ; | column
ld l, a
ret
; ----------------------------------------------------------------------------
; attr_addr_cr — B=row, C=col -> HL = attribute address ($5800 + row*32 + col).
; Preserves BC.
; ----------------------------------------------------------------------------
attr_addr_cr:
ld a, b
ld l, a
ld h, 0
add hl, hl ; ×2
add hl, hl ; ×4
add hl, hl ; ×8
add hl, hl ; ×16
add hl, hl ; ×32 -> HL = row*32
ld de, $5800
add hl, de
ld a, c
ld e, a
ld d, 0
add hl, de ; + col
ret
; ----------------------------------------------------------------------------
; wall_at — B=row, C=col of the target. NZ if it's a wall, Z if walkable.
; ----------------------------------------------------------------------------
wall_at:
call attr_addr_cr
bit WALL_BIT, (hl)
ret
; ----------------------------------------------------------------------------
; pos_bc — load BC with his current position (B=row, C=col).
; ----------------------------------------------------------------------------
pos_bc:
ld a, (lamp_row)
ld b, a
ld a, (lamp_col)
ld c, a
ret
; ----------------------------------------------------------------------------
; save_under / restore_under / draw_lamp — now position-general (Unit 6 logic,
; using the (col,row) address routines).
; ----------------------------------------------------------------------------
save_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.su:
ld a, (hl)
ld (de), a
inc de
inc h
djnz .su
call pos_bc
call attr_addr_cr
ld a, (hl)
ld (under_lamp + 8), a
ret
restore_under:
call pos_bc
call scr_addr_cr
ld de, under_lamp
ld b, 8
.ru:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .ru
call pos_bc
call attr_addr_cr
ld a, (under_lamp + 8)
ld (hl), a
ret
draw_lamp:
call pos_bc
call attr_addr_cr
ld (hl), LAMP_ATTR
call pos_bc
call scr_addr_cr
ld de, lamplighter
ld b, 8
.dl:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .dl
ret
; ----------------------------------------------------------------------------
; State, buffer, and shape.
; ----------------------------------------------------------------------------
lamp_col:
defb START_COL
lamp_row:
defb START_ROW
tcol:
defb 0 ; target column being tested
trow:
defb 0 ; target row being tested
under_lamp:
defb 0, 0, 0, 0, 0, 0, 0, 0, 0
lamplighter:
defb %00111100 ; ..XXXX.. head
defb %00111100 ; ..XXXX.. head
defb %00011000 ; ...XX... neck
defb %01111110 ; .XXXXXX. arms
defb %00011000 ; ...XX... body
defb %00011000 ; ...XX... body
defb %00100100 ; ..X..X.. legs
defb %01000010 ; .X....X. feet
end start
Now Q, A, O and P walk him in all four directions, and every wall stops him — he roams the square and is trapped by it at once:
Drive him into a corner and two walls hold him at once:
When it's wrong, see why
Vertical movement is where the new arithmetic shows its bugs:
- He teleports or scrambles when moving up or down.
scr_addr_cris wrong. Checkhigh = $40 + (row AND $18), and that the low byte is(row AND 7)rotated right three times (RRCA×3) thenOR col. - Left/right is fine but vertical is offset by a band. You're mixing the screen and attribute formulas, or the third bits aren't masked with
$18. - He walks through the top or bottom wall. The vertical step didn't update
trow, sowall_attested the wrong cell. Up/down must change the target row. - He smears a trail when moving vertically. Save/restore are still using a fixed-row address. They must go through the
(col, row)routines so they follow him between rows.
Before and after
You started with a figure penned to one row and finished with one that roams the whole square and can't escape it. The new piece is one routine — turn any (col, row) into a screen address — and everything else you already had: the collision from Unit 7 and the save/restore from Unit 6 worked unchanged, because they were always per-cell. The cell-sprite engine is complete: a figure that moves anywhere, respects every wall, and damages nothing it crosses.
Try this: a pillar to walk around
Drop a solid block in the middle of the floor, in setup:
ld hl, $5800 + 11*32 + 15
ld (hl), WALL
Now he can't pass through the centre — he has to go around it, up or down and back. The collision that bounds the square bounds a single interior cell just the same. You've made the first piece of real level geometry.
Try this: cross the seam on purpose
Start him at START_ROW equ 7 and walk down one cell to row 8. That step crosses from the top third into the middle third — a jump of over two kilobytes in screen memory — yet he moves by one tidy cell with no glitch. scr_addr_cr hides the seam completely. Move up and down across it a few times and watch how ordinary the hardest part of the screen layout now looks.
Try this: cursor keys instead
Rework the reads to use the cursor keys: 5 (left), 6 (down), 7 (up), 8 (right). 5 lives in the half-row at $F7FE; 6, 7 and 8 share $EFFE. Map each to its bit and you've reskinned the controls without touching the movement engine — proof the input and the engine are cleanly separate.
What you've learnt
- Moving between rows needs the full
(col, row)→ address arithmetic; the single-row shortcut only held within one row. - The screen address splits into third (high byte) and row-within-third × 32 + column (low byte) — and a left-shift-by-5 is three
RRCAs. - Collision and save/restore didn't change — they were always per-cell; only the address routine generalised.
- The four walls bound him with no special edge code — solid is enough, in every direction.
What's next
The engine is done: a figure that roams a walled room, preserving everything it crosses. Now the game begins. In Unit 9 we scatter the first real game objects — the lamps: unlit cells with a lantern glyph and a cold cyan colour, placed from a small data table. The square stops being empty, and the lamplighter finally has something to do.