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

The Lamps

The first real game objects. Keep the lamp positions in a small data table and draw an unlit cyan lantern at each — placement as data, not code. And Unit 6 pays off: the lamplighter walks over them without scrubbing them out.

45% of Gloaming

The movement engine is finished — the lamplighter roams the walled square and keeps whatever he crosses. Now the game begins, and it begins with the thing he's here to do: light the lamps. So first there must be lamps.

A lamp is another cell sprite — a little lantern drawn into a cell — but a still one, sitting on the floor in cold cyan, waiting to be lit. The interesting question isn't how to draw one; we've drawn sprites since Unit 2. It's how to place eight of them without writing eight copies of the same code. The answer is a data table.

Where we start

Unit 8's finished engine — a figure roaming an empty walled square. We give the square its contents: the lamps he's here to light.

Placement is data, not code

We could write draw a lantern at (4,3), draw a lantern at (27,3), eight times over. Instead we write the positions as plain bytes — a small table of column/row pairs — and a loop that draws whatever the table holds:

lamp_data:
    defb 4, 3
    defb 27, 3
    ...
    defb $FF        ; a column of $FF marks the end

draw_lamps walks that table two bytes at a time, drawing an unlit lantern at each pair, and stops when it hits the $FF sentinel. Want a different level? Edit the table — the program doesn't change at all. Separating what (the data) from how (the code) is one of the oldest and best ideas in programming, and a level map is the perfect place to meet it.

A lamp is walkable floor — with pixels

Two small but vital choices. First, the lantern's colour is %00000101 — cyan INK on black PAPER. Black paper means bit 3 is clear, so the Unit 7 wall test reads a lamp as floor: the lamplighter can step onto it, which is the whole point — he lights a lamp by standing on it (Unit 10).

Second, and this is the lovely part: a lamp has pixels, and the lamplighter can walk straight over it without harm. That's Unit 6 paying off in full. When he steps onto a lamp, save_under tucks the lantern's nine bytes into the buffer; while he stands there his figure covers it; when he leaves, restore_under puts the lantern back exactly as it was. The naive blank-erase from Unit 5 would have wiped the lamp out the instant he touched it. Save/restore protects it for nothing.

Milestone — scatter the lamps

We add a lamp_data table of column/row pairs, a draw_lamps loop that places an unlit lantern at each (called in setup, before the lamplighter goes down on top), and the lantern's eight-byte shape. The movement engine, collision, and save/restore are all untouched — the lamps just are the floor now.

