Skip to content

Sprite Movement with Bounds

Move sprites with D-pad input and screen boundary clamping. Essential for player-controlled objects.

Taught in Game 1, Unit 2 spritesmovementboundsinput

Overview

Player sprites need to respond to D-pad input while staying on screen. Read the controller, check each direction, apply velocity, and clamp to screen boundaries. This pattern handles all four directions independently, allowing diagonal movement.

Code

; =============================================================================
; SPRITE MOVEMENT WITH BOUNDS - NES
; Move player sprite with screen boundary clamping
; Taught: Game 1 (Neon Nexus), Unit 2
; CPU: ~100 cycles | Memory: ~60 bytes
; =============================================================================

; Controller button masks
BTN_UP     = %00001000
BTN_DOWN   = %00000100
BTN_LEFT   = %00000010
BTN_RIGHT  = %00000001

; Movement speed
PLAYER_SPEED = 2                ; Pixels per frame

; Screen boundaries (accounting for 8x8 sprite)
SCREEN_LEFT   = 0
SCREEN_RIGHT  = 248             ; 256 - 8 (sprite width)
SCREEN_TOP    = 0
SCREEN_BOTTOM = 224             ; 240 - 8 (sprite height) - 8 (NTSC bottom overscan)

.segment "ZEROPAGE"
player_x:    .res 1
player_y:    .res 1
buttons:     .res 1

.segment "CODE"

; Move player based on controller input
; Call after read_controller
move_player:
        ; === Check UP ===
        lda buttons
        and #BTN_UP
        beq @check_down

        lda player_y
        sec
        sbc #PLAYER_SPEED       ; Move up (subtract)
        bcc @check_down         ; SBC borrow = went below 0, ignore
        cmp #SCREEN_TOP         ; Optional clamp when SCREEN_TOP > 0
        bcc @check_down
        sta player_y

@check_down:
        lda buttons
        and #BTN_DOWN
        beq @check_left

        lda player_y
        clc
        adc #PLAYER_SPEED       ; Move down (add)
        cmp #SCREEN_BOTTOM
        bcs @check_left         ; Past bottom edge
        sta player_y

@check_left:
        lda buttons
        and #BTN_LEFT
        beq @check_right

        lda player_x
        sec
        sbc #PLAYER_SPEED       ; Move left (subtract)
        bcc @check_right        ; SBC borrow = went below 0, ignore
        cmp #SCREEN_LEFT
        bcc @check_right
        sta player_x

@check_right:
        lda buttons
        and #BTN_RIGHT
        beq @done

        lda player_x
        clc
        adc #PLAYER_SPEED       ; Move right (add)
        cmp #SCREEN_RIGHT
        bcs @done               ; Past right edge
        sta player_x

@done:
        rts

Why BCC immediately after SBC: CMP overwrites the carry flag with its own result, so a CMP / BCC after SBC doesn't catch underflow when the minimum is 0 — $FF (the wrapped result) is still ≥ 0 unsigned, so the carry stays set. The pattern above checks the SBC's borrow first (BCC reads SBC's carry), then optionally compares against a non-zero minimum.

Trade-offs

AspectCost
CPU~100 cycles
Memory~60 bytes
LimitationFixed speed, no acceleration

When to use: Any game with player-controlled sprites.

When to avoid: Grid-based movement - check edges differently.

Understanding the Bounds Checks

The 6502 has no signed comparison, so we use carry flag tricks:

DirectionOperationOverflow Check
UpSBC (subtract)BCC directly after — borrow = underflow past 0
DownADC (add)BCS after CMP — carry set means ≥ boundary
LeftSBC (subtract)BCC directly after — borrow = underflow past 0
RightADC (add)BCS after CMP — carry set means ≥ boundary

For SBC, the BCC must read the SBC's carry directly. CMP would overwrite it.

Signed-comparison alternative

For movement that allows negative coordinates (e.g., scrolling worlds where X can be off-screen left), use signed compare via XOR with #$80:

; Signed compare: A vs operand, branching on signed condition
; Trick: XOR both with $80 to flip the sign bit, then compare unsigned
signed_cmp_max:
        eor #$80
        cmp #(MAX + $80)
        ; Now BCC = signed less, BCS = signed greater-or-equal

Most NES games stick to unsigned 8-bit screen coordinates because the visible area fits in 0-255 anyway. Signed-comparison patterns matter more for off-screen object tracking.

Diagonal Movement

This pattern checks all four directions independently, so holding Up+Right moves diagonally. The total speed increases by ~41% on diagonals. For consistent diagonal speed:

; Reduce diagonal speed (approximation)
DIAG_SPEED = 1                  ; Use slower speed for diagonals

move_player:
        lda buttons
        and #(BTN_UP | BTN_DOWN | BTN_LEFT | BTN_RIGHT)
        ; Check if multiple directions held...
        ; Use DIAG_SPEED instead of PLAYER_SPEED

Updating OAM

After moving, copy position to OAM buffer (typically in NMI):

nmi:
        ; ... save registers ...

        ; Update sprite position in OAM
        lda player_y
        sta oam_buffer+0        ; Y position
        lda player_x
        sta oam_buffer+3        ; X position

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

        ; ... restore registers ...
        rti

Patterns: Controller Reading, NMI Game Loop

Vault: NES