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

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.
The Blink
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_timerisn’t reloaded from the duration column, ortune_idxnever advances — the tick keeps agreeing with itself. - The tune is silent and the code looks right? The sweep register.
$00here; and remember the channel must be enabled in$4015too. - The jingle keeps playing into the game? Nothing stops the band but you —
init_game_screengates 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 + 10is 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-
ANDclock in the main loop and a fewPPUDATAwrites 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.