Skip to content
Game 1Unit 17 of 171 hr learning time

The Last Yard

A win that means something: collect every coin and the level turns over — coins return, the obstacle quickens from a speed table, an L digit joins the HUD. A tiny pulse-channel sequencer gives the title a tune and both endings a voice. The game becomes a loop with a curve.

100% of Dash

Unit 16 called Phase 1 complete, and mechanically it was. Then we tried to win it — drove a controller script through every coin in one run — and learnt three things in an afternoon. There was nothing to win: the third coin was exactly like the second. The spike gap was one pixel wider than the jump, so the right half of the level had never been reached. And dying once next to the obstacle could spend all three lives in three frames. This unit is everything that came out of that afternoon: a level loop, a tune, two stings, a blink — and two honest fixes the game needed before any of it could matter.

Dash Unit 17

That’s level two: all three coins back on their spots, the runner returned to the gate, L2 in the HUD — and the obstacle already inbound, half again as fast as it was a minute ago.

Every Coin Home

The win condition was sitting in the data all along: three coins, a coins_left counter, and one comparison after the collect checks. When the last coin goes, next_level turns the world over:

; -----------------------------------------------------------------------------
; next_level — every coin home: the world resets, faster
;   Three caps, three jobs: the level COUNTER never stops, the table INDEX
;   clamps at the last row, and the HUD DIGIT (in the NMI) shows 9 forever
;   after. The player's number tells the truth; the physics admits it ran
;   out of new ideas.
; -----------------------------------------------------------------------------
next_level:
    inc level
    lda #3
    sta coins_left
    jsr place_coins

    ; A new field starts at the gate — the runner returns to the start.
    ; (Leave them where they stand and the respawned coin under their
    ; feet collects itself on the next frame. Every reset, every plane.)
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    sta anim_timer
    sta anim_frame
    lda #1
    sta on_ground

    lda #255
    sta obstacle_x

    ldx level               ; table index = level - 1, clamped
    dex
    cpx #LEVEL_TOP
    bcc @speed_ok
    ldx #LEVEL_TOP
@speed_ok:
    lda level_speed_tbl, x
    sta obstacle_speed

    ldx #<fanfare_phrase    ; say so
    ldy #>fanfare_phrase
    jsr start_phrase
    rts

Three details carry the unit’s weight:

  • The runner returns to the gate. Leave them where they stand and the respawned coin under their feet collects itself on the next frame — we shipped that bug for about six minutes. Every reset, every plane: the rule from the title screen applies to level turns too.
  • The speed lives in a table, and the table is the difficulty curve:
; Obstacle pixels per frame, one entry per level; the last entry is the wall
level_speed_tbl:
    .byte 2, 3, 3, 4, 4

The obstacle’s move code reads obstacle_speed fresh every frame and never learns that levels exist; next_level pokes the new value while nobody’s looking. No if level >= 3 anywhere.

  • Three caps, three jobs. The level counter never stops climbing. The table index clamps at the last entry — past level five the game repeats its hardest setting, because an unclamped index would read whatever byte follows the table and call it a speed. And the HUD digit shows 9 forever after. The player’s number tells the truth; the physics quietly admits it ran out of new ideas. Conflate those caps and the game lies about one of them.
  • The score had the same bug, and the opposite fix. It was a single tile too — so the tenth coin drew DIGIT_ZERO + 10, the spike glyph, and every coin after that garbled the readout. But where the level digit caps (the difficulty plateaus, so 9 is honest), the score must not: an achievement that stops counting is a lie. So the fix grows the field instead of clamping it — split the byte into hundreds, tens and units and write three tiles, and the number climbs as far as the coins do. Same latent bug, opposite answer, because the two numbers have opposite jobs.

A Table and a Tick

The title was silent; the endings were silent. One routine fixes all three, because a melody — on the NES as on every machine — is just a table of pitch and duration with a clock walking it:

; -----------------------------------------------------------------------------
; tune_tick — one frame of the sequencer
;   A phrase is rows of three bytes: period low, period high, frames.
;   Period 0/0 is a rest (silence is a note too). Two terminators, two
;   meanings: frames $FF loops to the top (the title jingle), frames $FE
;   stops for good (a fanfare or a sting says its piece once).
;   The APU holds each note itself — this routine touches the chip twice
;   per note, and the game runs on regardless.
; -----------------------------------------------------------------------------
tune_tick:
    lda tune_ptr+1
    bne @active
    rts                     ; sequencer off
@active:
    dec tune_timer
    beq @next
    rts
@next:
    ldy tune_idx
    lda (tune_ptr), y       ; period, low byte
    sta tune_lo
    iny
    lda (tune_ptr), y       ; period, high byte
    sta tune_hi
    iny
    lda (tune_ptr), y       ; frames — or a marker
    iny
    sty tune_idx

    cmp #$FF                ; loop: round again
    bne @not_loop
    lda #0
    sta tune_idx
    jmp @next
@not_loop:
    cmp #$FE                ; stop: played once, now done
    bne @note
    lda #%00110000          ; silence the pulse
    sta SQ1_VOL
    lda #0
    sta tune_ptr+1          ; sequencer off
    rts
@note:
    sta tune_timer
    lda tune_lo
    ora tune_hi
    bne @sound
    lda #%00110000          ; a rest: quiet, but the clock keeps counting
    sta SQ1_VOL
    rts
@sound:
    lda #%10111000          ; duty 10, constant volume 8
    sta SQ1_VOL
    lda #$00                ; sweep unit off — safe for every period in our
    sta SQ1_SWEEP           ; tables (the idle target stays under $7FF)
    lda tune_lo
    sta SQ1_LO
    lda tune_hi
    sta SQ1_HI              ; the high write restarts the note
    rts

The APU holds each note by itself: tune_tick touches the chip twice per note — once to start it, once when its time is up — and the game runs on regardless. A period of zero is a rest: silence is a note too, so the channel gates off and the clock keeps counting.

