Skip to content
Game 1 Unit 13 of 128 1 hr learning time

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.

10% of Shadowkeep

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

Shadowkeep Unit 13

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:

CellAttributeMeaning
Wall$09PAPER 1 (blue), INK 1 — blocks movement
Floor$38PAPER 7 (white), INK 0 — walkable
Treasure$70BRIGHT, PAPER 6 (yellow), INK 0 — collectible
Hazard$90FLASH, PAPER 2 (red), INK 0 — dangerous (Unit 15)
Exit$28PAPER 5 (cyan), INK 0 — the way out
Player$3APAPER 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 EXIT byte should replace one WALL byte in the perimeter. If it’s surrounded by FLOOR cells, it won’t stand out.
  • Player can’t walk onto exit? Check that EXIT has INK 0 ($28 has bits 0-2 all zero). If INK equals WALL_INK (1), the collision check blocks movement.
  • Exit colour looks wrong? $28 is 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 equ line 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.