Step 1: a lamp data table and the loop that draws it
+94-48
11 ; Gloaming — Unit 9: The Lamps
22 ; Cumulative build; every step runs on its own. Narrative: the unit page.
3-; step-00 is Unit 8's finished movement engine — an empty square to roam.
3+; step-01 scatters lamps from a (col,row) data table; he crosses them unharmed.
44
55 org 32768
66
77 COBBLE equ %00000001 ; PAPER black, INK blue — floor
88 WALL equ %00001111 ; PAPER blue, INK white — solid
99 LAMP_ATTR equ %01000111 ; BRIGHT, PAPER black, INK white — the figure
10-WALL_BIT equ 3 ; PAPER bit 0: set on walls, clear on floor
10+LAMP_UNLIT equ %00000101 ; PAPER black, INK cyan, no bright — a cold, unlit lamp
11+WALL_BIT equ 3
1112
1213 START_COL equ 15
1314 START_ROW equ 11
1415
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
16+KEYS_OP equ $DFFE
17+KEYS_Q equ $FBFE
18+KEYS_A equ $FDFE
1819
1920 ; ============================================================================
2021 ; SETUP — runs once.
...
5657 add hl, de
5758 djnz .sides
5859
59- call save_under
60+ call draw_lamps ; place the lamps from the table
61+ call save_under ; then the lamplighter, on top
6062 call draw_lamp
6163
6264 ; ============================================================================
63-; THE HEARTBEAT — pick a direction (QAOP), test the target, move if clear.
65+; THE HEARTBEAT — QAOP movement (Unit 8), unchanged.
6466 ; ============================================================================
6567 im 1
6668 ei
...
6870 game_loop:
6971 halt
7072
71- ld a, (lamp_col) ; start the target at his current cell
73+ ld a, (lamp_col)
7274 ld (tcol), a
7375 ld a, (lamp_row)
7476 ld (trow), a
7577
76- ld bc, KEYS_OP ; O / P
78+ ld bc, KEYS_OP
7779 in a, (c)
78- bit 1, a ; O — left
80+ bit 1, a
7981 jr z, .left
80- bit 0, a ; P — right
82+ bit 0, a
8183 jr z, .right
82- ld bc, KEYS_Q ; Q — up
84+ ld bc, KEYS_Q
8385 in a, (c)
8486 bit 0, a
8587 jr z, .up
86- ld bc, KEYS_A ; A — down
88+ ld bc, KEYS_A
8789 in a, (c)
8890 bit 0, a
8991 jr z, .down
90- jr game_loop ; nothing held
92+ jr game_loop
9193
9294 .left:
9395 ld hl, tcol
...
105107 ld hl, trow
106108 inc (hl)
107109 .try:
108- ld a, (trow) ; test the target cell
110+ ld a, (trow)
109111 ld b, a
110112 ld a, (tcol)
111113 ld c, a
112- call wall_at ; NZ = wall
113- jr nz, game_loop ; blocked — stay put
114+ call wall_at
115+ jr nz, game_loop
114116
115- call restore_under ; clear — commit the move
117+ call restore_under
116118 ld a, (tcol)
117119 ld (lamp_col), a
118120 ld a, (trow)
...
122124 jr game_loop
123125
124126 ; ----------------------------------------------------------------------------
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.
127+; draw_lamps — walk the table, drawing an unlit lantern at each (col,row).
128+; Table is column,row pairs, ended by a column of $FF.
129+; ----------------------------------------------------------------------------
130+draw_lamps:
131+ ld hl, lamp_data
132+.next:
133+ ld a, (hl) ; column
134+ cp $FF
135+ ret z ; $FF column → end of table
136+ ld c, a
137+ inc hl
138+ ld b, (hl) ; row
139+ inc hl
140+ push hl ; keep the table pointer
141+ call draw_lantern ; draw at (B=row, C=col)
142+ pop hl
143+ jr .next
144+
145+; ----------------------------------------------------------------------------
146+; draw_lantern — B=row, C=col. Stamp an unlit lantern into the cell.
147+; ----------------------------------------------------------------------------
148+draw_lantern:
149+ call attr_addr_cr ; HL = attribute, BC preserved
150+ ld (hl), LAMP_UNLIT
151+ call scr_addr_cr ; HL = screen, BC preserved
152+ ld de, lantern
153+ ld b, 8
154+.dlt:
155+ ld a, (de)
156+ ld (hl), a
157+ inc de
158+ inc h
159+ djnz .dlt
160+ ret
161+
162+; ----------------------------------------------------------------------------
163+; scr_addr_cr / attr_addr_cr / wall_at / pos_bc (Unit 8).
129164 ; ----------------------------------------------------------------------------
130165 scr_addr_cr:
131166 ld a, b
132- and %00011000 ; bits that select the third (= third*8)
133- or %01000000 ; + $40 screen base
167+ and %00011000
168+ or %01000000
134169 ld h, a
135170 ld a, b
136- and %00000111 ; row within third (0-7)
137- rrca ; ×32: rotate the 3 bits up into 5-6-7
171+ and %00000111
138172 rrca
139173 rrca
140- or c ; | column
174+ rrca
175+ or c
141176 ld l, a
142177 ret
143178
144-; ----------------------------------------------------------------------------
145-; attr_addr_cr — B=row, C=col -> HL = attribute address ($5800 + row*32 + col).
146-; Preserves BC.
147-; ----------------------------------------------------------------------------
148179 attr_addr_cr:
149180 ld a, b
150181 ld l, a
151182 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
183+ add hl, hl
184+ add hl, hl
185+ add hl, hl
186+ add hl, hl
187+ add hl, hl
157188 ld de, $5800
158189 add hl, de
159190 ld a, c
160191 ld e, a
161192 ld d, 0
162- add hl, de ; + col
193+ add hl, de
163194 ret
164195
165-; ----------------------------------------------------------------------------
166-; wall_at — B=row, C=col of the target. NZ if it's a wall, Z if walkable.
167-; ----------------------------------------------------------------------------
168196 wall_at:
169197 call attr_addr_cr
170198 bit WALL_BIT, (hl)
171199 ret
172200
173-; ----------------------------------------------------------------------------
174-; pos_bc — load BC with his current position (B=row, C=col).
175-; ----------------------------------------------------------------------------
176201 pos_bc:
177202 ld a, (lamp_row)
178203 ld b, a
...
181206 ret
182207
183208 ; ----------------------------------------------------------------------------
184-; save_under / restore_under / draw_lamp — now position-general (Unit 6 logic,
185-; using the (col,row) address routines).
209+; save_under / restore_under / draw_lamp (Unit 8) — protect whatever is under
210+; him, lamps included.
186211 ; ----------------------------------------------------------------------------
187212 save_under:
188213 call pos_bc
...
235260 ret
236261
237262 ; ----------------------------------------------------------------------------
238-; State, buffer, and shape.
263+; Level data, state, buffer, and shapes.
239264 ; ----------------------------------------------------------------------------
265+lamp_data:
266+ defb 4, 3 ; column, row
267+ defb 27, 3
268+ defb 9, 7
269+ defb 22, 7
270+ defb 6, 15
271+ defb 25, 15
272+ defb 13, 20
273+ defb 18, 20
274+ defb $FF ; end of table
275+
240276 lamp_col:
241277 defb START_COL
242278 lamp_row:
243279 defb START_ROW
244280 tcol:
245- defb 0 ; target column being tested
281+ defb 0
246282 trow:
247- defb 0 ; target row being tested
283+ defb 0
248284
249285 under_lamp:
250286 defb 0, 0, 0, 0, 0, 0, 0, 0, 0
...
258294 defb %00011000 ; ...XX... body
259295 defb %00100100 ; ..X..X.. legs
260296 defb %01000010 ; .X....X. feet
297+
298+lantern:
299+ defb %00011000 ; ...XX... handle top
300+ defb %00100100 ; ..X..X.. handle loop
301+ defb %01111110 ; .XXXXXX. cap
302+ defb %01111110 ; .XXXXXX. glass
303+ defb %01011010 ; .X.XX.X. glass panes
304+ defb %01111110 ; .XXXXXX. glass
305+ defb %01111110 ; .XXXXXX. base
306+ defb %00111100 ; ..XXXX.. foot
261307
262308 end start
263309
The complete program
; Gloaming — Unit 9: The Lamps
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 scatters lamps from a (col,row) data table; he crosses them unharmed.

            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