The part worth keeping is the two terminators. $FF loops to the top — that’s the title jingle, round and round until START. $FE stops for good — that’s a fanfare or a sting, which says its piece once and goes quiet. Win and lose having voices cost the sequencer exactly one extra marker:

; Phrases: period low, period high, frames. 0,0 = rest.
; Terminators: frames $FF = loop, $FE = stop. (NTSC pulse periods.)

; Camptown Races — what else would a game called Dash play?
camptown_phrase:
    .byte $1C,$01, 8        ; G  Camp-
    .byte $1C,$01, 8        ; G  town
    .byte $52,$01, 8        ; E  la-
    .byte $1C,$01, 8        ; G  dies
    .byte $FD,$00, 8        ; A  sing
    .byte $1C,$01, 8        ; G  dis
    .byte $52,$01, 16       ; E  song
    .byte $00,$00, 8        ;    (breath)
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 16       ; D  dah
    .byte $00,$00, 6        ;    (breath)
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 16       ; D  dah
    .byte $00,$00, 10       ;    (breath)
    .byte $1C,$01, 8        ; G  Camp-
    .byte $1C,$01, 8        ; G  town
    .byte $52,$01, 8        ; E  race-
    .byte $1C,$01, 8        ; G  track
    .byte $FD,$00, 8        ; A  five
    .byte $1C,$01, 8        ; G  miles
    .byte $52,$01, 16       ; E  long
    .byte $00,$00, 8        ;    (breath)
    .byte $7C,$01, 8        ; D  oh
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 8        ; D  dah
    .byte $AB,$01, 24       ; C  day
    .byte $00,$00, 30       ;    (and round again)
    .byte $00,$00, $FF      ; loop

; The level fanfare — a rising run, played once
fanfare_phrase:
    .byte $AB,$01, 7        ; C
    .byte $52,$01, 7        ; E
    .byte $1C,$01, 7        ; G
    .byte $D5,$00, 22       ; C, an octave up, held
    .byte $00,$00, $FE      ; stop

; The lose sting — two steps down, then the floor
lose_phrase:
    .byte $52,$01, 10       ; E
    .byte $7C,$01, 10       ; D
    .byte $AB,$01, 10       ; C
    .byte $39,$02, 30       ; G below, long
    .byte $00,$00, $FE      ; stop

The tune is Camptown Races — a hundred and seventy years old, and what else would a game called Dash play? Here’s one loop of it on pulse 1:

The level fanfare — the third coin’s ding, then a rising run to a held top C:

And the lose sting — the damage buzz finishes its own sentence, then two steps down and the floor:

One confession from the building of it: our first jingle was completely silent, and the code was correct on paper. The culprit was the sweep-disable write — the classic NES idiom is $08 (negate set, shift zero), and the emulator’s sweep unit mutes pulse 1 on exactly that pattern (bug filed). $00 disables the sweep just as thoroughly for every period in our tables, so that’s what ships. When your sound is silent and your code looks right, read the sweep register twice.

PRESS START now winks on a 32-frame beat — and the lesson is where the code lives. Nametable writes belong to vblank, so the blink is the NMI handler’s job: the main loop just increments blink_timer, and the NMI reads bit 5 and writes either the text or eleven blanks:

    ; --- Title: blink PRESS START (VRAM writes belong to the NMI) ---
    lda game_state
    cmp #STATE_TITLE
    bne @not_title

    bit PPUSTATUS
    lda #$22
    sta PPUADDR
    lda #$0A
    sta PPUADDR
    lda blink_timer
    and #%00100000          ; 32 frames on, 32 off
    bne @blink_off
    ldx #0
@blink_text:
    lda press_text, x
    sta PPUDATA
    inx
    cpx #11
    bne @blink_text
    jmp @skip_hud
@blink_off:
    lda #0
    ldx #0
@blink_blank:
    sta PPUDATA
    inx
    cpx #11
    bne @blink_blank
    jmp @skip_hud

A blink is a draw and a clear taking turns on a clock you already had. The whole thing is one AND.

The Two Fixes

The spike gap was unjumpable. The jump carries the runner about fifteen pixels; the spikes spanned sixteen. Nobody had ever crossed them — every screenshot before this unit was taken on the left half of the level. One spike tile instead of two, and the game’s geometry finally agrees with its physics. The test that catches this class of bug is brutally short: can the game be finished? Run it before you call anything complete.

Dying could cascade. Take a hit while the obstacle is over the respawn point and it hit you again the next frame, and again the frame after — three lives in three frames. The fix is a grace window: a second of invulnerability after every respawn, covering every damage source, made visible by blinking the runner while it lasts:

    ; --- Collision with obstacle ---
    lda hurt_timer          ; the grace window: fresh from a respawn,
    bne @no_collide         ; nothing can hurt you while you find your feet
    lda on_ground
    beq @no_collide

    ; --- Check for hazard tiles ---
    lda hurt_timer          ; the grace window covers the spikes too
    beq @hazard_check
    dec hurt_timer          ; (and this is where it counts down)
    jmp @no_hazard
@hazard_check:
    lda on_ground

Fairness needs to be seen to be believed — the blink isn’t decoration, it’s the game telling you the rules.

Try This: Bend the Curve

Make the table longer and gentler — 2, 2, 3, 3, 3, 4 — or sharper. Add a sixth level that’s slower than the fifth and watch what a breather does to the game’s rhythm. Difficulty design is data entry now; that was the point of the table.

Try This: A Second Voice

The whole tune sits on pulse 1. Pulse 2 is idle — duplicate the sequencer state (three more zero-page bytes, a second tick) and play a bass line under Camptown. Two voices is where a jingle starts sounding like music, and the structure doesn’t change at all.

Try This: Show the Level on the Title

“REACHED LEVEL 4” under PRESS START is the classic arcade brag. The level byte survives the trip back to the title — the only question is whether the title should advertise your best run or your last one. (They’re different bytes.)

The Complete Code

