August 18, 2026

Maximum NES Graphics Sprites, Background, Colors - Mapper 5

 NES Development in 2026 Documentation (Technical Information)

Obviously written with AI

256 × 240 · 2 bits per pixel

The NES Graphics Ceiling

There are four different questions hiding inside “how much graphics can an NES ROM show,” and they have four different answers. Only one of them is bounded by the cartridge. The other three are bounded by the screen, by 32 bytes of palette RAM, and by 21 cycles of horizontal blank.

15,360 bytes

The screen is the ceiling

Start with the number that caps everything. The background is 32×30 tiles — 960 cells, 61,440 pixels. At 2 bits per pixel that is 15,360 bytes of pattern data, and no mapper, trick, or engine will ever display more unique background pixels in one frame than that. You cannot show a 64 KB image on an NES frame for the same reason you cannot pour a gallon into a pint glass.

So “maximum graphics” is never about beating 15,360. It is about four separate fights:

Per frame
15,360 B
Hard cap. Stock hardware reaches only 4,096 B of it — 256 distinct tiles, endlessly repeated.
Colors at once
25
Backdrop + 4×3 background + 4×3 sprite, from a 64-entry master palette (~54 distinct).
Throughput
~900 KB/s
15,360 B × 60 fps, straight off CHR-ROM, at essentially zero CPU cost.
Total in cart
1 MB
MMC5's CHR address space. Pirate JY boards reach 2 MB but bring none of the good features.

The interesting gap is in the first cell. The screen can hold 960 unique tiles, but a stock PPU can only address 256 at a time — one 4 KB pattern table. Every NES game you have ever seen is drawing a 15 KB screen out of a 4 KB vocabulary. Closing that gap is the whole game, and exactly one mapper closes it outright.

Mapper select

MMC5 (Mapper 5), and it isn't close

Judge mappers on the three things that actually move graphics: how much CHR they can address, how finely color can vary, and how many distinct tiles can appear in a single frame.

MapperCHR maxColor cellBG tiles / frameRead
MMC1 (1)128 KB16×16256Your Tiny Wizards baseline.
MMC3 (4)256 KB16×16256 × bandsWhat your game demo runs on. Scanline IRQ multiplies tilesets down the screen.
UNROM512 (30)8 KB RAM16×16256Mini Robot Hero's board. Compression play, but a tiny window.
FME-7 (69)256 KB16×16256 × bandsMMC3-class with a cleaner cycle-accurate IRQ.
MMC5 (5)1 MB8×816,384Per-tile palette and per-tile bank select. The only chip that can paint all 960 cells uniquely.
JY Co. (90/209/211)2 MB16×16256Twice the ROM, none of the magic, and uneven emulation.

Note what that last row means: more cartridge does not buy more graphics. A 2 MB JY board still paints every frame from a 256-tile vocabulary. MMC5 with a quarter of the ROM shows 64× more distinct tiles per frame. The mapper feature that matters is not capacity, it is addressing.

$5104 = 1

Extended attribute mode

This is the feature the whole plan rests on. Write 1 to $5104 and MMC5 repurposes its 1 KB of ExRAM: for every background tile fetch, the PPU's nametable index is paired with an ExRAM byte covering that exact screen cell, and that byte carries

  • bits 0–5 — a 4 KB CHR bank, chosen per 8×8 cell. With the two real-time bits in $5130 appended on top, that is 256 banks: the full 1 MB.
  • bits 6–7 — the palette, also per 8×8 cell, instead of one palette per 16×16 quad.

Two consequences. First, the 256-tile vocabulary becomes 16,384 — enough that every one of the 960 cells can be a different tile, so a genuine full-screen bitmap becomes expressible. Second, the attribute grid gets four times finer, which is the single most visible quality jump available on the platform.

Stock PPU
1 PALETTE / 16x16 PX

4 Color Decisions


MMC5 ExAttr
1 PALETTE / 8×8 PX
16 color decisions

The same 32×32 pixel region. Stock hardware forces four tiles to share one 3-color palette; ExAttr gives each tile its own. This is the source of that characteristic NES color bleed — and removing it is what reads instantly as “wrong console.”

$3F00  /  $2001

Getting past 25 colors

ExAttr fixes tiles and attribute granularity, but the palette is still 32 bytes: 13 background colors, 12 sprite colors. To beat that you have to rewrite palette RAM while the frame is being drawn, and the window is brutal — NTSC horizontal blank is about 21 CPU cycles.