LAMP_UNLIT  equ     %00000101       ; PAPER black, INK cyan, no bright — a cold, unlit lamp
WALL_BIT    equ     3

START_COL   equ     15
START_ROW   equ     11

KEYS_OP     equ     $DFFE
KEYS_Q      equ     $FBFE
KEYS_A      equ     $FDFE

; ============================================================================
; 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    draw_lamps      ; place the lamps from the table
            call    save_under      ; then the lamplighter, on top
            call    draw_lamp

; ============================================================================
; THE HEARTBEAT — QAOP movement (Unit 8), unchanged.
; ============================================================================
            im      1
            ei

game_loop:
            halt

            ld      a, (lamp_col)
            ld      (tcol), a
            ld      a, (lamp_row)
            ld      (trow), a

            ld      bc, KEYS_OP
            in      a, (c)
            bit     1, a
            jr      z, .left
            bit     0, a
            jr      z, .right
            ld      bc, KEYS_Q
            in      a, (c)
            bit     0, a
            jr      z, .up
            ld      bc, KEYS_A
            in      a, (c)
            bit     0, a
            jr      z, .down
            jr      game_loop

.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)
            ld      b, a
            ld      a, (tcol)
            ld      c, a
            call    wall_at
            jr      nz, game_loop

            call    restore_under
            ld      a, (tcol)
            ld      (lamp_col), a
            ld      a, (trow)
            ld      (lamp_row), a
            call    save_under
            call    draw_lamp
            jr      game_loop

; ----------------------------------------------------------------------------
; draw_lamps — walk the table, drawing an unlit lantern at each (col,row).
;   Table is column,row pairs, ended by a column of $FF.
; ----------------------------------------------------------------------------
draw_lamps:
            ld      hl, lamp_data
.next:
            ld      a, (hl)         ; column
            cp      $FF
            ret     z               ; $FF column → end of table
            ld      c, a
            inc     hl
            ld      b, (hl)         ; row
            inc     hl
            push    hl              ; keep the table pointer
            call    draw_lantern    ; draw at (B=row, C=col)
            pop     hl
            jr      .next

; ----------------------------------------------------------------------------
; draw_lantern — B=row, C=col. Stamp an unlit lantern into the cell.
; ----------------------------------------------------------------------------
draw_lantern:
            call    attr_addr_cr    ; HL = attribute, BC preserved
            ld      (hl), LAMP_UNLIT
            call    scr_addr_cr     ; HL = screen, BC preserved
            ld      de, lantern
            ld      b, 8
.dlt:
            ld      a, (de)
            ld      (hl), a
            inc     de
            inc     h
            djnz    .dlt
            ret

; ----------------------------------------------------------------------------
; scr_addr_cr / attr_addr_cr / wall_at / pos_bc  (Unit 8).
; ----------------------------------------------------------------------------
scr_addr_cr:
            ld      a, b
            and     %00011000
            or      %01000000
            ld      h, a
            ld      a, b
            and     %00000111
            rrca
            rrca
            rrca
            or      c
            ld      l, a
            ret

attr_addr_cr:
            ld      a, b
            ld      l, a
            ld      h, 0
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            add     hl, hl
            ld      de, $5800
            add     hl, de
            ld      a, c
            ld      e, a
            ld      d, 0
            add     hl, de
            ret