; =============================================================================
; DASH - Unit 17: The Last Yard
; =============================================================================
; A win that means something: collect every coin and the level advances —
; coins return, the obstacle quickens from a speed table, an L digit joins
; the HUD. A tiny tune sequencer on pulse 1 gives the title Camptown Races,
; the level-up a rising fanfare and the game over a falling sting. PRESS
; START blinks. The game is a loop with a curve.
; =============================================================================

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

; -----------------------------------------------------------------------------
; APU Registers
; -----------------------------------------------------------------------------
SQ1_VOL    = $4000
SQ1_SWEEP  = $4001
SQ1_LO     = $4002
SQ1_HI     = $4003
TRI_LINEAR = $4008
TRI_LO     = $400A
TRI_HI     = $400B
APU_STATUS = $4015

; -----------------------------------------------------------------------------
; 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
PLAYER_Y       = 200
PLAYER_TILE_1  = 1
PLAYER_TILE_2  = 27
RIGHT_WALL     = 248
FLOOR_Y        = 200
GRAVITY        = 1
JUMP_VEL       = $F6
OBSTACLE_TILE  = 2
OBSTACLE_SPEED = 2
GROUND_TILE    = 3
COIN_TILE      = 4
WALL_TILE      = 29             ; brick — reads as built (jump it), not scenery
DIGIT_ZERO     = 5
SPIKE_TILE     = 15
START_LIVES    = 3
LETTER_G       = 16
LETTER_A       = 17
LETTER_M       = 18
LETTER_E       = 19
LETTER_V       = 20
LETTER_R       = 21
LETTER_D       = 22
LETTER_S       = 23
LETTER_H       = 24
LETTER_P       = 25
LETTER_T       = 26
LETTER_L       = 28
ANIM_SPEED     = 8
LEVEL_TOP      = 4              ; last index of level_speed_tbl

STATE_TITLE    = 0
STATE_PLAYING  = 1
STATE_GAMEOVER = 2

; -----------------------------------------------------------------------------
; Memory
; -----------------------------------------------------------------------------
.segment "ZEROPAGE"
player_x:       .res 1
player_y:       .res 1
vel_y:          .res 1
buttons:        .res 1
prev_buttons:   .res 1
nmi_flag:       .res 1
on_ground:      .res 1
obstacle_x:     .res 1
tile_ptr:       .res 2
score:          .res 1
lives:          .res 1
game_state:     .res 1
game_over_drawn: .res 1
anim_timer:     .res 1
anim_frame:     .res 1
moving:         .res 1
level:          .res 1
coins_left:     .res 1
obstacle_speed: .res 1
blink_timer:    .res 1
tune_ptr:       .res 2
tune_idx:       .res 1
tune_timer:     .res 1
tune_lo:        .res 1
tune_hi:        .res 1
hurt_timer:     .res 1

.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
    stx APU_STATUS

@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

    ; Enable APU channels: pulse 1 + triangle
    lda #%00000101
    sta APU_STATUS

    ; Enable NMI
    lda #%10000000
    sta PPUCTRL

    ; Show title screen
    jsr init_title_screen

    lda #STATE_TITLE
    sta game_state

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

    ; --- Read controller ---
    jsr read_controller

    ; --- Tick the tune (every state: jingle, fanfare and sting all ride it) ---
    jsr tune_tick

    ; --- State dispatch ---
    lda game_state
    cmp #STATE_TITLE
    beq title_update
    cmp #STATE_GAMEOVER
    beq gameover_update
    jmp playing_update

; -----------------------------------------------------------------------------
; Title State
; -----------------------------------------------------------------------------
title_update:
    inc blink_timer         ; the NMI reads bit 5: 32 frames on, 32 off

    lda buttons
    and #BTN_START
    beq @done
    lda prev_buttons
    and #BTN_START
    bne @done

    jsr init_game_screen
    lda #STATE_PLAYING
    sta game_state

@done:
    lda buttons
    sta prev_buttons
    jmp main_loop

; -----------------------------------------------------------------------------
; Game Over State
; -----------------------------------------------------------------------------
gameover_update:
    lda buttons
    and #BTN_START
    beq @done
    lda prev_buttons
    and #BTN_START
    bne @done

    jsr init_title_screen
    lda #STATE_TITLE
    sta game_state

@done:
    lda buttons
    sta prev_buttons
    jmp main_loop

; -----------------------------------------------------------------------------
; Playing State
; -----------------------------------------------------------------------------
playing_update:
    lda #0
    sta moving

    ; --- 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

    lda #%10111000
    sta SQ1_VOL
    lda #%10111001
    sta SQ1_SWEEP
    lda #$C8
    sta SQ1_LO
    lda #$00
    sta SQ1_HI

@no_jump:

    ; --- Move left (with wall check) ---
    lda buttons
    and #BTN_LEFT
    beq @not_left
    lda player_x
    beq @not_left

    lda player_y
    clc
    adc #4
    lsr
    lsr
    lsr
    tax

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    sec
    sbc #1
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    bne @not_left

    dec player_x
    lda #1
    sta moving
@not_left:

    ; --- Move right (with wall check) ---
    lda buttons
    and #BTN_RIGHT
    beq @not_right
    lda player_x
    cmp #RIGHT_WALL
    bcs @not_right

    lda player_y
    clc
    adc #4
    lsr
    lsr
    lsr
    tax

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #8
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    bne @not_right

    inc player_x
    lda #1
    sta moving
@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

    ; --- Boundary check: prevent Y wrap above screen ---
    lda vel_y
    bpl @no_y_wrap          ; Only check when moving up
    lda player_y
    cmp #$F0                ; If Y wrapped to a high value
    bcc @no_y_wrap
    lda #0                  ; Clamp to top of screen
    sta player_y
    sta vel_y
@no_y_wrap:

    ; --- Tile collision (vertical) ---
    lda vel_y
    bmi @no_floor

    lda player_y
    clc
    adc #8
    lsr
    lsr
    lsr
    tax

    cpx #30
    bcs @on_solid

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #4
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    beq @no_floor

@on_solid:
    lda player_y
    clc
    adc #8
    and #%11111000
    sec
    sbc #8
    sta player_y
    lda #0
    sta vel_y
    lda #1
    sta on_ground
    jmp @done_floor

