Ball In Cup 2600
A downloadable project
; ===========================================================================
; BALL IN CUP
; An original game for the Atari 2600 Video Computer System (VCS)
; ---------------------------------------------------------------------------
; Written by:
;
; Dedicated, with all my love, to my son:
;
; R A M D I F A L L U J A H K H A N D H A R
;
; Ramdi -- if you are reading this, then the string finally pulled the ball
; home. I built this the old way: by hand, register by register, the same
; way we shaped games on the 2600 back when every byte was a decision and
; every scanline was a held breath. I hoped that one day you would open this
; source and feel what I felt at the machine: that a small, honest program
; can be beautiful -- revelation after revelation -- and that a father can
; hide a whole heart inside four kilobytes.
;
; Swing the ball. Find the cup. Begin.
;
; -- Dad
; ===========================================================================
;
; TARGET : Atari 2600 (NTSC), 4K ROM, standard joystick in LEFT port.
; ASSEMBLE : dasm src/ball_in_cup.asm -f3 -Isrc -obin/ball_in_cup.bin
;
; HOW TO PLAY
; * A white ball swings on its tether above a green cup.
; * Push the joystick LEFT / RIGHT to pump the swing, like the real toy.
; * Press the BUTTON to let go -- the ball drops with the momentum it has.
; * Land it in the cup to score. Miss, and it simply swings again.
; There is no losing here, Ramdi. Only trying. That is how you learn
; anything worth learning.
; ===========================================================================
processor 6502
include "vcs.h"
include "macro.h"
; ---------------------------------------------------------------------------
; COLOUR CONSTANTS (NTSC)
; ---------------------------------------------------------------------------
COL_BG = $00 ; black night sky
COL_BALL = $0E ; white ball
COL_SCORE = $1E ; bright yellow score
COL_CUP = $C8 ; warm green cup
COL_FLASH = $4A ; orange flash shown briefly on a catch
; ---------------------------------------------------------------------------
; GAMEPLAY CONSTANTS
; ---------------------------------------------------------------------------
CENTER_X = 76 ; resting X of the ball (cup mouth centre)
START_Y = 92 ; resting scanline of the ball while tethered
SKY_TOP = 32 ; first scanline of the open-sky region
RIM_Y = 150 ; scanline of the cup rim (the catch line)
SWING_MAX = 5 ; clamp on swing speed
PUMP = 1 ; swing energy added per joystick push
GRAV_MASK = 3 ; apply gravity once every (mask+1) frames
FALL_MAX = 5 ; terminal fall speed
CATCH_LO = 64 ; left edge of the forgiving catch window
CATCH_HI = 88 ; right edge of the forgiving catch window
EDGE_LO = 16 ; how far left the ball may swing
EDGE_HI = 136 ; how far right the ball may swing
SCORE_P0_X = 68 ; screen X of the tens digit
SCORE_P1_X = 76 ; screen X of the ones digit
; ---------------------------------------------------------------------------
; RAM (zero page $80..$FF)
; ---------------------------------------------------------------------------
SEG.U variables
ORG $80
BallX ds 1 ; ball X (screen pixel of left edge)
BallY ds 1 ; ball Y (absolute scanline)
BallVX ds 1 ; horizontal velocity (signed)
BallVY ds 1 ; vertical velocity (downward, free flight)
State ds 1 ; 0 = tethered/swinging, 1 = free flight
ScoreBCD ds 1 ; score, packed BCD 00..99
BallYSky ds 1 ; BallY - SKY_TOP, precomputed for the kernel
FrameCtr ds 1 ; free-running frame counter
FireLatch ds 1 ; button edge-detect latch
SoundTimer ds 1 ; frames remaining for the catch beep
FlashTimer ds 1 ; frames remaining for the catch colour flash
TensPtr ds 2 ; pointer to tens-digit graphic
OnesPtr ds 2 ; pointer to ones-digit graphic
; ===========================================================================
; ROM
; ===========================================================================
SEG code
ORG $F000
; ---------------------------------------------------------------------------
; RESET -- cold start
; ---------------------------------------------------------------------------
Reset
CLEAN_START ; clear RAM + TIA, set the stack (macro.h)
lda #CENTER_X-26 ; start the ball off-centre so it swings at boot
sta BallX
lda #START_Y
sta BallY
lda #SWING_MAX
sta BallVX ; an initial push to the right
lda #0
sta BallVY
sta State
sta ScoreBCD
sta FireLatch
sta SoundTimer
sta FlashTimer
; ---------------------------------------------------------------------------
; MAIN LOOP -- one pass per frame (NTSC: 3 + 37 + 192 + 30 = 262 lines)
; ---------------------------------------------------------------------------
MainLoop
; ----- VERTICAL SYNC (3 lines) -----
lda #2
sta VSYNC
sta WSYNC
sta WSYNC
sta WSYNC
lda #0
sta VSYNC
; ----- VERTICAL BLANK: arm timer, run logic, place objects -----
lda #2
sta VBLANK ; keep the beam blanked
lda #44
sta TIM64T ; ~37 lines of vblank
jsr GameLogic ; physics, input, scoring (no WSYNCs)
jsr PositionScore ; place P0/P1 for the score (uses WSYNCs)
.waitVBlank
lda INTIM
bne .waitVBlank
sta WSYNC
lda #0
sta VBLANK ; beam on -- picture begins now
jsr KernelScore ; lines 0..15 : the score
jsr KernelTransition ; lines 16..31 : reposition P0 as the ball
jsr KernelSky ; lines 32..151 : sky + swinging ball
jsr KernelCup ; lines 152..191: the cup
; ----- OVERSCAN (30 lines) -----
lda #2
sta VBLANK
lda #36
sta TIM64T
.waitOver
lda INTIM
bne .waitOver
sta WSYNC
jmp MainLoop
; ===========================================================================
; GAME LOGIC (runs during vertical blank -- no cycle-critical WSYNCs)
; ===========================================================================
GameLogic
inc FrameCtr
; --- fade out the catch beep / flash when their timers run down ---
lda SoundTimer
beq .soundDone
dec SoundTimer
bne .soundDone
lda #0
sta AUDV0 ; silence
.soundDone
lda FlashTimer
beq .flashDone
dec FlashTimer
.flashDone
lda State
bne FreeFlight
; ----- TETHERED: the ball swings; the player pumps it ----------------------
Tethered
; Joystick LEFT (SWCHA bit6 = 0 when pushed)
lda SWCHA
and #%01000000
bne .notLeft
lda BallVX
sec
sbc #PUMP
sta BallVX
.notLeft
; Joystick RIGHT (SWCHA bit7 = 0 when pushed)
lda SWCHA
and #%10000000
bne .notRight
lda BallVX
clc
adc #PUMP
sta BallVX
.notRight
; A gentle restoring pull toward centre, applied every 4th frame, gives
; the tether its lazy pendulum sway without ever standing still.
lda FrameCtr
and #3
bne .noPull
lda BallX
cmp #CENTER_X
beq .noPull
bcs .rightOfCentre
inc BallVX ; left of centre -> pull right
jmp .noPull
.rightOfCentre
dec BallVX ; right of centre -> pull left
.noPull
jsr ClampSwing
jsr MoveX ; BallX += BallVX, bounce at the edges
; BUTTON = release the ball into free flight.
lda INPT4
bmi .buttonUp ; bit7 = 1 -> not pressed
lda FireLatch
bne LogicDone ; ignore a held button (edge only)
lda #1
sta FireLatch
sta State ; -> free flight
lda #1
sta BallVY ; start gently falling
jmp LogicDone
.buttonUp
lda #0
sta FireLatch
jmp LogicDone
; ----- FREE FLIGHT: gravity takes over; did we find the cup? ----------------
FreeFlight
; gravity every (GRAV_MASK+1) frames
lda FrameCtr
and #GRAV_MASK
bne .noGravity
lda BallVY
cmp #FALL_MAX
bcs .noGravity
inc BallVY
.noGravity
lda BallY
clc
adc BallVY
sta BallY
jsr MoveX ; keep drifting horizontally as it falls
lda BallY
cmp #RIM_Y
bcc LogicDone ; still above the rim -> keep flying
; Reached the rim. Are we inside the forgiving catch window?
lda BallX
cmp #CATCH_LO
bcc .missed
cmp #CATCH_HI+1
bcs .missed
; ----- CAUGHT! -----
sed
lda ScoreBCD
clc
adc #1
sta ScoreBCD
cld
lda #8 ; a soft, pure tone
sta AUDC0
lda #16
sta AUDF0
lda #12
sta AUDV0
lda #18
sta SoundTimer
lda #20
sta FlashTimer
.missed
jsr ResetToy
LogicDone
; precompute the ball's row within the sky for the kernel
lda BallY
sec
sbc #SKY_TOP
sta BallYSky
rts
; ---- return the ball to its resting, tethered swing ----
ResetToy
lda #CENTER_X-26
sta BallX
lda #START_Y
sta BallY
lda #0
sta BallVY
sta State
lda #SWING_MAX
sta BallVX
rts
; ---- clamp BallVX to +/- SWING_MAX ----
ClampSwing
lda BallVX
bmi .neg
cmp #SWING_MAX+1
bcc .ok
lda #SWING_MAX
sta BallVX
rts
.neg
cmp #256-SWING_MAX
bcs .ok
lda #256-SWING_MAX
sta BallVX
.ok
rts
; ---- BallX += BallVX, with a soft bounce at the swing limits ----
MoveX
lda BallX
clc
adc BallVX ; signed add (two's complement low byte)
sta BallX
cmp #EDGE_LO
bcs .mvLeft
lda #EDGE_LO
sta BallX
jsr FlipVX
rts
.mvLeft
cmp #EDGE_HI+1
bcc .mvRight
lda #EDGE_HI
sta BallX
jsr FlipVX
.mvRight
rts
; ---- BallVX = -BallVX ----
FlipVX
lda #0
sec
sbc BallVX
sta BallVX
rts
; ===========================================================================
; POSITION THE SCORE PLAYERS (during vblank)
; ===========================================================================
PositionScore
lda #SCORE_P0_X
ldx #0
jsr PosObject ; player 0 = tens
lda #SCORE_P1_X
ldx #1
jsr PosObject ; player 1 = ones
sta WSYNC
sta HMOVE
rts
; ---- Classic divide-by-15 horizontal positioning ----
; A = target X (0..159), X = object (0 = P0, 1 = P1)
PosObject
sec
sta WSYNC
.div15
sbc #15
bcs .div15
eor #7
asl
asl
asl
asl
sta HMP0,x
sta RESP0,x
rts
; ===========================================================================
; KERNEL: SCORE (lines 0..15) -- two BCD digits via players 0 and 1
; ===========================================================================
KernelScore
; build pointers to the two digit graphics from the BCD score
lda ScoreBCD
and #$F0
lsr
lsr
lsr
lsr ; A = tens (0..9)
asl
asl
asl ; tens * 8
clc
adc #<DigitGfx
sta TensPtr
lda #>DigitGfx
adc #0
sta TensPtr+1
lda ScoreBCD
and #$0F
asl
asl
asl ; ones * 8
clc
adc #<DigitGfx
sta OnesPtr
lda #>DigitGfx
adc #0
sta OnesPtr+1
lda #COL_SCORE
sta COLUP0
sta COLUP1
ldx #0 ; line within the 16-line score band
.scoreLoop
txa
lsr ; X/2 -> font row 0..7 (each row 2 lines tall)
tay
lda (TensPtr),y
sta WSYNC
sta GRP0
lda (OnesPtr),y
sta GRP1
inx
cpx #16
bne .scoreLoop
lda #0
sta GRP0
sta GRP1
rts
; ===========================================================================
; KERNEL: TRANSITION (lines 16..31) -- reposition P0 to become the ball
; ===========================================================================
KernelTransition
lda #0
sta GRP0
sta GRP1
lda BallX
ldx #0
jsr PosObject ; 1 WSYNC, sets P0's coarse + fine position
sta WSYNC
sta HMOVE
; the ball's colour: normally white, briefly orange right after a catch
ldx #COL_BALL
lda FlashTimer
beq .noFlash
ldx #COL_FLASH
.noFlash
stx COLUP0
ldx #13 ; pad out the rest of the transition band
.pad
sta WSYNC
dex
bne .pad
rts
; ===========================================================================
; KERNEL: SKY (lines 32..151) -- the swinging ball (player 0)
; ===========================================================================
KernelSky
ldy #0 ; Y = sky line 0..119
.skyLoop
tya
sec
sbc BallYSky ; A = line - ball top
cmp #8
bcs .blank ; outside the 8-pixel ball
tax
lda BallGfx,x
jmp .draw
.blank
lda #0
.draw
sta WSYNC
sta GRP0
iny
cpy #120
bne .skyLoop
lda #0
sta GRP0
rts
; ===========================================================================
; KERNEL: CUP (lines 152..191) -- a reflected-playfield goblet
; ===========================================================================
KernelCup
lda #COL_CUP
sta COLUPF
lda #%00000001 ; reflect the playfield -> a symmetric cup
sta CTRLPF
lda #0
sta PF0
sta PF1
lda #$18 ; the two upright cup walls
sta PF2
ldx #31
.walls
sta WSYNC
dex
bne .walls
lda #$F8 ; the cradle / floor of the cup
sta PF2
ldx #9
.floor
sta WSYNC
dex
bne .floor
lda #0 ; tidy up so nothing bleeds into overscan
sta PF2
sta COLUPF
rts
; ===========================================================================
; GRAPHICS DATA
; ===========================================================================
; ---- the ball: a tidy 8x8 sphere ----
BallGfx
.byte %00111100
.byte %01111110
.byte %11111111
.byte %11111111
.byte %11111111
.byte %11111111
.byte %01111110
.byte %00111100
; ---- digit font 0..9, eight rows each (bit7 = leftmost pixel) ----
DigitGfx
.byte %00111100 ; 0
.byte %01100110
.byte %01101110
.byte %01110110
.byte %01100110
.byte %01100110
.byte %00111100
.byte %00000000
.byte %00011000 ; 1
.byte %00111000
.byte %00011000
.byte %00011000
.byte %00011000
.byte %00011000
.byte %01111110
.byte %00000000
.byte %00111100 ; 2
.byte %01100110
.byte %00000110
.byte %00001100
.byte %00110000
.byte %01100000
.byte %01111110
.byte %00000000
.byte %00111100 ; 3
.byte %01100110
.byte %00000110
.byte %00011100
.byte %00000110
.byte %01100110
.byte %00111100
.byte %00000000
.byte %00001100 ; 4
.byte %00011100
.byte %00111100
.byte %01101100
.byte %01111110
.byte %00001100
.byte %00001100
.byte %00000000
.byte %01111110 ; 5
.byte %01100000
.byte %01111100
.byte %00000110
.byte %00000110
.byte %01100110
.byte %00111100
.byte %00000000
.byte %00011100 ; 6
.byte %00110000
.byte %01100000
.byte %01111100
.byte %01100110
.byte %01100110
.byte %00111100
.byte %00000000
.byte %01111110 ; 7
.byte %00000110
.byte %00001100
.byte %00011000
.byte %00110000
.byte %00110000
.byte %00110000
.byte %00000000
.byte %00111100 ; 8
.byte %01100110
.byte %01100110
.byte %00111100
.byte %01100110
.byte %01100110
.byte %00111100
.byte %00000000
.byte %00111100 ; 9
.byte %01100110
.byte %01100110
.byte %00111110
.byte %00000110
.byte %00001100
.byte %00111000
.byte %00000000
; ===========================================================================
; HIDDEN MESSAGE -- never shown on screen, but written into the cartridge so
; that one day, reading the raw bytes, you will find it. Hi, Ramdi. (ASCII)
; ===========================================================================
Dedication
.byte "FOR RAMDI FALLUJAH KHANDHAR. ", 0
.byte "FROM YOUR FATHER, . ", 0
.byte "EVERY BYTE HERE WAS PLACED BY HAND, AND EACH ONE MEANS I LOVE YOU. ", 0
.byte "SWING THE BALL. FIND THE CUP. BEGIN.", 0
; ===========================================================================
; 6502 VECTORS (top of the 4K bank)
; ===========================================================================
ORG $FFFA
.word Reset ; NMI
.word Reset ; RESET
.word Reset ; IRQ
END
