Skip to content

Sprite Movement with Bounds

Move a C64 sprite with joystick port 2, clamp it to the screen edges, and handle its ninth X bit without disturbing other sprites.

Code status: assembled and emulator-executed. PAL and NTSC C64, normal-size single-colour sprite 0, in Emu198x and VICE. The recorded checks identify the tested sources and observations. They establish these cases on those configurations; original-hardware checks are separate.

Move the sprite

This example puts a white 24×21-pixel sprite on a black screen. Use joystick port 2 to move it. Hold a direction and it reaches the edge, then stays there. Move diagonally and both coordinates change. Release the stick and the sprite stops.

The movement rule takes two pixels per update and clamps each coordinate to its permitted range. If Y=51 and you move up, the proposed position is 49, so the result is 50. Rejecting that step would leave a one-pixel gap; clamping reaches the edge.

Run it

Use the maintained samples checkout, keeping its folder structure. The demonstration reuses the setup routine and shape from Hardware Sprites.

Shell
git clone https://github.com/code198x/code-samples.git
cd code-samples/commodore-64/patterns/assembly/physics/sprite-movement-bounds
make

make uses Asm198x to produce build/demo.prg. Load it in a PAL or NTSC C64 emulator, type RUN, and map your controls to port 2. Reset to leave.

The standalone program takes over the screen, VIC bank, sprites, CIA port directions and interrupt masks. It maps out BASIC and KERNAL and does not return to BASIC. It is not a subroutine you can drop into a program that still relies on those services.

demo.asm
; Load, RUN, then use joystick port 2. Reset to leave.
; It owns the screen, VIC bank, CIA interrupt masks and sprite registers.
DEMO_X = 160
DEMO_Y = 140
SHARED_SEED = 0             ; verification seeds bits belonging to other sprites

* = $0801
    !byte $0b, $08, $0a, $00, $9e
    !text "2064"
    !byte 0, 0, 0
* = $0810
start:
    sei
    lda #$7f
    sta $dc0d
    sta $dd0d
    lda $dc0d
    lda $dd0d
    lda #$35
    sta $01                  ; RAM at BASIC/KERNAL, I/O visible
    lda #<nmi_return
    sta $fffa
    lda #>nmi_return
    sta $fffb
    lda $dd02
    ora #3
    sta $dd02
    lda $dd00
    ora #3
    sta $dd00                ; VIC bank 0
    lda #$14
    sta $d018                ; screen $0400, character ROM $1000
    lda #$1b
    sta $d011                ; display on, normal text
    lda #$08
    sta $d016                ; 40 columns, single-colour text
    lda #0
    sta $d015
    sta $d020
    sta $d021
    ldx #0
    lda #32
clear_screen:
    sta $0400, x
    sta $0500, x
    sta $0600, x
    sta $06e8, x
    inx
    bne clear_screen
    ; Blank shapes keep the other sprites invisible during ownership checks.
    ldx #7
    lda #$81
blank_pointers:
    sta $07f8, x
    dex
    bpl blank_pointers
    lda #SHARED_SEED
    sta $d015
    ora #1                   ; sprite 0 starts with wrong high/mode bits
    sta $d010
    sta $d017
    sta $d01b
    sta $d01c
    sta $d01d
    lda #<DEMO_X
    ldx #>DEMO_X
    ldy #DEMO_Y
    jsr sprite0_show
    lda #0
    sta $dc02                ; both CIA1 ports input: no keyboard scanning
    sta $dc03
    lda #<DEMO_X
    sta sprite_x_lo
    lda #>DEMO_X
    sta sprite_x_hi
    lda #DEMO_Y
    sta sprite_y
    jmp wait_top
    ; Guest snapshots make input, movement and shared bits observable.
copy_registers_start:
    ldx #0
copy_registers:
    lda $d000, x
    sta observed, x
    inx
    cpx #$2f
    bne copy_registers
    inc frames
frame_done:
    jmp wait_top
wait_top:
    bit $d011
    bmi wait_top
wait_picture_end:
    lda $d012
    cmp #250
    bne wait_picture_end
picture_ready:               ; debugger observation point; completed picture
wait_bottom:
    bit $d011
    bpl wait_bottom
    jsr read_joystick2
    jsr move_sprite
    jmp copy_registers_start
nmi_return:
    rti
frames:
    !byte 0
observed:
    !fill $2f, 0

read_joystick2:
    lda $dc00
    eor #$ff
    and #$0f
    rts

!source "movement.inc"
!source "../../rendering/hardware-sprites/sprite.inc"
!source "../../rendering/hardware-sprites/shape.inc"
blank_shape:
    !fill 64, 0

The caller reads $DC00 once per update. Its direction switches are active low, so EOR #$FF turns pressed switches into set bits. Both CIA1 ports are configured as inputs and keyboard scanning is disabled. That ownership matters when combining joystick code with a keyboard reader.

The movement contract

move_sprite takes pressed direction bits in A: up=1, down=2, left=4, right=8. It reads and updates sprite_x_lo, sprite_x_hi and sprite_y, then writes sprite 0’s VIC position. It changes A and flags, preserves X and Y, and keeps its input in joy_state.

Call with the processor in binary arithmetic mode (decimal flag clear). State must start inside X=24..320, Y=50..229, inclusive, with X’s high byte equal to 0 or 1. The routine assumes a normal-size sprite and the display window set up by this caller.

Opposite directions cancel on their axis. Up+down therefore leaves Y alone; left+right leaves X alone. A diagonal applies both axes, moving two pixels horizontally and two vertically. It covers more distance than a straight move; there is no diagonal speed normalisation. Fire does nothing here.