@no_floor:
    lda #0
    sta on_ground

@done_floor:

    ; --- Check collectibles ---
    ldx #8
    jsr check_collect
    ldx #12
    jsr check_collect
    ldx #16
    jsr check_collect

    ; --- Every coin home? The level is won ---
    lda coins_left
    bne @coins_remain
    jsr next_level
@coins_remain:

    ; --- Move obstacle ---
    lda obstacle_x
    sec
    sbc obstacle_speed      ; the level's tuning, read fresh every frame
    sta obstacle_x

    ; --- Collision with obstacle ---
    lda hurt_timer          ; the grace window: fresh from a respawn,
    bne @no_collide         ; nothing can hurt you while you find your feet
    lda on_ground
    beq @no_collide

    lda player_y
    cmp #(FLOOR_Y - 7)
    bcc @no_collide

    lda obstacle_x
    cmp #240
    bcs @no_collide

    lda player_x
    clc
    adc #8
    cmp obstacle_x
    bcc @no_collide
    beq @no_collide

    lda obstacle_x
    clc
    adc #8
    cmp player_x
    bcc @no_collide
    beq @no_collide

    jsr take_damage

@no_collide:

    ; --- Check for hazard tiles ---
    lda hurt_timer          ; the grace window covers the spikes too
    beq @hazard_check
    dec hurt_timer          ; (and this is where it counts down)
    jmp @no_hazard
@hazard_check:
    lda on_ground
    beq @no_hazard

    lda player_y
    clc
    adc #8
    lsr
    lsr
    lsr
    tax

    cpx #30
    bcs @no_hazard

    lda level_rows_lo, x
    sta tile_ptr
    lda level_rows_hi, x
    sta tile_ptr+1

    lda player_x
    clc
    adc #4
    lsr
    lsr
    lsr
    tay

    lda (tile_ptr), y
    cmp #SPIKE_TILE
    bne @no_hazard

    jsr take_damage

@no_hazard:

    ; --- Animation ---
    lda moving
    beq @reset_anim

    inc anim_timer
    lda anim_timer
    cmp #ANIM_SPEED
    bcc @anim_done
    lda #0
    sta anim_timer
    lda anim_frame
    eor #1                  ; Toggle between 0 and 1
    sta anim_frame
    jmp @anim_done

@reset_anim:
    lda #0
    sta anim_timer
    sta anim_frame

@anim_done:

    ; --- Update sprite positions ---
    lda player_y
    sta oam_buffer+0
    lda hurt_timer          ; the grace window is visible: the runner
    beq @no_blink           ; blinks while it lasts
    and #%00000100
    beq @no_blink
    lda #$EF
    sta oam_buffer+0
@no_blink:
    lda player_x
    sta oam_buffer+3

    ; Set animation tile
    lda anim_frame
    beq @frame1
    lda #PLAYER_TILE_2
    jmp @set_tile
@frame1:
    lda #PLAYER_TILE_1
@set_tile:
    sta oam_buffer+1

    lda #FLOOR_Y
    sta oam_buffer+4
    lda obstacle_x
    sta oam_buffer+7

    lda buttons
    sta prev_buttons
    jmp main_loop

; =============================================================================
; Subroutines
; =============================================================================

; -----------------------------------------------------------------------------
; read_controller
; -----------------------------------------------------------------------------
read_controller:
    lda #1
    sta JOYPAD1
    lda #0
    sta JOYPAD1

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

; -----------------------------------------------------------------------------
; check_collect
; -----------------------------------------------------------------------------
check_collect:
    lda oam_buffer, x
    cmp #$EF
    beq @done

    lda player_y
    clc
    adc #8
    cmp oam_buffer, x
    bcc @done
    beq @done

    lda oam_buffer, x
    clc
    adc #8
    cmp player_y
    bcc @done
    beq @done

    lda player_x
    clc
    adc #8
    cmp oam_buffer+3, x
    bcc @done
    beq @done

    lda oam_buffer+3, x
    clc
    adc #8
    cmp player_x
    bcc @done
    beq @done

    lda #$EF
    sta oam_buffer, x
    inc score
    dec coins_left

    lda #%00011000
    sta TRI_LINEAR
    lda #$29
    sta TRI_LO
    lda #$00
    sta TRI_HI

@done:
    rts

; -----------------------------------------------------------------------------
; take_damage
; -----------------------------------------------------------------------------
take_damage:
    lda lives
    beq @done

    dec lives
    bne @still_alive

    ; --- Game over: hide all game sprites ---
    lda #STATE_GAMEOVER
    sta game_state
    lda #0
    sta game_over_drawn

    lda #$EF
    sta oam_buffer+0         ; Player
    sta oam_buffer+4         ; Obstacle
    sta oam_buffer+8         ; Coin 1
    sta oam_buffer+12        ; Coin 2
    sta oam_buffer+16        ; Coin 3

    ldx #<lose_phrase        ; the ending gets a voice
    ldy #>lose_phrase
    jsr start_phrase
    rts

@still_alive:
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    sta anim_timer
    sta anim_frame
    lda #1
    sta on_ground
    lda #60
    sta hurt_timer          ; a second of grace — no obstacle double-kill

    lda #%00111100
    sta SQ1_VOL
    lda #%00000000
    sta SQ1_SWEEP
    lda #$80
    sta SQ1_LO
    lda #$01
    sta SQ1_HI

@done:
    rts

; -----------------------------------------------------------------------------
; place_coins — all three coins back on their spots
; -----------------------------------------------------------------------------
place_coins:
    lda #152
    sta oam_buffer+8
    lda #COIN_TILE
    sta oam_buffer+9
    lda #2
    sta oam_buffer+10
    lda #128
    sta oam_buffer+11

    lda #FLOOR_Y
    sta oam_buffer+12
    lda #COIN_TILE
    sta oam_buffer+13
    lda #2
    sta oam_buffer+14
    lda #200
    sta oam_buffer+15

    lda #168
    sta oam_buffer+16
    lda #COIN_TILE
    sta oam_buffer+17
    lda #2
    sta oam_buffer+18
    lda #32
    sta oam_buffer+19
    rts