The established technique stores the backdrop redundantly at $3F00/4/8/C so a BIT $2007 can skip over it, leaving room for three $2007 writes: 3 palette bytes per hblank, rendering left on.

That budget is enough, and here is the arithmetic that makes the demo viable. A tile row is 8 scanlines, so each row offers 24 byte-slots. A complete set of four background palettes is 12 bytes. You can hand every tile row its own fresh set of four palettes, loaded during the row above it, and still leave half the hblank budget unused. Thirty rows × 12 bytes = 360 bytes of palette script per frame.

Combine that with ExAttr's per-cell palette selection and the practical result is: any 8×8 cell can use almost any 3 colors, subject only to the 32 cells in a row agreeing on four palettes between them. On-screen color goes from 25 to effectively the entire ~54-color master palette.

LayerWhat it beatsCostRisk
ExAttr mode256-tile vocabulary; 16×16 color cells1 KB ExRAM maplow
Per-row palette rewrite13 background colors per frame3 B/hblank + IRQhigh
Color emphasis $2001Uniform tint — 8 R/G/B combos per band1 writelow
Sprite overlay3 colors per object, becomes 62 OAM slots eachnone
8×16 spritesVertical coverage per OAM entryglobal modenone
Mid-frame $5130256 KB reach per screen1 writemedium
Temporal ditherThe 54-color master palette itselffreeflickers on LCD
The fragile oneMid-frame palette writes are where this class of demo breaks. The PPU's VRAM address must only sit in palette range during hblank — leave it there while pixels are being drawn and you get a rainbow smear down the screen. It is also exactly the territory where emulators disagree: Mesen handles it, FCEUX is not to be trusted here. Budget real time for cycle-counting, and verify on hardware or an FPGA core.
Measured

It survives contact with real images

All of the above is a claim until something renders. The encoder is built — tools/mkexattr.py, about 7 seconds per image — and run over three sources. Both encoders below share the same solver and the same 54-entry palette. The only difference is the constraint: 16×16 cells with four palettes per frame, against 8×8 cells with four palettes per row.
One row of fighters, 4×. Stock has to spend its four frame-wide palettes on the city, the pavement and the HUD,
so the characters get whatever is left and go grey. Per-row palettes mean this strip of the screenonly has to serve
this strip, and the clothing survives.


SourceSTOCKEXATTRFLOORGAP CLOSED - COLORS
gameplay73.447.337.974% - Colors: 8 → 24
sample_image83.167.162.377% - Colors: 8 → 27
enemy_concept66.258.157.088% - Colors: 7 → 18

Error is RMS OKLab distance ×1000 over the most saturated fifth of the source pixels; lower is better. Floor is the unreachable best case — every pixel snapped independently to its nearest NES colour, no cells, no palettes. Extended attributes close roughly three quarters of the distance between a stock encode and that ceiling, and about triple the colours on screen.

The stronger result is what the floor column implies. On inspiration.jpg the whole-frame chroma error is 56.0 against a floor of 55.5 — within one percent of the best the hardware palette can express. The residual belongs to the NES palette, not the solver. More restarts buy nothing; that part is finished.

The predicted budgets held exactly: 16,384 bytes of CHR, a 960-byte ExRAM map, a 360-byte palette script.

The measurement trapThe first metric lied, and convincingly. Whole-frame RMS in OKLab rated extended attributes 3.4% better than stock — a rounding error, flatly contradicted by the panels. Total error is dominated by lightness, and lightness is carried by the tile bitmap rather than the palette, so both encoders get it about right. The colour collapse lives in a small minority of pixels: a neon sign is a few hundred out of 61,440, and averaging washes it away. Splitting the metric into chroma, then restricting to the most saturated fifth of the source, finally produced numbers that agreed with what the eye reports. Worth knowing before tuning a quantiser against a single scalar.


Full frames: source, stock, ExAttr, floor. The interesting comparison is the third panel
against the fourth — the constrained encode against the unconstrained one. They are
hard to tell apart, which is the whole argument.




The harder case. A dark scene lit by saturated neon is close to the worst thing you can hand a palette with few dark saturated entries — note how far the floor itself sits from the source.

8 per scanline

$5C00–$5FFF

The static nametable trick

Here is the engine idea worth borrowing, and it comes from one detail: ExRAM lives in CPU address space, not PPU space. Writes to $5C00–$5FFF are ordinary stores that land during active rendering — you are not confined to vblank. (In modes 0 and 1 it is write-only while rendering, so keep a RAM shadow if you need to read back.)

