Overview
The NES supports 64 sprites, each requiring 4 bytes in OAM (Object Attribute Memory). For multiple game objects, you need to manage their OAM entries systematically - updating positions, hiding inactive objects, and handling the OAM DMA transfer during NMI.
Code
; =============================================================================
; MULTI-SPRITE OAM UPDATE - NES
; Manage multiple sprites in OAM buffer
; Taught: Game 1 (Neon Nexus), Unit 5
; CPU: ~200 cycles | Memory: ~100 bytes
; =============================================================================
OAMADDR = $2003
OAMDMA = $4014
MAX_OBJECTS = 8 ; Maximum active objects
SPRITE_HIDDEN = $FF ; Y position to hide sprite
.segment "OAM"
oam_buffer: .res 256 ; Must be page-aligned ($xx00)
.segment "ZEROPAGE"
; Object data (parallel arrays)
obj_x: .res MAX_OBJECTS ; X positions
obj_y: .res MAX_OBJECTS ; Y positions
obj_tile: .res MAX_OBJECTS ; Tile numbers
obj_attr: .res MAX_OBJECTS ; Attributes (palette, flip)
obj_active: .res MAX_OBJECTS ; 0=inactive, 1=active
.segment "CODE"
; Update all active objects in OAM buffer
; Call this before NMI triggers DMA
update_oam:
ldx #0 ; Object index
ldy #0 ; OAM buffer index
@object_loop:
lda obj_active, x
beq @skip_object ; Skip inactive objects
; Copy object to OAM buffer
; Byte 0: Y position
lda obj_y, x
sta oam_buffer, y
iny
; Byte 1: Tile number
lda obj_tile, x
sta oam_buffer, y
iny
; Byte 2: Attributes
lda obj_attr, x
sta oam_buffer, y
iny
; Byte 3: X position
lda obj_x, x
sta oam_buffer, y
iny
jmp @next_object
@skip_object:
; Write hidden sprite to OAM
lda #SPRITE_HIDDEN
sta oam_buffer, y
iny
iny
iny
iny ; Skip 4 bytes
@next_object:
inx
cpx #MAX_OBJECTS
bne @object_loop
; Hide remaining sprites (up to 64)
lda #SPRITE_HIDDEN
@hide_rest:
cpy #0 ; Wrapped around = done
beq @done
sta oam_buffer, y
iny
iny
iny
iny
bne @hide_rest
@done:
rts
; Perform OAM DMA transfer (call from NMI)
do_oam_dma:
lda #0
sta OAMADDR ; Start at OAM address 0
lda #>oam_buffer ; High byte of buffer address
sta OAMDMA ; Triggers 256-byte DMA transfer
rts
; Hide all sprites (call at init or game over)
hide_all_sprites:
lda #SPRITE_HIDDEN
ldx #0
@loop:
sta oam_buffer, x
inx
inx
inx
inx
bne @loop
rts
; Spawn object at index X
; Input: A=tile, obj_x[X], obj_y[X] already set
spawn_object:
sta obj_tile, x
lda #0
sta obj_attr, x ; Default attributes
lda #1
sta obj_active, x
rts
; Deactivate object at index X
kill_object:
lda #0
sta obj_active, x
rts
Trade-offs
| Aspect | Cost |
|---|---|
| CPU | ~200 cycles for 8 objects |
| Memory | ~100 bytes code + 256 OAM + arrays |
| Limitation | 8 sprites per scanline hardware limit |
When to use: Games with multiple moving objects (enemies, bullets, items).
When to avoid: Single-sprite games - use direct OAM writes instead.
OAM Entry Format
Each sprite uses 4 consecutive bytes:
| Offset | Purpose | Notes |
|---|---|---|
| +0 | Y position | $FF hides sprite |
| +1 | Tile number | Pattern table index |
| +2 | Attributes | Palette, flip, priority |
| +3 | X position | - |
Attribute byte format:
76543210
||||||++- Palette (0-3)
|||+++--- Unused
||+------ Priority (0=front, 1=behind BG)
|+------- Horizontal flip
+-------- Vertical flip
NMI Integration
nmi:
pha
txa
pha
tya
pha
; Update OAM from game state
jsr update_oam
; DMA transfer to PPU
jsr do_oam_dma
pla
tay
pla
tax
pla
rti
Sprite Flickering
The NES PPU can only display 8 sprites per scanline. When the PPU evaluates OAM during a scanline and finds a 9th candidate, it sets the sprite-overflow flag ($2002 bit 5) and drops further sprites for that line. Which sprites get dropped depends on OAM order: the PPU evaluates sprite 0 first, sprite 63 last, and the first eight on a scanline are kept.
This means sprite priority is fixed by OAM index. Sprite 0 always wins; sprite 63 always gets dropped first. So if you have 10 sprites that occasionally overlap a scanline, the same 2 will always disappear — visually jarring.
To distribute the flicker evenly across all sprites, rotate the OAM start index each frame:
; Simple rotation: start OAM index at different offset each frame
update_oam_rotate:
lda frame_count
and #$1C ; 0, 4, 8, 12, 16, 20, 24, 28
tay ; Start OAM index
; ... rest of update loop
Cycling through 8 different start offsets every frame means each sprite spends roughly 1/8 of its time at low priority, so flicker is shared rather than concentrated. This is the trick Super Mario Bros. and most NES games use to keep large enemy groups looking smooth despite the 8-per-line ceiling.
Related
Patterns: NMI Game Loop, Sprite Movement with Bounds
Vault: NES