; -----------------------------------------------------------------------------
; next_level — every coin home: the world resets, faster
;   Three caps, three jobs: the level COUNTER never stops, the table INDEX
;   clamps at the last row, and the HUD DIGIT (in the NMI) shows 9 forever
;   after. The player's number tells the truth; the physics admits it ran
;   out of new ideas.
; -----------------------------------------------------------------------------
next_level:
    inc level
    lda #3
    sta coins_left
    jsr place_coins

    ; A new field starts at the gate — the runner returns to the start.
    ; (Leave them where they stand and the respawned coin under their
    ; feet collects itself on the next frame. Every reset, every plane.)
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    sta anim_timer
    sta anim_frame
    lda #1
    sta on_ground

    lda #255
    sta obstacle_x

    ldx level               ; table index = level - 1, clamped
    dex
    cpx #LEVEL_TOP
    bcc @speed_ok
    ldx #LEVEL_TOP
@speed_ok:
    lda level_speed_tbl, x
    sta obstacle_speed

    ldx #<fanfare_phrase    ; say so
    ldy #>fanfare_phrase
    jsr start_phrase
    rts

; -----------------------------------------------------------------------------
; start_phrase — point the sequencer at a phrase table
;   X = low byte, Y = high byte of the table; first note due next tick
; -----------------------------------------------------------------------------
start_phrase:
    stx tune_ptr
    sty tune_ptr+1
    lda #0
    sta tune_idx
    lda #1
    sta tune_timer
    rts

; -----------------------------------------------------------------------------
; tune_tick — one frame of the sequencer
;   A phrase is rows of three bytes: period low, period high, frames.
;   Period 0/0 is a rest (silence is a note too). Two terminators, two
;   meanings: frames $FF loops to the top (the title jingle), frames $FE
;   stops for good (a fanfare or a sting says its piece once).
;   The APU holds each note itself — this routine touches the chip twice
;   per note, and the game runs on regardless.
; -----------------------------------------------------------------------------
tune_tick:
    lda tune_ptr+1
    bne @active
    rts                     ; sequencer off
@active:
    dec tune_timer
    beq @next
    rts
@next:
    ldy tune_idx
    lda (tune_ptr), y       ; period, low byte
    sta tune_lo
    iny
    lda (tune_ptr), y       ; period, high byte
    sta tune_hi
    iny
    lda (tune_ptr), y       ; frames — or a marker
    iny
    sty tune_idx

    cmp #$FF                ; loop: round again
    bne @not_loop
    lda #0
    sta tune_idx
    jmp @next
@not_loop:
    cmp #$FE                ; stop: played once, now done
    bne @note
    lda #%00110000          ; silence the pulse
    sta SQ1_VOL
    lda #0
    sta tune_ptr+1          ; sequencer off
    rts
@note:
    sta tune_timer
    lda tune_lo
    ora tune_hi
    bne @sound
    lda #%00110000          ; a rest: quiet, but the clock keeps counting
    sta SQ1_VOL
    rts
@sound:
    lda #%10111000          ; duty 10, constant volume 8
    sta SQ1_VOL
    lda #$00                ; sweep unit off — safe for every period in our
    sta SQ1_SWEEP           ; tables (the idle target stays under $7FF)
    lda tune_lo
    sta SQ1_LO
    lda tune_hi
    sta SQ1_HI              ; the high write restarts the note
    rts

; -----------------------------------------------------------------------------
; init_title_screen
; -----------------------------------------------------------------------------
init_title_screen:
    lda #0
    sta PPUMASK

    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$00
    sta PPUADDR

    lda #0
    ldy #4
    ldx #0
@clear:
    sta PPUDATA
    dex
    bne @clear
    dey
    bne @clear

    ; "DASH" at row 12, column 14 ($218E)
    bit PPUSTATUS
    lda #$21
    sta PPUADDR
    lda #$8E
    sta PPUADDR
    lda #LETTER_D
    sta PPUDATA
    lda #LETTER_A
    sta PPUDATA
    lda #LETTER_S
    sta PPUDATA
    lda #LETTER_H
    sta PPUDATA

    ; "PRESS START" at row 16, column 10 ($220A)
    lda #$22
    sta PPUADDR
    lda #$0A
    sta PPUADDR
    lda #LETTER_P
    sta PPUDATA
    lda #LETTER_R
    sta PPUDATA
    lda #LETTER_E
    sta PPUDATA
    lda #LETTER_S
    sta PPUDATA
    lda #LETTER_S
    sta PPUDATA
    lda #0
    sta PPUDATA
    lda #LETTER_S
    sta PPUDATA
    lda #LETTER_T
    sta PPUDATA
    lda #LETTER_A
    sta PPUDATA
    lda #LETTER_R
    sta PPUDATA
    lda #LETTER_T
    sta PPUDATA

    ; Hide all sprites
    lda #$EF
    ldx #0
@hide:
    sta oam_buffer, x
    inx
    bne @hide

    lda #0
    sta prev_buttons
    sta blink_timer

    ldx #<camptown_phrase   ; the band strikes up
    ldy #>camptown_phrase
    jsr start_phrase

    bit PPUSTATUS
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    lda #%00011110
    sta PPUMASK
    rts

; -----------------------------------------------------------------------------
; init_game_screen
; -----------------------------------------------------------------------------
init_game_screen:
    lda #0
    sta PPUMASK

    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$00
    sta PPUADDR

    lda #0
    ldy #4
    ldx #0
@clear:
    sta PPUDATA
    dex
    bne @clear
    dey
    bne @clear

    ; Ground tiles (rows 26-29)
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$40
    sta PPUADDR

    lda #GROUND_TILE
    ldx #128
