Exit Door
A cyan cell in the bottom wall marks the way out. A new attribute constant defines the exit — visible from the start, a goal the player can see and walk towards.
The room has walls, a floor, treasure, and a hazard. But no exit. Nowhere to go after collecting everything. The player walks around, the border turns green, and… that’s it. There’s no goal to reach, no doorway to walk through.
A game needs a destination. Something the player can see and aim for. An exit door.
One More Attribute
The attribute system already encodes the game world. Wall, floor, treasure, hazard — each is an attribute value. The exit is another one. Pick a colour that stands out against everything else:
- Walls are blue (
$09) - Floor is white (
$38) - Treasure is bright yellow (
$70) - Hazard is flashing red (
$90)
Cyan paper works. It’s different from everything else, clearly visible in the wall, and reads as “doorway” against the blue surround.
EXIT equ $28 ; PAPER 5 (cyan) + INK 0
That’s $28 — PAPER 5 (cyan), no BRIGHT, no FLASH, INK 0 (black). The INK is 0, which means the collision check (AND $07 / CP WALL_INK) won’t treat it as a wall. The player can walk onto it freely.
Placing the Exit
; Exit door — a new attribute value
;
; The room needs a goal. An exit door, marked by a colour
; the player can see but hasn't encountered before.
;
; Cyan paper stands out against white floors and blue walls:
EXIT equ $28 ; PAPER 5 (cyan) + INK 0
; Room data now includes the exit cell:
room_data:
db WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL
db WALL, FLOOR, WALL, FLOOR, FLOOR, FLOOR, TREASURE, FLOOR, WALL
db WALL, FLOOR, TREASURE, FLOOR, FLOOR, FLOOR, WALL, FLOOR, WALL
db WALL, FLOOR, FLOOR, FLOOR, HAZARD, FLOOR, FLOOR, TREASURE, WALL
db WALL, WALL, WALL, WALL, WALL, EXIT, WALL, WALL, WALL
; The exit is in the bottom wall (row 4, col 5 of the room).
; It's visible from the start — a cyan gap in the blue wall.
; The player can see where they need to go.
The exit replaces one wall cell in the bottom row. It sits at row 4, column 5 of the room data — a gap in the perimeter. The cyan colour contrasts with the surrounding blue walls, making it obvious where the player needs to go.
The rest of the room is unchanged. Same three treasures, same hazard, same corridors. The only difference is one byte in the data table: EXIT where WALL used to be.
Why It Already Works
The exit doesn’t need any special movement code. The existing collision check tests whether the target cell’s INK colour equals WALL_INK (1). The exit has INK 0 — same as floor, treasure, and hazard. The player walks onto it like any non-wall cell.
ld a, (hl) ; Read target attribute
and $07 ; Isolate INK (bits 0-2)
cp WALL_INK ; INK 1 = wall?
jr z, .not_q ; Yes — blocked
This is the power of the attribute system as a game design tool. Adding a new cell type doesn’t require changing the movement code. The collision test is about what blocks movement (walls), not what allows it. Everything that isn’t a wall is walkable — including the exit.
The Complete Code
; ============================================================================
; SHADOWKEEP — Unit 13: Exit Door
; ============================================================================
; Every room needs a goal. The exit door is a new attribute colour — cyan
; paper ($28) — that stands out against the white floor and blue walls.
;
; The exit sits in the bottom wall, visible from the start. The player
; can walk onto it (INK 0 passes the wall check). It doesn't do anything
; yet — Unit 14 adds the win condition.
;
; The room layout is the same structure, but with one wall cell replaced
; by the exit. A gap in the blue border, filled with cyan.
; ============================================================================
org 32768
; Attribute values
WALL equ $09 ; PAPER 1 (blue) + INK 1
FLOOR equ $38 ; PAPER 7 (white) + INK 0
TREASURE equ $70 ; BRIGHT + PAPER 6 (yellow) + INK 0
HAZARD equ $90 ; FLASH + PAPER 2 (red) + INK 0
EXIT equ $28 ; PAPER 5 (cyan) + INK 0
PLAYER equ $3a ; PAPER 7 (white) + INK 2 (red)
; Collision
WALL_INK equ 1 ; INK colour that means "wall"
; Room
ROOM_TOP equ 10
ROOM_LEFT equ 12
ROOM_WIDTH equ 9
ROOM_HEIGHT equ 5
ROW_SKIP equ 23 ; 32 - ROOM_WIDTH
TOTAL_TREASURE equ 3 ; Treasures in the room
; Screen addresses for starting position (row 12, col 13)
START_ROW equ 12
START_COL equ 13
START_SCR equ $488d
START_ATT equ $598d
; Score display position (row 23, col 10)
SCORE_SCR equ $50ea ; Screen bitmap: row 23, col 10
SCORE_ATT equ $5aea ; Attribute: row 23, col 10
SCORE_LEN equ 12 ; "TREASURE n/3" = 12 characters
; Keyboard rows
KEY_ROW_QT equ $fb ; Q, W, E, R, T
KEY_ROW_AG equ $fd ; A, S, D, F, G
KEY_ROW_PY equ $df ; P, O, I, U, Y
; ROM font
FONT_BASE equ $3c00 ; Character set in ROM
; ----------------------------------------------------------------------------
; Entry point
; ----------------------------------------------------------------------------
start:
ld a, 0
out ($fe), a
; Clear screen
ld hl, $4000
ld de, $4001
ld bc, 6911
ld (hl), 0
ldir
; ==================================================================
; Draw room from data table
; ==================================================================
ld hl, $594c ; Attribute address: row 10, col 12
ld de, room_data
ld c, ROOM_HEIGHT
.row: ld b, ROOM_WIDTH
.cell: ld a, (de)
ld (hl), a
inc de
inc hl
djnz .cell
push de
ld de, ROW_SKIP
add hl, de
pop de
dec c
jr nz, .row
; ==================================================================
; Draw the player at starting position
; ==================================================================
ld a, (START_ATT)
ld (player_under), a
ld hl, START_SCR
ld de, player_gfx
ld b, 8
.initdraw: ld a, (de)
ld (hl), a
inc de
inc h
djnz .initdraw
ld a, PLAYER
ld (START_ATT), a
; Set attributes for score line
ld hl, SCORE_ATT
ld a, $47 ; BRIGHT + INK 7 (white on black)
ld b, SCORE_LEN
.sattr: ld (hl), a
inc hl
djnz .sattr
call print_score ; Show initial status
; ==================================================================
; Main loop
; ==================================================================
.loop: halt
; --- Erase player at current position ---
ld hl, (player_scr)
ld b, 8
ld a, 0
.erase: ld (hl), a
inc h
djnz .erase
ld hl, (player_att)
ld a, (player_under)
ld (hl), a
; --- Check Q (up) ---
ld a, KEY_ROW_QT
in a, ($fe)
bit 0, a
jr nz, .not_q
ld hl, (player_att)
ld de, $ffe0 ; -32 (one row up)
add hl, de
ld a, (hl)
and $07
cp WALL_INK
jr z, .not_q
call check_collect
ld (player_att), hl
ld hl, (player_scr)
ld de, $ffe0
add hl, de
ld (player_scr), hl
ld a, (player_row)
dec a
ld (player_row), a
.not_q:
; --- Check A (down) ---
ld a, KEY_ROW_AG
in a, ($fe)
bit 0, a
jr nz, .not_a
ld hl, (player_att)
ld de, 32
add hl, de
ld a, (hl)
and $07
cp WALL_INK
jr z, .not_a
call check_collect
ld (player_att), hl
ld hl, (player_scr)
ld de, 32
add hl, de
ld (player_scr), hl
ld a, (player_row)
inc a
ld (player_row), a
.not_a:
; --- Check O (left) ---
ld a, KEY_ROW_PY
in a, ($fe)
bit 1, a
jr nz, .not_o
ld hl, (player_att)
dec hl
ld a, (hl)
and $07
cp WALL_INK
jr z, .not_o
call check_collect
ld (player_att), hl
ld hl, (player_scr)
dec hl
ld (player_scr), hl
ld a, (player_col)
dec a
ld (player_col), a
.not_o:
; --- Check P (right) ---
ld a, KEY_ROW_PY
in a, ($fe)
bit 0, a
jr nz, .not_p
ld hl, (player_att)
inc hl
ld a, (hl)
and $07
cp WALL_INK
jr z, .not_p
call check_collect
ld (player_att), hl
ld hl, (player_scr)
inc hl
ld (player_scr), hl
ld a, (player_col)
inc a
ld (player_col), a
.not_p:
; --- Draw player at current position ---
ld hl, (player_scr)
ld de, player_gfx
ld b, 8
.draw: ld a, (de)
ld (hl), a
inc de
inc h
djnz .draw
ld hl, (player_att)
ld a, PLAYER
ld (hl), a
; --- Update score display ---
call print_score
; --- Border shows completion ---
ld a, (treasure_count)
cp TOTAL_TREASURE
jr nz, .not_all
ld a, 4 ; Green border — all collected
out ($fe), a
jp .loop
.not_all:
ld a, 0 ; Black border
out ($fe), a
jp .loop
; ============================================================================
; Subroutines
; ============================================================================
; ----------------------------------------------------------------------------
; check_collect — save target cell, check for treasure, collect if found
; Entry: HL = target attribute address
; Exit: HL preserved, player_under updated
; ----------------------------------------------------------------------------
check_collect:
ld a, (hl)
ld (player_under), a
bit 6, a ; BRIGHT = treasure?
ret z ; No — done
res 6, a ; Clear BRIGHT (collected)
ld (player_under), a
push hl
ld hl, treasure_count
inc (hl)
pop hl
; Play collect sound — short rising tone
push hl
push de
ld hl, 80 ; Duration (cycles)
ld e, 40 ; Pitch (delay — lower = higher)
call beep
ld hl, 80
ld e, 30 ; Higher pitch
call beep
ld hl, 80
ld e, 20 ; Highest pitch
call beep
pop de
pop hl
ret
; ----------------------------------------------------------------------------
; beep — generate a tone on the speaker
; Entry: HL = duration (number of wave cycles), E = pitch (delay per half)
; Exit: speaker off, border black
; Destroys: A, B, HL
; ----------------------------------------------------------------------------
beep:
ld a, $10 ; Bit 4 high — speaker on
.on: out ($fe), a ; Push speaker cone out
ld b, e ; Delay counter = pitch
.delay1: djnz .delay1 ; Wait
xor $10 ; Toggle bit 4
out ($fe), a ; Pull speaker cone back
ld b, e ; Same delay
.delay2: djnz .delay2 ; Wait
xor $10 ; Toggle bit 4 back
dec hl ; One cycle done
ld a, h
or l ; HL = 0?
ld a, $10 ; Reload (doesn't affect flags)
jr nz, .on ; More cycles — continue
xor a ; A = 0 — speaker off, border black
out ($fe), a
ret
; ----------------------------------------------------------------------------
; print_score — display "TREASURE n/3" at row 23
; Destroys: A, BC, DE, HL
; ----------------------------------------------------------------------------
print_score:
ld de, SCORE_SCR
ld hl, score_text
call print_str
ld a, (treasure_count)
add a, '0'
call print_char
ld a, '/'
call print_char
ld a, TOTAL_TREASURE
add a, '0'
call print_char
ret
; ----------------------------------------------------------------------------
; print_str — print null-terminated string at screen address DE
; Entry: HL = string address, DE = screen address
; Exit: HL past null terminator, DE advanced past last character
; ----------------------------------------------------------------------------
print_str:
ld a, (hl)
or a
ret z
push hl
call print_char
pop hl
inc hl
jr print_str
; ----------------------------------------------------------------------------
; print_char — draw one character to screen memory using ROM font
; Entry: A = character (32-127), DE = screen address (pixel row 0)
; Exit: DE advanced to next column (E incremented)
; ----------------------------------------------------------------------------
print_char:
push de
ld l, a
ld h, 0
add hl, hl
add hl, hl
add hl, hl
ld bc, FONT_BASE
add hl, bc
ld b, 8
.pchar: ld a, (hl)
ld (de), a
inc hl
inc d
djnz .pchar
pop de
inc e
ret
; ============================================================================
; Room data — one byte per cell
; ============================================================================
;
; W W W W W W W W W
; W . W . . . T . W T = treasure
; W . T . . . W . W H = hazard
; W . . . H . . T W X = exit
; W W W W W X W W W
;
room_data:
db WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL, WALL
db WALL, FLOOR, WALL, FLOOR, FLOOR, FLOOR, TREASURE, FLOOR, WALL
db WALL, FLOOR, TREASURE, FLOOR, FLOOR, FLOOR, WALL, FLOOR, WALL
db WALL, FLOOR, FLOOR, FLOOR, HAZARD, FLOOR, FLOOR, TREASURE, WALL
db WALL, WALL, WALL, WALL, WALL, EXIT, WALL, WALL, WALL
; ============================================================================
; String data
; ============================================================================
score_text: db "TREASURE ", 0
; ============================================================================
; Player data
; ============================================================================
player_gfx: db $18, $3c, $7e, $ff
db $ff, $7e, $3c, $18
player_row: db START_ROW
player_col: db START_COL
player_scr: dw START_SCR
player_att: dw START_ATT
player_under:
db FLOOR
treasure_count:
db 0
end start

The room looks almost identical to Unit 12 — but now there’s a cyan cell in the bottom wall. That’s the exit. You can walk onto it, and player_under will store $28 (the exit attribute). Nothing happens yet when you stand on it. The exit is visible but inert.
The Attribute Palette So Far
Every cell type in the game maps to a single byte:
| Cell | Attribute | Meaning |
|---|---|---|
| Wall | $09 | PAPER 1 (blue), INK 1 — blocks movement |
| Floor | $38 | PAPER 7 (white), INK 0 — walkable |
| Treasure | $70 | BRIGHT, PAPER 6 (yellow), INK 0 — collectible |
| Hazard | $90 | FLASH, PAPER 2 (red), INK 0 — dangerous (Unit 15) |
| Exit | $28 | PAPER 5 (cyan), INK 0 — the way out |
| Player | $3A | PAPER 7, INK 2 (red) — the player character |
Six bytes encode the entire game world. Each one is readable by the CPU in a single instruction (LD A,(HL)) and testable with simple bitwise operations. No sprite tables, no tile maps, no collision arrays. Just attributes.
Try This: Move the Exit
Put the exit in a different wall position:
; Exit on the right wall, row 2
db WALL, FLOOR, WALL, FLOOR, FLOOR, FLOOR, TREASURE, FLOOR, WALL
db WALL, FLOOR, TREASURE, FLOOR, FLOOR, FLOOR, WALL, FLOOR, EXIT
db WALL, FLOOR, FLOOR, FLOOR, HAZARD, FLOOR, FLOOR, TREASURE, WALL
The exit can go anywhere on the perimeter. Put it on the top wall to make the player navigate upward. Put it in a corner to make it harder to reach. The room layout defines the challenge — the exit position shapes the route.
Try This: Multiple Exits
Nothing stops you adding two exits. Replace two wall cells:
db WALL, WALL, WALL, WALL, WALL, EXIT, WALL, WALL, WALL ; Bottom
; ... and in the top wall:
db WALL, WALL, EXIT, WALL, WALL, WALL, WALL, WALL, WALL ; Top
With two exits, the room has choices. Different exits could lead to different rooms (in later units when multiple rooms are added).
Try This: Hidden Exit
Use the same colour as the floor to make the exit invisible:
HIDDEN_EXIT equ $38 ; Same as FLOOR — player can't see it
This makes the exit look like floor. The player has to explore and discover it. A different approach to level design — mystery instead of navigation.
If It Doesn’t Work
- No cyan cell visible? Check the room data. The
EXITbyte should replace oneWALLbyte in the perimeter. If it’s surrounded byFLOORcells, it won’t stand out. - Player can’t walk onto exit? Check that
EXIThas INK 0 ($28has bits 0-2 all zero). If INK equalsWALL_INK(1), the collision check blocks movement. - Exit colour looks wrong?
$28is PAPER 5 (cyan) with INK 0. If you see a different colour, check the hex value — each nibble matters.
What You’ve Learnt
- New attribute constant — adding a cell type is adding one
equline and placing bytes in the room data. No movement code changes needed. - Colour as design language — six attribute values encode the entire game world. Wall, floor, treasure, hazard, exit, player. Each readable in one instruction.
- Negative collision — the wall check tests what blocks movement, not what allows it. Any cell with INK != 1 is walkable. New cell types work automatically.
- Visual goal — the cyan exit is visible from the start. The player can see where they need to go. Good game design shows the goal before the challenge.
What’s Next
The exit is visible but does nothing. In Unit 14, you’ll add the win condition: collect all three treasures and the exit activates. Stand on it and the room is complete — the first time Shadowkeep has an ending.