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

Obstacle

A diamond-shaped obstacle scrolls across the floor. SEC/SBC moves it left each frame. Bounding box collision detects the hit. Jump or get knocked back.

3% of Dash

The player can run and jump — but there’s nothing to jump over. This unit adds a red diamond that scrolls across the floor from right to left. Time the jump wrong and you’re knocked back to the start. Time it right and the diamond passes harmlessly beneath you.

This is the first real gameplay: a challenge, a consequence, and a reason to press A.

A Second Sprite

The NES supports 64 hardware sprites. So far we’ve used one (the player). The obstacle is the second — another 4-byte entry in the OAM buffer.

    ; Set up obstacle sprite (OAM entry 1)
    lda #FLOOR_Y
    sta oam_buffer+4        ; Y position
    lda #OBSTACLE_TILE
    sta oam_buffer+5        ; Tile number
    lda #1                  ; Palette 1 (red)
    sta oam_buffer+6
    lda #255
    sta oam_buffer+7        ; X position (right edge)

OAM entry 0 is bytes 0–3 (the player). Entry 1 is bytes 4–7 (the obstacle). Same format: Y, tile, attributes, X. The attribute byte is 1 — this selects sprite palette 1 instead of palette 0, which makes the obstacle red instead of white.

A Different Palette

In the palette data, sprite palette 0 starts with white as colour 1 (for the player). Sprite palette 1 starts with red:

    .byte $0F, $30, $16, $27   ; Palette 0: white, red, orange (player)
    .byte $0F, $16, $27, $30   ; Palette 1: red, orange, white (obstacle)

Same colours, different order. The obstacle tile uses colour index 1 (the first non-transparent colour), which maps to $16 (red) in palette 1. Two sprites, two palettes, two colours — the player is white, the obstacle is red.

The Diamond Tile

Tile 2 in CHR-ROM is a diamond shape:

; Tile 2: Diamond obstacle
.byte %00011000
.byte %00111100
.byte %01111110
.byte %11111111
.byte %11111111
.byte %01111110
.byte %00111100
.byte %00011000

Wide in the middle, pointed at the top and bottom. The pattern uses only plane 0 (colour index 1), just like the running figure — but the palette makes it red.

Moving the Obstacle

    ; --- Move obstacle ---
    lda obstacle_x
    sec
    sbc #OBSTACLE_SPEED     ; Move left by 2 pixels per frame
    sta obstacle_x

Every frame, SEC / SBC #2 subtracts 2 from the obstacle’s X position. The obstacle moves left at 2 pixels per frame — 120 pixels per second, crossing the screen in about two seconds.

SEC (Set Carry) is the subtraction equivalent of CLC before addition. The 6502’s SBC subtracts the operand AND borrows from the carry flag. Setting carry first means “no borrow” — a clean subtraction. Forget SEC and the result is off by one.

Wrapping

When obstacle_x reaches 0 and subtracts 2, the byte wraps to 254. The sprite jumps from the left edge to the right edge and begins scrolling left again. No reset code needed — unsigned arithmetic does the wrapping for free. The obstacle loops endlessly.

The wrap isn’t perfectly smooth (the sprite teleports from X=0 to X=254), but at 2 pixels per frame the jump is barely visible. The obstacle slides off the left, reappears on the right, and keeps going.

Collision Detection

    ; --- Collision with obstacle ---
    lda on_ground
    beq @no_collide         ; In the air — jumped over it!

    lda obstacle_x
    cmp #240
    bcs @no_collide         ; Obstacle near screen edge — skip

    ; Check X overlap between player and obstacle
    ; Both sprites are 8 pixels wide
    lda player_x
    clc
    adc #8                  ; Player right edge
    cmp obstacle_x          ; Past obstacle left edge?
    bcc @no_collide
    beq @no_collide

    lda obstacle_x
    clc
    adc #8                  ; Obstacle right edge
    cmp player_x            ; Past player left edge?
    bcc @no_collide
    beq @no_collide

    ; Hit! Reset player to starting position
    lda #PLAYER_X
    sta player_x

@no_collide:

This is a bounding box collision check — the standard technique in 2D games. Two rectangles overlap when each one’s right edge is past the other’s left edge.

Step by Step

Guard: airborne? If on_ground is 0, the player is in the air. Skip the collision — they’ve jumped over the obstacle. This single check is why jumping works as a game mechanic. The obstacle can only hurt you on the ground.

Guard: screen edge? When the obstacle wraps through X=240–255, it’s either off-screen or sliding in from the edge. Checking collision during the wrap could give false results (the addition obstacle_x + 8 wraps around too), so we skip it. CMP #240 / BCS handles this — if obstacle_x >= 240, skip.