That flips the usual bottleneck on its head:

  • Write the nametable exactly once, at init, and never touch it again. Set each cell's tile index to (row × 32 + col) & $FF. Indices repeat every 256 cells, and ExRAM's bank bits disambiguate: cells 0–255 point at bank A, 256–511 at bank B, and so on. Four 4 KB banks — 16 KB — give you 960 unique tiles.
  • Then animate purely through ExRAM. Rewriting one byte changes both which tile a cell shows and which palette it uses. All 960 bytes at ~6 cycles each is about 6,150 cycles — roughly 21% of a frame, spent during active display, with vblank left completely free.

The upshot is a screen where all 960 cells animate independently at 60 fps, each with its own palette, and the PPU bus is never touched after boot. No other NES configuration can do this. It is also the honest answer to “impossibly many characters”: build each character from a 2×4 block of cells and there is room for roughly 120 of them, all animating, none flickering, none competing for sprite slots.

The constraint that comes with itBackground cells are locked to the 8-pixel grid, so ExRAM-driven characters move in 8-pixel steps. Two ways out: pre-render each character at all 8 horizontal sub-positions into CHR-ROM and flip banks for smooth motion at zero CPU cost — 1 MB is enough for that — or reserve the 64 hardware sprites for whatever genuinely needs pixel-accurate movement, and let the background carry the crowd.
8 per scanline

The wall that doesn't move

One limit no mapper touches, ever: 8 sprites per scanline, 64 total. It lives in the PPU, not the cartridge, and MMC5 is as helpless against it as NROM. Every apparent exception is one of three things:

  • Flicker. Rotate OAM priority each frame so overflowing sprites take turns. More objects, at the cost of 30 Hz strobing.
  • Move it to the background. The static-nametable engine above. Unlimited objects per scanline, because they are not sprites.
  • Spend two sprites on one object. Overlay a second sprite for 6 colors instead of 3. Halves your count, doubles apparent color depth — usually the right trade for a handful of hero characters.

Sprite pattern data is capped too: 64 entries at 8×16 is 2,048 bytes of unique tile data per frame. Sprites are for motion and color depth, not for volume.

2,273 cycles

Why CHR-ROM wins on bandwidth

CHR-ROM is fetched by the PPU directly off the cartridge, so a bank switch presents fresh tile data for the price of one store instruction. CHR-RAM has to be filled by the CPU, and vblank is only 2,273 cycles — realistically 250–380 bytes with fully unrolled stores.

BYTES DELIVERED PER FRAME · BARS LINEAR TO 15,360CHR-ROM bank15,360 — the entire screen, ~4 CPU cyclesCHR-RAM, blanked~4,900 — costs the whole frame, screen goes blackCHR-RAM, vblank~380 max2.5% of a screen, and most of vblank goneExRAM map960 — and written during active display
CHR-RAM's window is 40× too small to repaint a screen. This is the compression paradox: CHR-ROM cannot be compressed, because the PPU fetches it as-is — and CHR-RAM, the only place compression helps, is exactly where you have no bandwidth to spend the savings.

Which resolves the “most graphics in a ROM” question in a way I did not expect before running the arithmetic. At 15,360 bytes per frame × 60 fps, a full 1 MB of CHR-ROM can be displayed in its entirety in about 1.1 seconds. The PPU is not the bottleneck for total graphics volume. Cartridge size is.

Three demos

What to actually build

A · The still that can't exist

A full-screen 256×240 image with all 960 cells unique, 8×8 color cells, and a fresh four-palette set every tile row. Roughly 50 colors on screen. To anyone who knows the platform this is immediately, uncomfortably wrong — which is the point. Make it a gallery and page through images.

CHR / image16 KB
ExRAM map960 B
Palette script360 B
Images in 1 MB~60
CPU / framenear idle

B · The crowd

The static-nametable engine at full stretch: ~120 characters built from 2×4 cell blocks, every one animating independently at 60 fps with its own palette, plus 64 hardware sprites on top for anything needing sub-tile movement. A stadium, an army, a dance floor. No flicker anywhere, because almost nothing on screen is a sprite.

Characters~120 + 64 spr
ExRAM / frame960 B
CPU / frame~21%
VRAM writesnone after init
Motion grid8 px

C · The throughput flex

