Skip to content
Game 1 Unit 8 of 20 1 hr learning time

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.

40% of Gloaming

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 $18 keeps just the two bits that say which third he's in — and those bits already sit in the right place to add to $40 for the high byte.
  • row AND 7 is 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 three RRCAs, rotating those three bits up into positions 5, 6 and 7. OR the 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.

Step 1: up/down on QAOP, and a full (col,row) address routine
+118-66
11 ; Gloaming — Unit 8: Edges
22 ; 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.
44
55 org 32768
66
...
99 LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the figure
1010 WALL_BIT equ 3 ; PAPER bit 0: set on walls, clear on floor
1111
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
1712 START_COL equ 15
13+START_ROW equ 11
1814
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
2018
2119 ; ============================================================================
2220 ; SETUP — runs once.
...
6260 call draw_lamp
6361
6462 ; ============================================================================
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.
6664 ; ============================================================================
6765 im 1
6866 ei
...
7068 game_loop:
7169 halt
7270
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
7477 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
8091
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
89114
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
97122 jr game_loop
98123
99124 ; ----------------------------------------------------------------------------
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.
102129 ; ----------------------------------------------------------------------------
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
109142 ret
110143
111144 ; ----------------------------------------------------------------------------
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.
114147 ; ----------------------------------------------------------------------------
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
121163 ret
122164
123165 ; ----------------------------------------------------------------------------
124-; Address helpers (Unit 6).
166+; wall_at — B=row, C=col of the target. NZ if it's a wall, Z if walkable.
125167 ; ----------------------------------------------------------------------------
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)
132171 ret
133172
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
135179 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
140181 ret
141182
142183 ; ----------------------------------------------------------------------------
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).
144186 ; ----------------------------------------------------------------------------
145187 save_under:
146- call scr_addr
188+ call pos_bc
189+ call scr_addr_cr
147190 ld de, under_lamp
148191 ld b, 8
149192 .su:
...
152195 inc de
153196 inc h
154197 djnz .su
155- call attr_addr
198+ call pos_bc
199+ call attr_addr_cr
156200 ld a, (hl)
157201 ld (under_lamp + 8), a
158202 ret
159203
160204 restore_under:
161- call scr_addr
205+ call pos_bc
206+ call scr_addr_cr
162207 ld de, under_lamp
163208 ld b, 8
164209 .ru:
...
167212 inc de
168213 inc h
169214 djnz .ru
170- call attr_addr
215+ call pos_bc
216+ call attr_addr_cr
171217 ld a, (under_lamp + 8)
172218 ld (hl), a
173219 ret
174220
175221 draw_lamp:
176- call attr_addr
222+ call pos_bc
223+ call attr_addr_cr
177224 ld (hl), LAMP_ATTR
178- call scr_addr
225+ call pos_bc
226+ call scr_addr_cr
179227 ld de, lamplighter
180228 ld b, 8
181229 .dl:
...
191239 ; ----------------------------------------------------------------------------
192240 lamp_col:
193241 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
196248
197249 under_lamp:
198250 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:

Holding P then Q, the lamplighter walks right to the wall and then up to the top, tucking into the corner. The trip from the middle of the screen to the top crosses one of the Spectrum's layout seams — the boundary between two thirds, a jump of over two kilobytes in screen memory — and he never notices.

Drive him into a corner and two walls hold him at once:

The lamplighter tucked into the top-right corner of the square, pressed against both the top and right walls.
The top-right corner: blocked right and up at the same time. He's at the last floor cell in both directions — confirmed by reading his column and row straight out of memory (30, 1).

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_cr is wrong. Check high = $40 + (row AND $18), and that the low byte is (row AND 7) rotated right three times (RRCA×3) then OR 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, so wall_at tested 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.