Skip to content

Sprite Movement with Bounds

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

Code status: maintained movement routine, executed on NTSC NROM. The fixture checks 3,984 position/input cases against inclusive saturating bounds. Rendering and controller hardware are separate checks.

Audit execution record · Scope and remaining checks

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

movement.inc
; =============================================================================
; SPRITE MOVEMENT WITH BOUNDS - NES
; Move player sprite with screen boundary clamping
; Taught: Game 1 (Neon Nexus), Unit 2
; =============================================================================

; 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             ; chosen lower margin; stored OAM Y is one less than visible Y

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

.segment "CODE"

; player_x=0..248, player_y=0..224 on entry; initialise these and buttons.
; Changes A/flags, preserves X/Y. Bounds are inclusive. Sequential order is
; up, down, left, right; opposing inputs can be asymmetric at an edge.
; This owns the three declared ZP bytes. Call once after sampling input.
move_player:
    lda buttons
    and #BTN_UP
    beq @check_down
    lda player_y
    sec
    sbc #PLAYER_SPEED
    bcc @clamp_up
    cmp #SCREEN_TOP
    bcs @store_up
@clamp_up:
    lda #SCREEN_TOP
@store_up:
    sta player_y
@check_down:
    lda buttons
    and #BTN_DOWN
    beq @check_left
    lda player_y
    clc
    adc #PLAYER_SPEED
    bcs @clamp_down
    cmp #SCREEN_BOTTOM
    bcc @store_down
@clamp_down:
    lda #SCREEN_BOTTOM
@store_down:
    sta player_y
@check_left:
    lda buttons
    and #BTN_LEFT
    beq @check_right
    lda player_x
    sec
    sbc #PLAYER_SPEED
    bcc @clamp_left
    cmp #SCREEN_LEFT
    bcs @store_left
@clamp_left:
    lda #SCREEN_LEFT
@store_left:
    sta player_x
@check_right:
    lda buttons
    and #BTN_RIGHT
    beq @done
    lda player_x
    clc
    adc #PLAYER_SPEED
    bcs @clamp_right
    cmp #SCREEN_RIGHT
    bcc @store_right
@clamp_right:
    lda #SCREEN_RIGHT
@store_right:
    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

Aspect Cost
CPU Not measured for this caller; include the selected input, drawing and scheduling costs
Memory Assemble the selected routines and caller to obtain code and state sizes
Limitation Fixed 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:

Direction Operation Overflow Check
Up SBC (subtract) BCC directly after — borrow = underflow past 0
Down ADC (add) BCS after CMP — carry set means ≥ boundary
Left SBC (subtract) BCC directly after — borrow = underflow past 0
Right ADC (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:

6502 assembly
; 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:

6502 assembly
; 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):

6502 assembly
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

State and bounds

The include owns player_x, player_y and buttons in zero page. Initialise coordinates within X = 0–248 and Y = 0–224; fill buttons from the same frame’s input sample. Call once per update. A and flags change; X/Y registers survive. The movement order is up, down, left, right, so opposing directions can be asymmetric at a boundary.

Crossing an edge clamps to that edge rather than discarding the whole step. Starting at X = 1 and moving left by two reaches 0. Moving right from 247 reaches 248. Both endpoints are valid positions.

If copied directly into OAM, the stored Y is one less than the visible top scanline. Choose the game’s margins with that encoding in mind; the sample’s 224 is a chosen margin, not a promise to draw a sprite at every physical screen row. Initialise the OAM buffer and hide unused sprites separately.