wall_at:
            call    attr_addr_cr
            bit     WALL_BIT, (hl)
            ret

pos_bc:
            ld      a, (lamp_row)
            ld      b, a
            ld      a, (lamp_col)
            ld      c, a
            ret

; ----------------------------------------------------------------------------
; save_under / restore_under / draw_lamp  (Unit 8) — protect whatever is under
;   him, lamps included.
; ----------------------------------------------------------------------------
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

; ----------------------------------------------------------------------------
; Level data, state, buffer, and shapes.
; ----------------------------------------------------------------------------
lamp_data:
            defb    4, 3            ; column, row
            defb    27, 3
            defb    9, 7
            defb    22, 7
            defb    6, 15
            defb    25, 15
            defb    13, 20
            defb    18, 20
            defb    $FF             ; end of table

lamp_col:
            defb    START_COL
lamp_row:
            defb    START_ROW
tcol:
            defb    0
trow:
            defb    0

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

lantern:
            defb    %00011000       ; ...XX...   handle top
            defb    %00100100       ; ..X..X..   handle loop
            defb    %01111110       ; .XXXXXX.   cap
            defb    %01111110       ; .XXXXXX.   glass
            defb    %01011010       ; .X.XX.X.   glass panes
            defb    %01111110       ; .XXXXXX.   glass
            defb    %01111110       ; .XXXXXX.   base
            defb    %00111100       ; ..XXXX..   foot

            end     start

Eight cyan lanterns appear from the table, and the lamplighter drops in over the top — the square stops being empty:

The walled square with eight small cyan lanterns scattered around the interior and the white lamplighter standing in the centre.
Eight unlit lanterns, placed from the data table, with the lamplighter among them. Move a lamp, add one, thin them out — it's all in the table, not the code.

And the Unit 6 payoff, caught in the act: walk him straight over a lamp and it survives, untouched and still cold:

The lamplighter walks down and then right, straight across a lantern — it vanishes under him, then reappears intact behind him. Unit 5's blank-erase would have wiped it out; save/restore now protects real pixels, not just blank floor.

When it's wrong, see why

The table and the lamp's colour are where this unit slips:

  • No lamps appear. draw_lamps isn't called in setup, or the table is missing its $FF sentinel. Check the call sits after the walls and before the lamplighter.
  • Lamps appear, then garbage trails off after the last one. The $FF sentinel is missing, so the loop runs past the table into whatever bytes follow. End the table with defb $FF.
  • A lamp stops him like a wall. Its attribute has a non-black PAPER (bit 3 set). LAMP_UNLIT must be black-paper (%00000101) so collision treats it as floor.
  • Walking over a lamp erases it. Your save/restore isn't protecting it — make sure you're drawing him with the Unit 6 save/restore, not an erase.

Before and after

You started with an empty square and finished with a level — eight lamps placed from a table you can rewrite at will, and a lamplighter who crosses them without harm. The placement is data, kept apart from the code that draws it; the protection is Unit 6, finally tested on something with real pixels to lose. The square has a goal now: those eight cold lamps, waiting.

Try this: redraw the level

Change the table. Move a lamp, add a ninth pair, or thin them down to three — edit the defb lines (and keep the $FF at the end). The level redraws itself with no other change. Try a lamp right next to a wall, or a tight cluster in one corner. The placement is yours to author, in data.

Try this: walk over a lamp and watch it live

Steer the lamplighter onto a lamp and off again. The lantern vanishes under him, then reappears, unharmed — proof that save/restore is preserving real pixels now. For contrast, picture Unit 5's blank-erase doing this: the lamp would be gone the moment he stepped on it. This is the bug Unit 6 existed to prevent, caught in the act of not happening.

Try this: reshape the lantern

Edit the eight lantern bytes. Give it a taller chimney, a wider base, a brighter pane pattern. Sketch the eight rows, write the bytes, and every lamp on the board changes at once — because they all draw from the one shape. One glyph, eight lamps.

What you've learnt

  • Game objects can be placed from a data table, not hard-coded — what (data) kept separate from how (code).
  • A loop walks a table of pairs with a sentinel ($FF) marking the end.
  • Save/restore now protects real pixels: the lamplighter crosses lamps unharmed — Unit 6's reason for existing.
  • Lamps are walkable floor-with-pixels (black PAPER), so he can stand on them — which the next unit needs.

What's next

The lamps are placed and cold. In Unit 10 the lamplighter lights them: step onto an unlit lamp and mark it lit — bright yellow — by changing the same bytes save_under is holding, so when he walks on the lamp glows behind him. The background beneath the sprite stops being scenery and becomes state he can change: the heart of how this little game is won.