movement.inc
; A = pressed directions: bit 0 up, 1 down, 2 left, 3 right.
; Opposites cancel per axis; fire and other bits are ignored.
; State must start within X=24..320, Y=50..229. Two pixels per call.
; Clobbers A and flags; preserves X/Y. Owns sprite 0 position, not its modes.
MIN_X = 24
MAX_X = 320
MIN_Y = 50
MAX_Y = 229
MOVE_SPEED = 2

move_sprite:
    sta joy_state
    and #3
    cmp #1
    beq move_up
    cmp #2
    beq move_down
    jmp move_horizontal
move_up:
    lda sprite_y
    sec
    sbc #MOVE_SPEED
    cmp #MIN_Y
    bcs store_y
    lda #MIN_Y
    jmp store_y
move_down:
    lda sprite_y
    clc
    adc #MOVE_SPEED
    cmp #MAX_Y
    bcc store_y
    lda #MAX_Y
store_y:
    sta sprite_y
move_horizontal:
    lda joy_state
    and #12
    cmp #4
    beq move_left
    cmp #8
    beq move_right
    jmp update_sprite_pos
move_left:
    lda sprite_x_lo
    sec
    sbc #MOVE_SPEED
    sta sprite_x_lo
    lda sprite_x_hi
    sbc #0
    sta sprite_x_hi
    bne update_sprite_pos
    lda sprite_x_lo
    cmp #MIN_X
    bcs update_sprite_pos
    lda #MIN_X
    sta sprite_x_lo
    jmp update_sprite_pos
move_right:
    lda sprite_x_lo
    clc
    adc #MOVE_SPEED
    sta sprite_x_lo
    lda sprite_x_hi
    adc #0
    sta sprite_x_hi
    cmp #>MAX_X
    bcc update_sprite_pos
    bne clamp_right
    lda sprite_x_lo
    cmp #<MAX_X
    bcc update_sprite_pos
clamp_right:
    lda #<MAX_X
    sta sprite_x_lo
    lda #>MAX_X
    sta sprite_x_hi
update_sprite_pos:
    lda sprite_x_lo
    sta $d000
    lda sprite_y
    sta $d001
    lda $d010
    and #$fe
    ora sprite_x_hi
    sta $d010
    rts
movement_end:

sprite_x_lo: !byte 160
sprite_x_hi: !byte 0
sprite_y:    !byte 140
joy_state:   !byte 0

Crossing X=255

The VIC-II splits a sprite’s X coordinate between a low-byte register and a bit in $D010. Moving right from 254 gives 256: the low byte becomes 0 and the high bit becomes 1. Moving left from 256 gives 254: the borrow clears that high bit again.

update_sprite_pos clears only bit 0 of $D010, then inserts sprite 0’s high bit. Bits 1–7 belong to the other sprites and remain unchanged. The routine also leaves their positions, colours and modes alone. The caller must exclude a competing writer during the read/modify/write; preserving bits does not make the sequence atomic.

Inclusive screen bounds

These are sprite coordinates for the normal 40-column, 25-row window:

Axis Visible coordinates Sprite size Last fully visible origin
X 24 through 343 24 pixels 343 - (24 - 1) = 320
Y 50 through 249 21 lines 249 - (21 - 1) = 229

An origin plus size - 1 gives the final occupied coordinate. That is why the lower bound includes Y=229, rather than stopping at 228. Expansion or a different display window needs different bounds.

One update per frame

The caller polls the raster and updates below the displayed area, after the sprite has been drawn. Its frame counter and register snapshot make progress observable to the verifier. This is a small polling loop with exclusive hardware ownership, not an interrupt-driven game framework.

Two pixels per frame means about 100 pixels per second on PAL and 120 on NTSC for straight movement. Keeping the same speed per second would require a separate regional timing policy. This example preserves the simple frame-based rule.

Trade-offs

Aspect Cost or limit
Code 160 assembled bytes for the movement routine, excluding the caller and sprite setup
State Four bytes for X low/high, Y and the sampled direction mask
Display A 64-byte sprite block plus its pointer; sprite DMA still takes CPU bus time
CPU Work depends on the selected directions and clamping path; no measured cycle bound is claimed
Movement Normal-size sprite 0, fixed bounds, two pixels per update; diagonals and PAL/NTSC have the speed differences described above

What has been checked

The verification recipe compares Asm198x and ACME output, then executes 6,144 arithmetic/register cases across Emu198x and VICE on PAL and NTSC. It covers both sides of every boundary, odd and even coordinates, X=255/256, all direction/fire combinations and preservation of seeded high bits belonging to other sprites.

A separate sequence drives the emulator control ports and checks 48 captures, including held input, release, both X-byte transitions, corners, diagonals and opposite directions. It reads state first, then releases the controls for one unchanged frame before checking every lit pixel in the completed picture. Injected direction-mask tests alone do not establish joystick wiring.

The old listing’s boundary failures are reproduced, and deliberately removing a clamp fails the same observer. These are configuration-specific emulator checks, not physical joystick or original-hardware tests.

Patterns: Hardware Sprites, Joystick Reading, Game Loop (Basic)

Vault: Commodore 64

Sources

Commodore, Commodore 64 Programmer’s Reference Guide (1983), chapter 3, pp. 131–144 (sprite size and positioning), and pp. 343–344 (control ports, direction switches and active-low input). Clamping, opposite-direction cancellation and diagonal speed are the example’s software choices.