@write_ground:
    sta PPUDATA
    dex
    bne @write_ground

    ; Spike tiles
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$4A
    sta PPUADDR
    lda #SPIKE_TILE
    sta PPUDATA

    ; Platform tiles
    bit PPUSTATUS
    lda #$22
    sta PPUADDR
    lda #$8C
    sta PPUADDR

    lda #GROUND_TILE
    ldx #8
@write_platform:
    sta PPUDATA
    dex
    bne @write_platform

    ; Wall tiles — brick, so the coin behind it reads as "hop the wall",
    ; not "the coin is walled off". Two tiles wide, two tall.
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$16
    sta PPUADDR
    lda #WALL_TILE
    sta PPUDATA
    sta PPUDATA

    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$36
    sta PPUADDR
    lda #WALL_TILE
    sta PPUDATA
    sta PPUDATA

    ; HUD
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$22
    sta PPUADDR
    lda #DIGIT_ZERO         ; score starts "000" (three tiles; the NMI keeps it)
    sta PPUDATA
    sta PPUDATA
    sta PPUDATA

    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR
    lda #(DIGIT_ZERO + START_LIVES)
    sta PPUDATA

    ; "L" for the level readout, top centre (the digit is the NMI's job)
    lda #$20
    sta PPUADDR
    lda #$2E
    sta PPUADDR
    lda #LETTER_L
    sta PPUDATA

    ; Attributes
    bit PPUSTATUS
    lda #$23
    sta PPUADDR
    lda #$E8
    sta PPUADDR

    ldx #0
@write_attrs:
    lda attr_data, x
    sta PPUDATA
    inx
    cpx #24
    bne @write_attrs

    ; Player sprite
    lda #PLAYER_Y
    sta oam_buffer+0
    lda #PLAYER_TILE_1
    sta oam_buffer+1
    lda #0
    sta oam_buffer+2
    lda #PLAYER_X
    sta oam_buffer+3

    ; Obstacle sprite
    lda #FLOOR_Y
    sta oam_buffer+4
    lda #OBSTACLE_TILE
    sta oam_buffer+5
    lda #1
    sta oam_buffer+6
    lda #255
    sta oam_buffer+7

    ; Coin sprites
    jsr place_coins

    ; Hide remaining sprites
    lda #$EF
    ldx #20
@hide:
    sta oam_buffer, x
    inx
    bne @hide

    ; Init game variables
    lda #PLAYER_X
    sta player_x
    lda #PLAYER_Y
    sta player_y
    lda #0
    sta vel_y
    sta score
    sta game_over_drawn
    sta prev_buttons
    sta anim_timer
    sta anim_frame
    sta moving
    sta hurt_timer
    lda #START_LIVES
    sta lives
    lda #1
    sta on_ground
    lda #255
    sta obstacle_x

    ; Level one: the counter, the coin count, the live speed
    lda #1
    sta level
    lda #3
    sta coins_left
    lda level_speed_tbl
    sta obstacle_speed

    ; The band stops: silence pulse 1, sequencer off
    lda #%00110000
    sta SQ1_VOL
    lda #0
    sta tune_ptr+1

    bit PPUSTATUS
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    lda #%00011110
    sta PPUMASK
    rts

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

    ; --- OAM DMA ---
    lda #0
    sta OAMADDR
    lda #>oam_buffer
    sta OAMDMA

    ; --- Title: blink PRESS START (VRAM writes belong to the NMI) ---
    lda game_state
    cmp #STATE_TITLE
    bne @not_title

    bit PPUSTATUS
    lda #$22
    sta PPUADDR
    lda #$0A
    sta PPUADDR
    lda blink_timer
    and #%00100000          ; 32 frames on, 32 off
    bne @blink_off
    ldx #0
@blink_text:
    lda press_text, x
    sta PPUDATA
    inx
    cpx #11
    bne @blink_text
    jmp @skip_hud
@blink_off:
    lda #0
    ldx #0
@blink_blank:
    sta PPUDATA
    inx
    cpx #11
    bne @blink_blank
    jmp @skip_hud

@not_title:
    ; --- HUD updates (play / game over) ---
    bit PPUSTATUS
    lda #$20
    sta PPUADDR
    lda #$22
    sta PPUADDR
    ; Score is three decimal digits (a byte, 0-255). Unlike the level digit it
    ; is NOT capped: the player's number tells the truth. Split into hundreds,
    ; tens and units by repeated subtraction; each +DIGIT_ZERO indexes tiles 5-14.
    lda score
    ldx #0                  ; hundreds
@score_h:
    cmp #100
    bcc @score_h_done
    sbc #100                ; carry set by the cmp above
    inx
    bne @score_h            ; x stays < 3 — an always-taken loop-back
@score_h_done:
    pha                     ; stash the tens+units remainder
    txa
    clc
    adc #DIGIT_ZERO
    sta PPUDATA             ; hundreds -> $2022
    pla
    ldx #0                  ; tens
@score_t:
    cmp #10
    bcc @score_t_done
    sbc #10
    inx
    bne @score_t
@score_t_done:
    pha                     ; stash units
    txa
    clc
    adc #DIGIT_ZERO
    sta PPUDATA             ; tens -> $2023
    pla
    clc
    adc #DIGIT_ZERO
    sta PPUDATA             ; units -> $2024

    lda #$20
    sta PPUADDR
    lda #$3C
    sta PPUADDR
    lda lives
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    lda #$20
    sta PPUADDR
    lda #$2F
    sta PPUADDR
    lda level
    cmp #10                 ; the digit caps at 9; the counter doesn't
    bcc @level_digit
    lda #9
@level_digit:
    clc
    adc #DIGIT_ZERO
    sta PPUDATA

    ; "GAME OVER" text (one-shot)
    lda game_state
    cmp #STATE_GAMEOVER
    bne @skip_hud
    lda game_over_drawn
    bne @skip_hud

    lda #$21
    sta PPUADDR
    lda #$CC
    sta PPUADDR
    lda #LETTER_G
    sta PPUDATA
    lda #LETTER_A
    sta PPUDATA
    lda #LETTER_M
    sta PPUDATA
    lda #LETTER_E
    sta PPUDATA
    lda #0
    sta PPUDATA
    lda #DIGIT_ZERO
    sta PPUDATA
    lda #LETTER_V
    sta PPUDATA
    lda #LETTER_E
    sta PPUDATA
    lda #LETTER_R
    sta PPUDATA

    lda #1
    sta game_over_drawn

@skip_hud:

    ; --- Reset scroll ---
    lda #0
    sta PPUSCROLL
    sta PPUSCROLL

    lda #1
    sta nmi_flag

    pla
    tay
    pla
    tax
    pla
    rti

irq:
    rti

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

palette_data:
    .byte $0F, $00, $10, $20
    .byte $0F, $09, $19, $29
    .byte $0F, $00, $10, $20
    .byte $0F, $00, $10, $20
    .byte $0F, $30, $16, $27
    .byte $0F, $16, $27, $30
    .byte $0F, $28, $38, $30
    .byte $0F, $30, $16, $27

attr_data:
    .byte $00, $00, $00, $05, $05, $00, $00, $00
    .byte $50, $50, $50, $50, $50, $54, $50, $50
    .byte $05, $05, $05, $05, $05, $05, $05, $05

press_text:
    .byte LETTER_P, LETTER_R, LETTER_E, LETTER_S, LETTER_S, 0
    .byte LETTER_S, LETTER_T, LETTER_A, LETTER_R, LETTER_T

; Obstacle pixels per frame, one entry per level; the last entry is the wall
level_speed_tbl:
    .byte 2, 3, 3, 4, 4

; Phrases: period low, period high, frames. 0,0 = rest.
; Terminators: frames $FF = loop, $FE = stop. (NTSC pulse periods.)

; Camptown Races — what else would a game called Dash play?
camptown_phrase:
    .byte $1C,$01, 8        ; G  Camp-
    .byte $1C,$01, 8        ; G  town
    .byte $52,$01, 8        ; E  la-
    .byte $1C,$01, 8        ; G  dies
    .byte $FD,$00, 8        ; A  sing
    .byte $1C,$01, 8        ; G  dis
    .byte $52,$01, 16       ; E  song
    .byte $00,$00, 8        ;    (breath)
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 16       ; D  dah
    .byte $00,$00, 6        ;    (breath)
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 16       ; D  dah
    .byte $00,$00, 10       ;    (breath)
    .byte $1C,$01, 8        ; G  Camp-
    .byte $1C,$01, 8        ; G  town
    .byte $52,$01, 8        ; E  race-
    .byte $1C,$01, 8        ; G  track
    .byte $FD,$00, 8        ; A  five
    .byte $1C,$01, 8        ; G  miles
    .byte $52,$01, 16       ; E  long
    .byte $00,$00, 8        ;    (breath)
    .byte $7C,$01, 8        ; D  oh
    .byte $52,$01, 8        ; E  doo-
    .byte $7C,$01, 8        ; D  dah
    .byte $AB,$01, 24       ; C  day
    .byte $00,$00, 30       ;    (and round again)
    .byte $00,$00, $FF      ; loop

; The level fanfare — a rising run, played once
fanfare_phrase:
    .byte $AB,$01, 7        ; C
    .byte $52,$01, 7        ; E
    .byte $1C,$01, 7        ; G
    .byte $D5,$00, 22       ; C, an octave up, held
    .byte $00,$00, $FE      ; stop

; The lose sting — two steps down, then the floor
lose_phrase:
    .byte $52,$01, 10       ; E
    .byte $7C,$01, 10       ; D
    .byte $AB,$01, 10       ; C
    .byte $39,$02, 30       ; G below, long
    .byte $00,$00, $FE      ; stop

; Level Data
level_empty_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0

level_platform_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 3,3,3,3
    .byte 3,3,3,3, 0,0,0,0, 0,0,0,0, 0,0,0,0

level_wall_row:
    .byte 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0
    .byte 0,0,0,0, 0,0,3,3, 0,0,0,0, 0,0,0,0

level_ground_row:
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3

level_ground_spike_row:
    .byte 3,3,3,3, 3,3,3,3, 3,3,15,3, 3,3,3,3
    .byte 3,3,3,3, 3,3,3,3, 3,3,3,3, 3,3,3,3

level_rows_lo:
    .byte <level_empty_row      ; Row 0
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row      ; Row 10
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_platform_row   ; Row 20
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_empty_row
    .byte <level_wall_row       ; Row 24
    .byte <level_wall_row
    .byte <level_ground_spike_row ; Row 26
    .byte <level_ground_row
    .byte <level_ground_row
    .byte <level_ground_row

level_rows_hi:
    .byte >level_empty_row      ; Row 0
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row      ; Row 10
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_platform_row   ; Row 20
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_empty_row
    .byte >level_wall_row       ; Row 24
    .byte >level_wall_row
    .byte >level_ground_spike_row ; Row 26
    .byte >level_ground_row
    .byte >level_ground_row
    .byte >level_ground_row

; =============================================================================
; 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 (frame 1 — legs apart)
.byte %00110000,%00110000,%01111000,%00110000
.byte %00110000,%00101000,%01000100,%01000100
.byte $00,$00,$00,$00,$00,$00,$00,$00

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

; Tile 3: Ground block
.byte $FF,$FF,$FF,$FF,$FF,$FF,$FF,$FF
.byte $FF,$00,$00,$00,$00,$00,$00,$00

; Tile 4: Coin
.byte $3C,$7E,$FF,$FF,$FF,$FF,$7E,$3C
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tiles 5-14: Digits 0-9 (both planes = colour 3)
.byte $70,$88,$88,$88,$88,$88,$70,$00  ; Tile 5: 0
.byte $70,$88,$88,$88,$88,$88,$70,$00
.byte $20,$60,$20,$20,$20,$20,$70,$00  ; Tile 6: 1
.byte $20,$60,$20,$20,$20,$20,$70,$00
.byte $70,$88,$08,$30,$40,$80,$F8,$00  ; Tile 7: 2
.byte $70,$88,$08,$30,$40,$80,$F8,$00
.byte $70,$88,$08,$30,$08,$88,$70,$00  ; Tile 8: 3
.byte $70,$88,$08,$30,$08,$88,$70,$00
.byte $10,$30,$50,$90,$F8,$10,$10,$00  ; Tile 9: 4
.byte $10,$30,$50,$90,$F8,$10,$10,$00
.byte $F8,$80,$F0,$08,$08,$88,$70,$00  ; Tile 10: 5
.byte $F8,$80,$F0,$08,$08,$88,$70,$00
.byte $30,$40,$80,$F0,$88,$88,$70,$00  ; Tile 11: 6
.byte $30,$40,$80,$F0,$88,$88,$70,$00
.byte $F8,$08,$10,$20,$20,$20,$20,$00  ; Tile 12: 7
.byte $F8,$08,$10,$20,$20,$20,$20,$00
.byte $70,$88,$88,$70,$88,$88,$70,$00  ; Tile 13: 8
.byte $70,$88,$88,$70,$88,$88,$70,$00
.byte $70,$88,$88,$78,$08,$10,$60,$00  ; Tile 14: 9
.byte $70,$88,$88,$78,$08,$10,$60,$00

; Tile 15: Spikes
.byte $18,$18,$3C,$3C,$7E,$7E,$FF,$FF
.byte $18,$18,$3C,$3C,$7E,$7E,$FF,$FF

; Tile 16: Letter G
.byte $70,$88,$80,$80,$98,$88,$70,$00
.byte $70,$88,$80,$80,$98,$88,$70,$00

; Tile 17: Letter A
.byte $70,$88,$88,$F8,$88,$88,$88,$00
.byte $70,$88,$88,$F8,$88,$88,$88,$00

; Tile 18: Letter M
.byte $88,$D8,$A8,$88,$88,$88,$88,$00
.byte $88,$D8,$A8,$88,$88,$88,$88,$00

; Tile 19: Letter E
.byte $F8,$80,$80,$F0,$80,$80,$F8,$00
.byte $F8,$80,$80,$F0,$80,$80,$F8,$00

; Tile 20: Letter V
.byte $88,$88,$88,$88,$50,$50,$20,$00
.byte $88,$88,$88,$88,$50,$50,$20,$00

; Tile 21: Letter R
.byte $F0,$88,$88,$F0,$A0,$90,$88,$00
.byte $F0,$88,$88,$F0,$A0,$90,$88,$00

; Tile 22: Letter D
.byte $E0,$90,$88,$88,$88,$90,$E0,$00
.byte $E0,$90,$88,$88,$88,$90,$E0,$00

; Tile 23: Letter S
.byte $70,$88,$80,$70,$08,$88,$70,$00
.byte $70,$88,$80,$70,$08,$88,$70,$00

; Tile 24: Letter H
.byte $88,$88,$88,$F8,$88,$88,$88,$00
.byte $88,$88,$88,$F8,$88,$88,$88,$00

; Tile 25: Letter P
.byte $F0,$88,$88,$F0,$80,$80,$80,$00
.byte $F0,$88,$88,$F0,$80,$80,$80,$00

; Tile 26: Letter T
.byte $F8,$20,$20,$20,$20,$20,$20,$00
.byte $F8,$20,$20,$20,$20,$20,$20,$00

; Tile 27: Running figure (frame 2 — legs together)
.byte %00110000,%00110000,%01111000,%00110000
.byte %00110000,%00110000,%01010000,%01010000
.byte $00,$00,$00,$00,$00,$00,$00,$00

; Tile 28: Letter L
.byte $80,$80,$80,$80,$80,$80,$F8,$00
.byte $80,$80,$80,$80,$80,$80,$F8,$00

; Tile 29: Brick wall — mortar courses in running bond, so the wall reads as
;          built (something you hop onto) rather than smooth ground you can't pass.
.byte %11111111,%11111111,%11110111,%00000000,%11111111,%11111111,%01111110,%00000000
.byte %11111111,%11111111,%11110111,%00000000,%11111111,%11111111,%01111110,%00000000

.res 8192 - 480, $00

If It Doesn’t Work

  • The jingle plays one note and holds it forever? tune_timer isn’t reloaded from the duration column, or tune_idx never advances — the tick keeps agreeing with itself.
  • The tune is silent and the code looks right? The sweep register. $00 here; and remember the channel must be enabled in $4015 too.
  • The jingle keeps playing into the game? Nothing stops the band but you — init_game_screen gates the channel off and clears the sequencer pointer. The APU holding notes by itself cuts both ways.
  • Level 2 starts with a coin already collected? The runner wasn’t returned to the gate — they’re standing on the respawned coin. Every reset resets the player too.
  • The level digit shows : or worse after level 9? The digit isn’t capped — DIGIT_ZERO + 10 is whatever tile lives past the digits. Cap the display, clamp the index, let the counter run.
  • PRESS START flickers garbage or tears? The blink writes are happening outside the NMI. Nametable writes during rendering corrupt the PPU address — the main loop owns the clock, the NMI owns the VRAM.

What You’ve Learnt

  • Levels are data — a counter, a clamped table index, and one live byte the game reads fresh every frame.
  • A sequencer is a table and a tick — pitch, duration, rest-as-a-note, and two terminators: loop for the jingle, stop for the stings.
  • VRAM work belongs to the NMI — the blink is a one-AND clock in the main loop and a few PPUDATA writes in vblank.
  • Winnability is a test — the spike gap was unjumpable and no screenshot ever showed it; driving the game to a win found it in one run.
  • Fairness is mechanics plus visibility — the grace window stops the cascade; the blink tells the player it exists.

The Game Is Whole

Seventeen units. A runner with a world to cross, coins worth winning, levels that answer skill with speed, a tune at the front door, a fanfare for the fold and a sting for the floor — and a title that blinks the way every arcade machine in history has blinked. Phase 1 isn’t just complete now; it’s finished, in the sense that matters: a stranger could pick up the pad, understand it, win at it, lose at it, and go again.

Phase 2 begins with the world beyond a single screen.