Full-screen unique animation at 60 fps driven entirely by CHR-ROM bank flips, with the CPU essentially asleep. Nothing else on the console moves this much pixel data. Limited purely by cartridge: about 68 frames of wholly unique full-screen animation per megabyte, so trade frame rate for length — 15 fps buys around 4.5 seconds.

Throughput~900 KB/s
CPU / frame~4 cycles
Frames / MB~68 @ 60fps
At 15 fps~4.5 s
mkchr.py

Fitting it to the toolchain you already have

Most of the scaffolding exists. The fighting game example already has a scanline IRQ, generated CHR includes, a Python asset compiler, and a 6502 test harness with enough PPU to assert on pixels. What changes:

  1. src/header.asm — NES 2.0, mapper 5, CHR sized to taste. You are already emitting NES 2.0 headers.
  2. src/mapper5.asm alongside the MMC3 one — $5100 PRG mode, $5104 = 1 for ExAttr, $5105 mirroring, $5130 upper CHR bits, $5203/$5204 scanline IRQ. Smaller than mapper.asm is now.
  3. tools/mkchr.py — a new output path emitting CHR banks, the ExRAM map, and the per-row palette script from a source PNG.
  4. test/harness.py — MMC5 ExAttr in the PPU model, plus a per-scanline palette trace so test_game.py can assert exact output. This is the real work.

The quantizer is the actual thinking problem

Everything above is plumbing. The part that decides whether the demo looks extraordinary or merely odd is the image compiler, and it is a constrained clustering problem:

  • For each 8×8 cell, find the best 3-color subset of the ~54-color master palette in a perceptual space — OKLab, not RGB.
  • Then, per tile row, cluster those 32 cells' color needs into exactly 4 palettes, minimizing total weighted error. Thirty independent 4-way clusterings, one per row — small enough for k-means with restarts, or near-exhaustive search.
  • Emit the 12-byte palette set per row, scheduled to load during the preceding row's hblanks, 3 bytes at a time.

Worth writing the error metric first and running it over sample_image.jpg before touching any assembly. If the numbers say the result will look good, it will.

Updated After Building Quantizer:

The image compiler reduces to thirty independent constrained clustering problems — one per tile row, since a row is exactly what one palette set has to serve:

  • Convert to OKLab and keep everything there. RGB distance ranks candidate colors wrongly in exactly the dark saturated regions these images live in.

  • Per row, run Lloyd iterations: assign each of the 32 cells to its cheapest palette, then refit each palette to the pixels that landed on it. Restart a few times from different seeds and keep the best.

  • The refit is the inner primitive — best 3 colors from the 54 for a bag of pixels, with the shared backdrop available free. Seed it with k-means, snap to the nearest legal entries, then local search: re-optimise one slot against all 54 candidates with the other two held, until nothing improves. Converges in a handful of passes.

  • The backdrop is global, so choose it by running the whole solve over a shortlist of the colors the image most wants, and keeping the winner.

Six restarts and twelve iterations lands within a percent of the theoretical floor, which is the signal to stop tuning. The one thing worth it before doing more is picking a metric you have checked against your own eyes — see above for how badly that can go.


Known risks

Where this breaks

  • MMC5 hardware is scarce. Aftermarket PCBs barely exist; the usual route is harvesting a donor (Castlevania III, Just Breed, Uncharted Waters), and most donors carry far less than 1 MB. Plan for emulator and FPGA cores as the primary target, with a physical cart as a stretch goal.
  • Mid-frame palette work is timing-critical and emulator-divergent. 21 cycles, hand-counted, and FCEUX will lie to you about it.
  • 8 sprites per scanline is permanent. Design around it rather than fighting it.
  • 1 KB CHR banking only reaches 256 KB on MMC5 — moot under ExAttr, which forces 4 KB banks, but it will bite elsewhere in the engine.
  • ExRAM is write-only while rendering in modes 0 and 1. Shadow it in RAM.
  • Temporal dithering is display-dependent. Lovely on a CRT, headache-inducing on some LCDs. Make it optional.
If MMC5 is off the tableYour existing MMC3 already runs a scanline IRQ. Push that to 8–30 CHR bank switches down the frame and the effective tile vocabulary multiplies by the band count — not the 960 unique cells ExAttr gives you, but a large multiple of 256, on hardware you can actually buy. The 16×16 color cell stays, so it will still read as an NES game. A very good one.

No comments:

Post a Comment

Articles are augmented by AI.