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
; =============================================================================
; 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:
rtsWhy 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:
; 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-equalMost 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_SPEEDUpdating 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 ...
rtiRelated
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.