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

Wall Collision

Read the attribute at the target cell before moving — if INK colour is blue, the cell is a wall. One memory read replaces all boundary checks.

5% of Shadowkeep

In Unit 6, the player stopped at the room edges because of hard-coded boundary checks — constants that said “row 11 is the top, row 13 is the bottom.” That works for a rectangle, but what about a room with pillars? Or an L-shaped corridor? Or a maze with twisting walls?

The answer has been there since Unit 1. The walls are blue. Blue means INK 1. Before moving, read the attribute at the target cell. If the INK colour is 1, it’s a wall — don’t move. If it’s anything else, the cell is passable.

One memory read. One AND. One comparison. That’s collision detection on the Spectrum.

The Attribute as Collision Map

This is the payoff of the attribute system. Every cell on screen already contains all the information the game needs:

INK ColourMeaningPassable?
0 (black)Floor, treasure, hazardYes
1 (blue)WallNo
2 (red)Player

The screen itself is the game world. No separate collision map. No hitbox data. No sprite overlap tests. The Spectrum’s “limitation” — one attribute byte per cell — is exactly what makes this work. The display and the game logic are the same data.

Checking a Target Cell

Before moving in any direction, read the attribute at the destination:

            ; Check if the cell above the player is a wall
            ;
            ; 1. Take the current attribute address
            ; 2. Subtract 32 to get the cell one row up
            ; 3. Read the attribute byte at that address
            ; 4. Extract the INK colour (bits 0–2)
            ; 5. Compare with wall INK (1 = blue)

            ld      hl, (player_att)  ; Current attribute address
            ld      de, $ffe0         ; -32 = one row up
            add     hl, de            ; HL = target cell address

            ld      a, (hl)           ; Read attribute at target
            and     $07               ; Keep INK bits only
            cp      WALL_INK          ; INK 1 = wall?
            jr      z, .blocked       ; Yes — don't move

            ; Not a wall — safe to move
            ld      (player_att), hl  ; Store new attribute address
            ; ... update bitmap address and row variable ...

.blocked:

The pattern is identical to Unit 2’s attribute reading, now applied to gameplay:

  1. Calculate the target address: current position ± offset (32 for rows, 1 for columns)
  2. LD A, (HL) — read the attribute byte
  3. AND $07 — extract the INK colour (bits 0–2)
  4. CP WALL_INK — compare with wall colour (1 = blue)
  5. JR Z — if equal, the cell is a wall — block the move

If the target isn’t a wall, the code falls through to the movement logic. The target address in HL is already the new position, so it can be stored directly as the updated attribute address.

Why INK, Not the Full Byte?

Walls have attribute $09 (PAPER 1, INK 1). But what if a wall cell also has BRIGHT or FLASH set? Checking the full byte with CP $09 would miss $49 (BRIGHT wall) or $89 (FLASH wall). By extracting just the INK with AND $07, the check works regardless of PAPER, BRIGHT, or FLASH. Any cell with INK 1 is a wall, whatever else it has.

Internal Walls

This unit adds a pillar inside the room — a wall cell at row 12, column 15:

            ld      a, WALL
            ld      ($598f), a      ; Row 12, col 15 — pillar

With boundary checks, this pillar would be invisible to the collision system — the player would walk straight through it. With attribute-based collision, the pillar blocks movement automatically. No extra code needed. The collision system reads what’s on screen, and the pillar is a wall.

This is why attribute-based collision is powerful. Add walls anywhere — the game handles them. Remove walls — the game adapts. The room layout is entirely data-driven, and the collision code never changes.

How Each Direction Works

The four direction checks follow the same structure. Only the address offset differs:

DirectionOffsetAddress Change
Up−32 ($FFE0)Previous row
Down+32Next row
Left−1Previous column
Right+1Next column

For up and down, ADD HL, DE applies the 32-byte row offset. For left and right, DEC HL or INC HL shifts by one column. After the collision check passes, the same offset updates both the attribute and bitmap addresses.

The Complete Code

; ============================================================================
; SHADOWKEEP — Unit 7: Wall Collision
; ============================================================================
; Before moving, read the attribute at the target cell. If its INK colour
; is 1 (blue = wall), the move is blocked. Any other INK colour is passable.
;
; This replaces the hard-coded boundary checks from Unit 6. The walls
; themselves are the collision map — no separate boundary data needed.
; The attribute system that seemed like a colour trick in Unit 1 is now
; the entire collision detection system.
; ============================================================================

            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)
HAZARD      equ     $90             ; FLASH + PAPER 2 (red)
PLAYER      equ     $3a             ; PAPER 7 (white) + INK 2 (red)

; Collision
WALL_INK    equ     1               ; INK colour that means "wall"

; Room dimensions
ROOM_TOP    equ     10
ROOM_LEFT   equ     12
ROOM_WIDTH  equ     9
ROOM_INNER  equ     7

; Screen addresses for starting position (row 11, col 13)
START_ROW   equ     11
START_COL   equ     13
START_SCR   equ     $486d
START_ATT   equ     $596d

; 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

; ----------------------------------------------------------------------------
; 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 the room
            ; ==================================================================

            ; --- Top wall ---
            ld      hl, $594c       ; Row 10, col 12
            ld      b, ROOM_WIDTH
            ld      a, WALL
.top:       ld      (hl), a
            inc     hl
            djnz    .top

            ; --- Row 11: wall, floor, wall ---
            ld      hl, $596c
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r11:       ld      (hl), a
            inc     hl
            djnz    .r11
            ld      a, WALL
            ld      (hl), a

            ; --- Row 12: wall, floor, wall ---
            ld      hl, $598c
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r12:       ld      (hl), a
            inc     hl
            djnz    .r12
            ld      a, WALL
            ld      (hl), a

            ; --- Row 13: wall, floor, wall ---
            ld      hl, $59ac
            ld      a, WALL
            ld      (hl), a
            inc     hl
            ld      a, FLOOR
            ld      b, ROOM_INNER