X overlap, first half: Add 8 to player_x to get the player’s right edge. Compare against obstacle_x (the obstacle’s left edge). If the player’s right edge hasn’t reached the obstacle’s left edge, they don’t overlap. BCC (carry clear = less than) skips out. BEQ skips too — touching edges isn’t overlapping.

X overlap, second half: Same check in reverse. Add 8 to obstacle_x to get the obstacle’s right edge. Compare against player_x (the player’s left edge). If the obstacle’s right edge hasn’t reached the player’s left edge, no overlap.

Hit! Both checks passed — the sprites overlap. Reset player_x to PLAYER_X (the starting position). The player snaps back to the left side of the screen.

Why Two Checks?

One check isn’t enough. “Player’s right edge is past obstacle’s left edge” is true when the player is directly on top of the obstacle — but it’s ALSO true when the player is far to the right (right edge is still numerically past the obstacle’s left edge). The second check eliminates that case. Together, the two checks confirm the sprites actually overlap in X.

Check 1 alone:
Player at X=200, obstacle at X=50
Player right edge = 208 > 50 ✓ (but they're 150 pixels apart!)

Check 1 AND Check 2:
Obstacle right edge = 58 > 200? ✗ — no collision. Correct.

Updating Both Sprites

After all the game logic runs, both sprites get their positions written to the OAM buffer:

    ; --- Update sprite positions ---
    lda player_y
    sta oam_buffer+0
    lda player_x
    sta oam_buffer+3

    lda #FLOOR_Y
    sta oam_buffer+4        ; Obstacle always on the floor
    lda obstacle_x
    sta oam_buffer+7

The obstacle’s Y never changes — it’s always on the floor. Only its X position updates. On the next NMI, the DMA transfer sends both sprites to the PPU and they appear at their new positions.

The Complete Code

; =============================================================================
; DASH - Unit 4: Obstacle
; =============================================================================
; A diamond-shaped obstacle scrolls across the floor. Jump over it or get
; knocked back to the start. The first challenge in Dash.
; =============================================================================

; -----------------------------------------------------------------------------
; NES Hardware Addresses
; -----------------------------------------------------------------------------
PPUCTRL   = $2000
PPUMASK   = $2001
PPUSTATUS = $2002
OAMADDR   = $2003
PPUADDR   = $2006
PPUDATA   = $2007
OAMDMA    = $4014
JOYPAD1   = $4016

; -----------------------------------------------------------------------------
; Button Masks
; -----------------------------------------------------------------------------
BTN_A      = %10000000
BTN_B      = %01000000
BTN_SELECT = %00100000
BTN_START  = %00010000
BTN_UP     = %00001000
BTN_DOWN   = %00000100
BTN_LEFT   = %00000010
BTN_RIGHT  = %00000001

; -----------------------------------------------------------------------------
; Game Constants
; -----------------------------------------------------------------------------
PLAYER_X       = 60            ; Starting X position (left side)
PLAYER_Y       = 206           ; Starting Y position (on the floor)
PLAYER_TILE    = 1             ; Tile number for the player sprite
RIGHT_WALL     = 248           ; Rightmost X position
FLOOR_Y        = 206           ; Y position of the floor
GRAVITY        = 1             ; Added to vel_y each frame
JUMP_VEL       = $F6           ; -10 in two's complement (upward impulse)
OBSTACLE_TILE  = 2             ; Tile number for the obstacle
OBSTACLE_SPEED = 2             ; Pixels per frame (moving left)

; -----------------------------------------------------------------------------
; Memory
; -----------------------------------------------------------------------------
.segment "ZEROPAGE"
player_x:   .res 1          ; Player X position
player_y:   .res 1          ; Player Y position
vel_y:      .res 1          ; Vertical velocity (signed: negative = up)
buttons:    .res 1          ; Current button state
nmi_flag:   .res 1          ; Set by NMI, cleared by main loop
on_ground:  .res 1          ; 1 = on floor, 0 = in air
obstacle_x: .res 1          ; Obstacle X position

.segment "OAM"
oam_buffer: .res 256

.segment "BSS"

; =============================================================================
; iNES Header
; =============================================================================
.segment "HEADER"
    .byte "NES", $1A
    .byte 2
    .byte 1
    .byte $01
    .byte $00
    .byte 0,0,0,0,0,0,0,0

; =============================================================================
; Code
; =============================================================================
.segment "CODE"

; --- Reset ---
reset:
    sei
    cld
    ldx #$40
    stx $4017
    ldx #$FF
    txs
    inx
    stx PPUCTRL
    stx PPUMASK
    stx $4010

@vblank1:
    bit PPUSTATUS
    bpl @vblank1

    lda #0
@clear_ram:
    sta $0000, x
    sta $0100, x
    sta $0200, x
    sta $0300, x
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $0700, x
    inx
    bne @clear_ram

@vblank2:
    bit PPUSTATUS
    bpl @vblank2

    ; Load palette
    bit PPUSTATUS
    lda #$3F
    sta PPUADDR
    lda #$00
    sta PPUADDR

    ldx #0
@load_palette:
    lda palette_data, x
    sta PPUDATA
    inx
    cpx #32
    bne @load_palette

    ; Set up player sprite (OAM entry 0)
    lda #PLAYER_Y
    sta oam_buffer+0
    lda #PLAYER_TILE
    sta oam_buffer+1
    lda #0
    sta oam_buffer+2
    lda #PLAYER_X
    sta oam_buffer+3

    ; Set up obstacle sprite (OAM entry 1)
    lda #FLOOR_Y
    sta oam_buffer+4
    lda #OBSTACLE_TILE
    sta oam_buffer+5
    lda #1                  ; Palette 1 (red)
    sta oam_buffer+6
    lda #255
    sta oam_buffer+7

    ; Initialise game state
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground
    lda #255
    sta obstacle_x          ; Start at right edge

    ; Hide other sprites (entries 2-63)
    lda #$EF
    ldx #8
@hide_sprites:
    sta oam_buffer, x
    inx
    bne @hide_sprites

    ; Enable rendering
    lda #%10000000
    sta PPUCTRL
    lda #%00011110
    sta PPUMASK

; =============================================================================
; Main Loop
; =============================================================================
main_loop:
    lda nmi_flag
    beq main_loop
    lda #0
    sta nmi_flag

    ; --- Read controller ---
    lda #1
    sta JOYPAD1
    lda #0
    sta JOYPAD1

    ldx #8
@read_pad:
    lda JOYPAD1
    lsr a
    rol buttons
    dex
    bne @read_pad

    ; --- Jump check ---
    lda buttons
    and #BTN_A
    beq @no_jump
    lda on_ground
    beq @no_jump
    lda #JUMP_VEL
    sta vel_y
    lda #0
    sta on_ground
@no_jump:

    ; --- Move left/right ---
    lda buttons
    and #BTN_LEFT
    beq @not_left
    lda player_x
    beq @not_left
    dec player_x
@not_left:

    lda buttons
    and #BTN_RIGHT
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right
    inc player_x
@not_right:

    ; --- Apply gravity ---
    lda vel_y
    clc
    adc #GRAVITY
    sta vel_y

    ; --- Apply velocity to Y position ---
    lda player_y
    clc
    adc vel_y
    sta player_y

    ; --- Floor collision ---
    lda player_y
    cmp #FLOOR_Y
    bcc @in_air
    lda #FLOOR_Y
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground
@in_air:

    ; --- Move obstacle ---
    lda obstacle_x
    sec
    sbc #OBSTACLE_SPEED
    sta obstacle_x

    ; --- Collision with obstacle ---
    lda on_ground
    beq @no_collide         ; In the air — jumped over it!

    lda obstacle_x
    cmp #240
    bcs @no_collide         ; Obstacle near screen edge — skip

    ; Check X overlap between player and obstacle
    ; Both sprites are 8 pixels wide
    lda player_x
    clc
    adc #8                  ; Player right edge
    cmp obstacle_x          ; Past obstacle left edge?
    bcc @no_collide
    beq @no_collide

    lda obstacle_x
    clc
    adc #8                  ; Obstacle right edge
    cmp player_x            ; Past player left edge?
    bcc @no_collide
    beq @no_collide

    ; Hit! Reset player to starting position
    lda #PLAYER_X
    sta player_x

@no_collide:

    ; --- Update sprite positions ---
    lda player_y
    sta oam_buffer+0
    lda player_x
    sta oam_buffer+3

    lda #FLOOR_Y
    sta oam_buffer+4        ; Obstacle always on the floor
    lda obstacle_x
    sta oam_buffer+7

    jmp main_loop

; =============================================================================
; NMI Handler
; =============================================================================
nmi:
    pha
    txa
    pha
    tya
    pha

    lda #0
    sta OAMADDR
    lda #>oam_buffer
    sta OAMDMA

    lda #1
    sta nmi_flag

    pla
    tay
    pla
    tax
    pla
    rti

irq:
    rti

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

palette_data:
    ; Background palettes
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    ; Sprite palettes
    .byte $0F, $30, $16, $27   ; Palette 0: white, red, orange (player)
    .byte $0F, $16, $27, $30   ; Palette 1: red, orange, white (obstacle)
    .byte $0F, $30, $16, $27
    .byte $0F, $30, $16, $27

; =============================================================================
; Vectors
; =============================================================================
.segment "VECTORS"
    .word nmi
    .word reset
    .word irq

; =============================================================================
; CHR-ROM
; =============================================================================
.segment "CHARS"

; Tile 0: Empty
.byte $00,$00,$00,$00,$00,$00,$00,$00
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 1: Running figure
.byte %00110000
.byte %00110000
.byte %01111000
.byte %00110000
.byte %00110000
.byte %00101000
.byte %01000100
.byte %01000100
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000

; Tile 2: Diamond obstacle
.byte %00011000
.byte %00111100
.byte %01111110
.byte %11111111
.byte %11111111
.byte %01111110
.byte %00111100
.byte %00011000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000
.byte %00000000

.res 8192 - 48, $00

Dash Unit 4

A white running figure on the left, a red diamond on the right. The diamond scrolls left across the floor. Jump over it — if you’re on the ground when it reaches you, you’re knocked back to the start. The diamond wraps around and comes again. And again.

Try This: Change the Speed

OBSTACLE_SPEED = 2             ; Current: moderate

Set it to 1 for a slow, predictable obstacle. Set it to 3 or 4 for a real challenge. At speed 4, the diamond crosses the screen in about one second — you need sharp reflexes to jump in time.

Try This: Two Obstacles

Add a third sprite (OAM entry 2, bytes 8–11) and a second variable obstacle2_x. Initialise the second obstacle at X=128 so they’re staggered:

obstacle2_x: .res 1

    ; In init:
    lda #128
    sta obstacle2_x

    ; In main loop (after first obstacle movement):
    lda obstacle2_x
    sec
    sbc #OBSTACLE_SPEED
    sta obstacle2_x

Duplicate the collision check for the second obstacle. Now the player must time jumps to clear both diamonds — and sometimes jump twice in quick succession.

Try This: Upward Knockback

Instead of resetting the player’s X, launch them upward:

    ; On hit:
    lda #JUMP_VEL
    sta vel_y
    lda #0
    sta on_ground

The player bounces off the obstacle like they’ve been hit. It feels more dynamic than a position reset and teaches that the velocity system from Unit 3 can be reused for any vertical movement — not just player-initiated jumps.

If It Doesn’t Work

  • No obstacle on screen? Check that oam_buffer+4 is set to FLOOR_Y (not left as $EF from the hide-sprites loop). The obstacle sprite must be initialised BEFORE hiding other sprites, or hidden sprites must start at index 8, not 4.
  • Obstacle doesn’t move? Make sure the SEC / SBC code runs every frame inside the main loop, not in the init section.
  • Collision triggers when it shouldn’t? Check the screen-edge guard (CMP #240 / BCS). Without it, the wrapping obstacle can trigger false collisions when its X position is near 255.
  • Collision never triggers? Both range checks must pass. A common mistake is using BCC without BEQ — when the right edge exactly equals the left edge, carry is set (no borrow), but the sprites are touching, not overlapping. The BEQ catches this case.
  • Obstacle is white instead of red? The attribute byte (oam_buffer+6) must be 1 to select palette 1. If it’s 0, the obstacle uses the same palette as the player.
  • Player gets hit while jumping? Check that on_ground is 0 during the jump. The collision guard BEQ @no_collide only works if on_ground is cleared when the player leaves the floor.
  • Forgot SEC? SBC without SEC subtracts an extra 1 when the carry flag is clear. The obstacle moves at 3 pixels per frame instead of 2 — fast enough to notice.

What You’ve Learnt

  • Multiple OAM sprites — each sprite is a 4-byte block in the OAM buffer. Entry 0 is bytes 0–3, entry 1 is bytes 4–7, and so on up to entry 63 at bytes 252–255.
  • SEC/SBC subtractionSEC sets the carry flag before SBC, just as CLC clears it before ADC. Forgetting SEC introduces an off-by-one error.
  • Unsigned wrapping — subtracting past zero wraps to 255. The obstacle scrolls left, wraps, and reappears from the right with no special reset code.
  • Bounding box collision — two rectangles overlap when each one’s right edge is past the other’s left edge. Two comparisons, tested together.
  • Guard conditions — the on_ground check and the screen-edge check prevent false collisions. Without guards, the collision triggers when the player is airborne or the obstacle is wrapping.
  • Sprite palettes — the attribute byte’s low 2 bits select which of the 4 sprite palettes to use. Different palettes let sprites share tiles but appear in different colours.

What’s Next

The jump works, the obstacle scrolls, the collision stings. But the leap is silent — in Unit 5, the APU’s pulse channel gives the jump a voice.