The First Hall
The thief stood on flat blue. A keep is made of stone, and stone has shade. Meet dithering — mixing two colours in the bitmap to get the shades between them — and build the keep's first room: a dark slate floor inside lit stone walls.
In Unit 1 the thief stood on a flat wash of blue — one attribute byte, the same colour in every cell. It did the job, but it was never going to be a keep. A keep is built of stone, and stone has texture and shade: dark in the depths, lit where the light falls. This unit gives the thief his first room, and the single trick that makes it look like stone.
That trick is dithering, and it's the most useful idea on the whole machine for getting around the Spectrum's two-colours-per-cell limit.
What you'll see by the end
A hall. A floor of dark, mottled slate, walled in by lit blue stone, the thief standing in the middle of it. Two shades of stone — and yet, look closely, both are made from the same two colours: blue and black. No new colours, no attribute trickery. Just a pattern of pixels, and an eye that does the mixing for us.
Two colours, many shades
A cell only ever holds two colours — its INK and its PAPER. We can't put a third in. But the bitmap decides, for each of the cell's 64 pixels, whether it shows INK or PAPER. So if we scatter black INK pixels across blue PAPER, the eye doesn't see individual pixels at arm's length — it blends them into a single perceived shade, somewhere between blue and black. And the density of that scatter sets where between:
PAPER blue, INK black — perceived shade by dither density:
none ████ flat blue (Unit 1's wash — too bright)
sparse ▓▓▓▓ light, lit stone (our walls)
half ▒▒▒▒ dark slate (our floor)
most ░░░░ near-black shadow
This is dithering. One pair of colours, a ramp of shades — and, for free, texture: the floor stops being a flat panel and starts looking like worked stone. Everything Shadowkeep does with light and shadow later is built on this one idea.
The two stone tiles
A tile is just eight bytes — eight rows of eight pixels — drawn into a cell exactly the way Gloaming drew its lantern glyph. Here are our two, and you can read the density straight off the bits:
floor_tile: wall_tile:
defb %10101010 defb %00010001
defb %01010101 defb %00000000
defb %10101010 defb %01000100
defb %01010101 defb %00000000
defb %10101010 defb %00010001
defb %01010101 defb %00000000
defb %10101010 defb %01000100
defb %01010101 defb %00000000
The floor is a 50% checker — every other pixel black — which blends to dark slate. The wall scatters just a few black specks across mostly-blue, so it stays light; and we give the wall the BRIGHT bit, lifting it into a lit, pale stone. Same blue, same black; the density does the rest. Both patterns tile seamlessly — each cell is identical, and a checker continues unbroken across the joins.
WALL_ATTR equ %01001000 ; BRIGHT — lit stone
FLOOR_ATTR equ %00001000 ; dim — dark slate
Drawing a cell, then a room
draw_tile colours a cell and lays its eight bitmap rows. It's Gloaming's glyph-draw, made general — give it a row, a column, a tile and an attribute, and it draws there:
draw_tile:
push bc ; keep row/column for the loop below
call attr_addr_cr
ld a, (tile_attr)
ld (hl), a
call scr_addr_cr
ld de, (tile_ptr) ; fetched after the helpers — they use DE
ld b, 8
.draw_tile_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .draw_tile_row
pop bc
ret
draw_hall then walks every cell of the screen with two nested loops — the same shape as the fill you wrote in Gloaming, row in B, column in C — and pick_tile chooses a wall tile on the edges, floor within:
pick_tile:
ld a, b
or a ; top row?
jr z, .wall
cp LAST_ROW ; bottom row?
jr z, .wall
ld a, c
or a ; left column?
jr z, .wall
cp LAST_COL ; right column?
jr z, .wall
; ... otherwise floor ...
A bordered rectangle of stone: walls around, slate within. The keep's first room.
The thief on the floor
The thief is drawn last, on top, exactly as in Unit 1. One honest detail: his solid sprite replaces the floor in his single cell — there's no seeing the slate through him, because cell-based drawing writes the whole cell. That's the honest version; a later game teaches masking, where a sprite keeps the background showing through its gaps. For now, he stands on the stone, and that's enough.
Milestone — build the hall
Unit 1's flat blue wash becomes a room of stone: draw_hall walks every cell, pick_tile lays a lit wall tile on the edges and a dark slate tile within, and the thief stands on the floor as before. The diff is large because a whole flat wash is replaced by a tiled room — but every new piece is the eight-row glyph-draw you already know, repeated over a grid.
| 1 | 1 | ; Shadowkeep — Unit 2: The First Hall | |
| 2 | 2 | ; Cumulative build; every step runs on its own. Narrative: the unit page. | |
| 3 | - | ; step-00 = Unit 1's end: the dark keep with the thief, on a flat blue wash. | |
| 3 | + | ; step-01 builds the dithered hall — lit stone walls, dark slate floor — around him. | |
| 4 | 4 | | |
| 5 | 5 | org 32768 | |
| 6 | 6 | | |
| 7 | - | STONE equ %00001000 ; PAPER 1 (blue), INK 0 — cold blue stone | |
| 7 | + | ; Two shades of stone, both PAPER 1 (blue) over INK 0 (black). The shade comes | |
| 8 | + | ; from the bitmap dither density; BRIGHT lifts the walls into the light. | |
| 9 | + | WALL_ATTR equ %01001000 ; BRIGHT — lit stone (drawn with a sparse dither) | |
| 10 | + | FLOOR_ATTR equ %00001000 ; dim — dark slate (drawn with a 50% dither) | |
| 8 | 11 | THIEF equ %01001010 ; BRIGHT, PAPER 1 (blue), INK 2 (red) — the thief | |
| 9 | - | HERO_COL equ 15 ; the middle of the keep, as in Gloaming | |
| 12 | + | | |
| 13 | + | HERO_COL equ 15 | |
| 10 | 14 | HERO_ROW equ 11 | |
| 15 | + | | |
| 16 | + | LAST_ROW equ 23 | |
| 17 | + | LAST_COL equ 31 | |
| 11 | 18 | | |
| 12 | 19 | ; ---------------------------------------------------------------------------- | |
| 13 | - | ; SETUP — the keep is dark, and a figure stands in it. | |
| 20 | + | ; SETUP — build the hall, then stand the thief in it. | |
| 14 | 21 | ; ---------------------------------------------------------------------------- | |
| 15 | 22 | start: | |
| 16 | 23 | ld a, 0 | |
| 17 | - | out ($FE), a ; black border — the keep wants the dark | |
| 18 | - | | |
| 19 | - | ld hl, $5800 ; wash all 768 attribute cells in stone | |
| 20 | - | ld de, $5801 | |
| 21 | - | ld (hl), STONE | |
| 22 | - | ld bc, 767 | |
| 23 | - | ldir | |
| 24 | + | out ($FE), a ; black border | |
| 24 | 25 | | |
| 26 | + | call draw_hall | |
| 25 | 27 | call draw_thief | |
| 26 | 28 | | |
| 27 | 29 | im 1 | |
| ... | |||
| 31 | 33 | jr .loop | |
| 32 | 34 | | |
| 33 | 35 | ; ---------------------------------------------------------------------------- | |
| 34 | - | ; draw_thief — colour his cell, then lay his eight rows into the bitmap. | |
| 35 | - | ; Exactly Gloaming's draw_lamp, pointed at a different eight bytes. | |
| 36 | + | ; draw_hall — every cell of the screen: a wall tile around the edge, a floor | |
| 37 | + | ; tile within. Two nested loops, row in B and column in C, exactly the shape of | |
| 38 | + | ; the fill you wrote in Gloaming — but laying eight-byte stone tiles, not one | |
| 39 | + | ; flat attribute byte. | |
| 40 | + | ; ---------------------------------------------------------------------------- | |
| 41 | + | draw_hall: | |
| 42 | + | ld b, 0 | |
| 43 | + | .row: | |
| 44 | + | ld c, 0 | |
| 45 | + | .col: | |
| 46 | + | call pick_tile ; sets tile_ptr + tile_attr for this cell | |
| 47 | + | call draw_tile ; draws it at (B, C) | |
| 48 | + | | |
| 49 | + | inc c | |
| 50 | + | ld a, c | |
| 51 | + | cp LAST_COL + 1 | |
| 52 | + | jr nz, .col | |
| 53 | + | inc b | |
| 54 | + | ld a, b | |
| 55 | + | cp LAST_ROW + 1 | |
| 56 | + | jr nz, .row | |
| 57 | + | ret | |
| 58 | + | | |
| 59 | + | ; pick_tile — wall if this cell is on the edge, floor otherwise. Leaves the | |
| 60 | + | ; choice in tile_ptr / tile_attr. Preserves B (row) and C (column). | |
| 61 | + | pick_tile: | |
| 62 | + | ld a, b | |
| 63 | + | or a ; row 0? | |
| 64 | + | jr z, .wall | |
| 65 | + | cp LAST_ROW ; row 23? | |
| 66 | + | jr z, .wall | |
| 67 | + | ld a, c | |
| 68 | + | or a ; column 0? | |
| 69 | + | jr z, .wall | |
| 70 | + | cp LAST_COL ; column 31? | |
| 71 | + | jr z, .wall | |
| 72 | + | .floor: | |
| 73 | + | ld hl, floor_tile | |
| 74 | + | ld (tile_ptr), hl | |
| 75 | + | ld a, FLOOR_ATTR | |
| 76 | + | ld (tile_attr), a | |
| 77 | + | ret | |
| 78 | + | .wall: | |
| 79 | + | ld hl, wall_tile | |
| 80 | + | ld (tile_ptr), hl | |
| 81 | + | ld a, WALL_ATTR | |
| 82 | + | ld (tile_attr), a | |
| 83 | + | ret | |
| 84 | + | | |
| 85 | + | ; draw_tile — colour cell (B,C), then lay its eight bitmap rows. The address | |
| 86 | + | ; helpers clobber A and DE, so the tile pointer is fetched *after* them. | |
| 87 | + | draw_tile: | |
| 88 | + | push bc | |
| 89 | + | call attr_addr_cr | |
| 90 | + | ld a, (tile_attr) | |
| 91 | + | ld (hl), a | |
| 92 | + | call scr_addr_cr ; B,C still hold row, column | |
| 93 | + | ld de, (tile_ptr) | |
| 94 | + | ld b, 8 | |
| 95 | + | .draw_tile_row: | |
| 96 | + | ld a, (de) | |
| 97 | + | ld (hl), a | |
| 98 | + | inc de | |
| 99 | + | inc h | |
| 100 | + | djnz .draw_tile_row | |
| 101 | + | pop bc | |
| 102 | + | ret | |
| 103 | + | | |
| 104 | + | ; ---------------------------------------------------------------------------- | |
| 105 | + | ; draw_thief — unchanged from Unit 1: colour his cell, lay his eight rows. | |
| 106 | + | ; He stands on the floor; his solid sprite simply replaces the floor in his one | |
| 107 | + | ; cell (cell-based drawing — no see-through. Masking comes much later, in its | |
| 108 | + | ; own game). Save/restore in Unit 3 will protect the floor as he moves. | |
| 36 | 109 | ; ---------------------------------------------------------------------------- | |
| 37 | 110 | draw_thief: | |
| 38 | 111 | ld b, HERO_ROW | |
| 39 | 112 | ld c, HERO_COL | |
| 40 | 113 | call attr_addr_cr | |
| 41 | 114 | ld (hl), THIEF | |
| 42 | - | call scr_addr_cr ; B,C still hold the row and column | |
| 115 | + | call scr_addr_cr | |
| 43 | 116 | ld de, thief | |
| 44 | 117 | ld b, 8 | |
| 45 | - | .dt: | |
| 118 | + | .draw_thief_row: | |
| 46 | 119 | ld a, (de) | |
| 47 | 120 | ld (hl), a | |
| 48 | 121 | inc de | |
| 49 | - | inc h ; next pixel row is one cell-third down: +256 | |
| 50 | - | djnz .dt | |
| 122 | + | inc h | |
| 123 | + | djnz .draw_thief_row | |
| 51 | 124 | ret | |
| 52 | 125 | | |
| 53 | 126 | ; ---------------------------------------------------------------------------- | |
| 54 | - | ; scr_addr_cr / attr_addr_cr — row in B, column in C, address out in HL. | |
| 55 | - | ; Carried verbatim from Gloaming. | |
| 127 | + | ; scr_addr_cr / attr_addr_cr — carried from Gloaming. Row in B, column in C. | |
| 56 | 128 | ; ---------------------------------------------------------------------------- | |
| 57 | 129 | scr_addr_cr: | |
| 58 | 130 | ld a, b | |
| ... | |||
| 86 | 158 | ret | |
| 87 | 159 | | |
| 88 | 160 | ; ---------------------------------------------------------------------------- | |
| 89 | - | ; The hooded thief — eight bytes, drawn here so you can read the figure in the | |
| 90 | - | ; ones and zeros: a pointed hood, a cloaked body, two feet at the hem. | |
| 161 | + | ; The stone tiles. Read the bits: a 1 is a black INK pixel, a 0 is blue PAPER | |
| 162 | + | ; showing through. The floor mixes them half-and-half (dark slate); the wall | |
| 163 | + | ; scatters a few black specks across mostly-blue, BRIGHT stone (lit, light). | |
| 164 | + | ; Both patterns tile seamlessly cell to cell. | |
| 91 | 165 | ; ---------------------------------------------------------------------------- | |
| 166 | + | floor_tile: ; 50% checker — dark slate | |
| 167 | + | defb %10101010 | |
| 168 | + | defb %01010101 | |
| 169 | + | defb %10101010 | |
| 170 | + | defb %01010101 | |
| 171 | + | defb %10101010 | |
| 172 | + | defb %01010101 | |
| 173 | + | defb %10101010 | |
| 174 | + | defb %01010101 | |
| 175 | + | | |
| 176 | + | wall_tile: ; sparse specks — light, lit stone | |
| 177 | + | defb %00010001 | |
| 178 | + | defb %00000000 | |
| 179 | + | defb %01000100 | |
| 180 | + | defb %00000000 | |
| 181 | + | defb %00010001 | |
| 182 | + | defb %00000000 | |
| 183 | + | defb %01000100 | |
| 184 | + | defb %00000000 | |
| 185 | + | | |
| 92 | 186 | thief: | |
| 93 | 187 | defb %00011000 ; ...XX... the hood's peak | |
| 94 | 188 | defb %00111100 ; ..XXXX.. the hood | |
| ... | |||
| 98 | 192 | defb %01111110 ; .XXXXXX. the cloak | |
| 99 | 193 | defb %00111100 ; ..XXXX.. the cloak narrows | |
| 100 | 194 | defb %00100100 ; ..X..X.. two feet at the hem | |
| 195 | + | | |
| 196 | + | tile_ptr: | |
| 197 | + | defw 0 | |
| 198 | + | tile_attr: | |
| 199 | + | defb 0 | |
| 101 | 200 | | |
| 102 | 201 | end start | |
| 103 | 202 | |
The complete program
; Shadowkeep — Unit 2: The First Hall
; Cumulative build; every step runs on its own. Narrative: the unit page.
; step-01 builds the dithered hall — lit stone walls, dark slate floor — around him.
org 32768
; Two shades of stone, both PAPER 1 (blue) over INK 0 (black). The shade comes
; from the bitmap dither density; BRIGHT lifts the walls into the light.
WALL_ATTR equ %01001000 ; BRIGHT — lit stone (drawn with a sparse dither)
FLOOR_ATTR equ %00001000 ; dim — dark slate (drawn with a 50% dither)
THIEF equ %01001010 ; BRIGHT, PAPER 1 (blue), INK 2 (red) — the thief
HERO_COL equ 15
HERO_ROW equ 11
LAST_ROW equ 23
LAST_COL equ 31
; ----------------------------------------------------------------------------
; SETUP — build the hall, then stand the thief in it.
; ----------------------------------------------------------------------------
start:
ld a, 0
out ($FE), a ; black border
call draw_hall
call draw_thief
im 1
ei
.loop:
halt
jr .loop
; ----------------------------------------------------------------------------
; draw_hall — every cell of the screen: a wall tile around the edge, a floor
; tile within. Two nested loops, row in B and column in C, exactly the shape of
; the fill you wrote in Gloaming — but laying eight-byte stone tiles, not one
; flat attribute byte.
; ----------------------------------------------------------------------------
draw_hall:
ld b, 0
.row:
ld c, 0
.col:
call pick_tile ; sets tile_ptr + tile_attr for this cell
call draw_tile ; draws it at (B, C)
inc c
ld a, c
cp LAST_COL + 1
jr nz, .col
inc b
ld a, b
cp LAST_ROW + 1
jr nz, .row
ret
; pick_tile — wall if this cell is on the edge, floor otherwise. Leaves the
; choice in tile_ptr / tile_attr. Preserves B (row) and C (column).
pick_tile:
ld a, b
or a ; row 0?
jr z, .wall
cp LAST_ROW ; row 23?
jr z, .wall
ld a, c
or a ; column 0?
jr z, .wall
cp LAST_COL ; column 31?
jr z, .wall
.floor:
ld hl, floor_tile
ld (tile_ptr), hl
ld a, FLOOR_ATTR
ld (tile_attr), a
ret
.wall:
ld hl, wall_tile
ld (tile_ptr), hl
ld a, WALL_ATTR
ld (tile_attr), a
ret
; draw_tile — colour cell (B,C), then lay its eight bitmap rows. The address
; helpers clobber A and DE, so the tile pointer is fetched *after* them.
draw_tile:
push bc
call attr_addr_cr
ld a, (tile_attr)
ld (hl), a
call scr_addr_cr ; B,C still hold row, column
ld de, (tile_ptr)
ld b, 8
.draw_tile_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .draw_tile_row
pop bc
ret
; ----------------------------------------------------------------------------
; draw_thief — unchanged from Unit 1: colour his cell, lay his eight rows.
; He stands on the floor; his solid sprite simply replaces the floor in his one
; cell (cell-based drawing — no see-through. Masking comes much later, in its
; own game). Save/restore in Unit 3 will protect the floor as he moves.
; ----------------------------------------------------------------------------
draw_thief:
ld b, HERO_ROW
ld c, HERO_COL
call attr_addr_cr
ld (hl), THIEF
call scr_addr_cr
ld de, thief
ld b, 8
.draw_thief_row:
ld a, (de)
ld (hl), a
inc de
inc h
djnz .draw_thief_row
ret
; ----------------------------------------------------------------------------
; scr_addr_cr / attr_addr_cr — carried from Gloaming. Row in B, column in C.
; ----------------------------------------------------------------------------
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
; ----------------------------------------------------------------------------
; The stone tiles. Read the bits: a 1 is a black INK pixel, a 0 is blue PAPER
; showing through. The floor mixes them half-and-half (dark slate); the wall
; scatters a few black specks across mostly-blue, BRIGHT stone (lit, light).
; Both patterns tile seamlessly cell to cell.
; ----------------------------------------------------------------------------
floor_tile: ; 50% checker — dark slate
defb %10101010
defb %01010101
defb %10101010
defb %01010101
defb %10101010
defb %01010101
defb %10101010
defb %01010101
wall_tile: ; sparse specks — light, lit stone
defb %00010001
defb %00000000
defb %01000100
defb %00000000
defb %00010001
defb %00000000
defb %01000100
defb %00000000
thief:
defb %00011000 ; ...XX... the hood's peak
defb %00111100 ; ..XXXX.. the hood
defb %01111110 ; .XXXXXX. hood meets shoulders
defb %01111110 ; .XXXXXX. the cloak
defb %01111110 ; .XXXXXX. the cloak
defb %01111110 ; .XXXXXX. the cloak
defb %00111100 ; ..XXXX.. the cloak narrows
defb %00100100 ; ..X..X.. two feet at the hem
tile_ptr:
defw 0
tile_attr:
defb 0
end start
A dark slate hall walled in lit stone, the thief at its centre. Hold the picture from Unit 1 beside it: same blue, same black, an entirely different place.
Try this: re-shade the floor
floor_tile is a 50% checker. Make it darker — set more bits, say %11101110 / %10111011 alternating — and the floor sinks toward black, a deeper dungeon. Make it sparser — %10001000 / %00100010 — and it lifts toward the wall's shade. You're sliding the floor up and down the shade ramp by changing nothing but pixel density. Find the slate you like.
Try this: a wall that reads as blocks
Our wall is a flat sparse dither. Real keep walls have courses — stone blocks with mortar between. Design a wall_tile with a solid run along the bottom row (%11111111) and a vertical line every few pixels, so repeated cells read as a brick course. Texture and shade are the same tool used two ways: pattern for what the stone is, density for how lit it is.
Try this: a third shade
Add a RUBBLE_ATTR / rubble_tile at a density between wall and floor, and have pick_tile lay a band of it along the bottom row inside the walls — a drift of broken stone. You'll have three readable shades on screen, all from blue and black. That's the whole vocabulary the atmosphere of this keep is built from.
When it's wrong, see why
- The floor is solid blue, no texture.
draw_tileis laying the same byte eight times instead of eight different ones, ortile_ptrpoints at the wrong data. Eachinc demust walk through the tile's eight bytes. - The pattern smears diagonally or streaks. The
inc hafter each row is what steps down the cell. Withinc lyou'd write along one pixel row — a streak, not a tile. - The whole screen is one tile (all wall, or all floor).
pick_tile's edge tests are wrong. Wall when row is0orLAST_ROW, or column is0orLAST_COL; floor otherwise. - The border colours flicker or the room is the wrong size.
LAST_ROW/LAST_COLare 23 and 31 — the last valid cell indices, not 24 and 32. - The checker shimmers unpleasantly on a real TV. A fine 50% dither can buzz on composite video; coarsen it to 2×2 blocks (
%11001100/%00110011) for a calmer texture. On the Next or an emulator it's rock-steady.
Before and after
You started with the thief on a flat blue wash and finished with him standing in a hall of stone — dark slate underfoot, lit walls around — and not one new colour was spent. Two shades came from one pair of colours, the eye doing the blending; texture came free in the bargain; and the whole room is the glyph-draw from Gloaming, made general and walked over a grid. The keep stopped being a colour and started being a place — on the strength of pixel density alone.
What you've learnt
- Dithering beats the two-colour limit. Mixing INK and PAPER pixels in the bitmap makes the eye perceive shades between a cell's two colours.
- Density is shade. Sparse INK reads light, half-and-half reads mid, dense reads dark — a whole ramp from one pair of colours.
- A tile is eight bytes, drawn like a glyph.
draw_tileis Gloaming's glyph-draw made general; a room is that draw, repeated over a grid. - Texture and shade are the same tool. The pattern decides what the stone is; the density decides how lit it is — the foundation for this keep's light and shadow.
- Cell-based sprites replace their cell. The thief covers the floor under him; masking, which keeps the background, is a deliberately later lesson.
What's next
The hall is built, but the thief is rooted to the spot. In Unit 3, "A Place to Move," we bring back Gloaming's keyboard reading, cell-step movement, and wall collision — and set the thief walking the hall. He'll save and restore the dithered stone beneath him as he goes, so the floor survives his passing, and the lit walls will turn him back. The engine you finished in Gloaming, now moving a thief through a room of stone.