.r13:       ld      (hl), a
            inc     hl
            djnz    .r13
            ld      a, WALL
            ld      (hl), a

            ; --- Bottom wall ---
            ld      hl, $59cc
            ld      b, ROOM_WIDTH
            ld      a, WALL
.bot:       ld      (hl), a
            inc     hl
            djnz    .bot

            ; --- Treasure, hazard, and internal wall ---
            ld      a, TREASURE
            ld      ($5990), a      ; Row 12, col 16
            ld      a, HAZARD
            ld      ($59af), a      ; Row 13, col 15
            ld      a, WALL
            ld      ($598f), a      ; Row 12, col 15 — pillar

            ; ==================================================================
            ; Draw the player at starting position
            ; ==================================================================

            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

            ; ==================================================================
            ; 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, FLOOR
            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)         ; Read target attribute
            and     $07             ; Extract INK
            cp      WALL_INK
            jr      z, .not_q       ; Wall — blocked

            ; Move up
            ld      hl, (player_att)
            ld      de, $ffe0
            add     hl, de
            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          ; +32 (one row down)
            add     hl, de
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_a

            ld      hl, (player_att)
            ld      de, 32
            add     hl, de
            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              ; -1 (one column left)
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_o

            ld      hl, (player_att)
            dec     hl
            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              ; +1 (one column right)
            ld      a, (hl)
            and     $07
            cp      WALL_INK
            jr      z, .not_p

            ld      hl, (player_att)
            inc     hl
            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

            jp      .loop

; ============================================================================
; Data
; ============================================================================

; Player character — diamond shape
player_gfx: db      $18, $3c, $7e, $ff
            db      $ff, $7e, $3c, $18

; Position tracking
player_row: db      START_ROW
player_col: db      START_COL

; Screen addresses
player_scr: dw      START_SCR
player_att: dw      START_ATT

            end     start

Shadowkeep Unit 7

The room now has an internal wall — a blue pillar at row 12, column 15. Move the player around and try to walk through it. The diamond stops. Try walking into the outer walls. Same result. Every blue cell blocks movement, whether it’s an edge wall or a pillar in the middle.

Notice that the FLOOR_TOP, FLOOR_BOT, FLOOR_LEFT, and FLOOR_RIGHT constants from Unit 6 are gone. The boundary checks are gone. The attribute system handles everything.

Try This: Add More Walls

Build a maze inside the room. Add wall cells at specific positions:

            ld      a, WALL
            ld      ($596e), a      ; Row 11, col 14
            ld      ($5970), a      ; Row 11, col 16
            ld      ($59b0), a      ; Row 13, col 16

Each wall cell automatically blocks movement. No collision code changes needed — just place walls and the game adapts.

Try This: Breakable Walls

What if some walls could be destroyed? Use a different INK for breakable walls:

WEAK_WALL   equ     $1b             ; PAPER 3 (magenta) + INK 3

A magenta wall. The collision check only blocks INK 1 (blue), so INK 3 is passable — the player walks through it. To make it block movement but be destroyable, you’d check for both INK 1 and INK 3, then change the weak wall to floor on contact. That’s a future enhancement, but the attribute system makes it straightforward.

Try This: Walk Onto Treasure

Navigate the diamond onto the yellow treasure cell (row 12, column 16). It works — treasure has INK 0, not INK 1, so it’s passable. But when you leave, the treasure disappears. The erase step restores FLOOR regardless of what was there. Fixing this properly requires tracking the original cell contents — that comes in Unit 10 when treasure collection is implemented.

If It Doesn’t Work

  • Player walks through walls? Check AND $07 is present. Without it, you’re comparing the full attribute byte, which won’t match WALL_INK (1) because the full wall byte is $09.
  • Player can’t move at all? Check the target address calculation. For “up,” the offset is −32 ($FFE0), not −1. For “left,” it’s −1, not −32.
  • Pillar doesn’t block? Verify the pillar’s attribute is $09 (WALL). If it has a different INK, the collision check won’t catch it. Check the address: row 12, col 15 = $598F.
  • Player stops one cell too early? The collision check reads the target cell, not the current cell. Make sure you add the offset before reading, not after.
  • Diagonal movement through corners? If you hold Q and P simultaneously, the player moves up and then right in the same frame. It’s possible to slip past a corner where two walls meet diagonally. This is a known characteristic of checking directions independently.

What You’ve Learnt

  • Attribute-based collision — read the attribute at the target position, check the INK colour. INK 1 = wall = blocked. Everything else is passable. One memory read replaces all boundary checks.
  • AND $07 — extract the INK colour from an attribute byte. This ignores PAPER, BRIGHT, and FLASH, making the check work for any wall variant.
  • WALL_INK constant — naming the collision colour makes the code self-documenting. Changing what “wall” means is a single constant change.
  • Data-driven collision — adding walls to the room automatically adds collision. No code changes needed. The collision system reads the screen, and the screen defines the world.
  • The attribute system as game engine — colour is collision. The same byte that controls how a cell looks also controls how it behaves. This is the Spectrum’s design philosophy in action.

What’s Next

The room is drawn by hand — every wall and floor cell placed individually. In Unit 8, you’ll define the room as a data table — a grid of bytes where each byte represents one cell. A loop reads the table and draws the room. Change the data, change the room. This is the foundation for multiple rooms and a real maze.