Skip to content

Multi-Sprite OAM Update

Manage multiple sprites in the OAM buffer. Update positions, hide unused sprites, and DMA transfer efficiently.

Taught in Game 1, Unit 5 spritesoamdmarendering

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

AspectCost
CPU~200 cycles for 8 objects
Memory~100 bytes code + 256 OAM + arrays
Limitation8 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:

OffsetPurposeNotes
+0Y position$FF hides sprite
+1Tile numberPattern table index
+2AttributesPalette, flip, priority
+3X 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.

Patterns: NMI Game Loop, Sprite Movement with Bounds

Vault: NES