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.

August 01, 2026

Z80-PC1 - A Personal Computer on a Single Board, 1976 CPU Z80






What it is:

A personal computer on a single board, built around a CPU from 1976.

The board is designed to be small because this is my first PCB motherboard project and I don't have a lot of experience with the expenses and complications of creating larger boards. Start with a 5 V supply, plug in a VGA monitor and a PS/2 keyboard, and theoretically a few seconds later you should have a command prompt. No operating system to install, no updates, no network. It boots into CP/M, the operating system that ran most serious 8-bit machines before MS-DOS, and from there you write programs, edit files, and save your work to a microSD card.


What you'd actually do with it:

Boot to an A> prompt. Write a "BASIC" program and you can run it immediately. You will be able to write Z80 assembly and assemble it on the machine itself, or cross-compile C on your main laptop and drop the binary on the SD card. Real software of the era should run on it, such as WordStar, Turbo Pascal, or simple games. Thousands of CP/M programs exist free today. 

And because a 40-pin connector exposes the complete CPU bus, anyone can design their own card - more memory, a sound chip, an instrument interface - and plug it in. That makes it easy to learn what a bus actually is and what it lets the system do.




What's on the board:

The three big black chips down the left are the heart: the Z80 processor at 8 MHz, 128 KB of RAM (bank-switched, because the Z80 can only see 64 KB at a time), and an ATmega microcontroller acting as the entire support chipset.

The small green board in the middle is a designed to be the GPU (Graphics Processing Unit): a Raspberry Pi Pico that generates the video and reads the keyboard. Around the edge: VGA, keyboard, serial, SD card, clock, power, expansion. The empty patch top-right is deliberately reserved so sound can be added later without redesigning everything.



A modern approach:

A 1983 machine needed a dozen support chips around its CPU - boot ROM, serial controller, disk controller, timer, video. This replaces nearly all of that with two microcontrollers running software! 

The computer is real, though: the Z80 is genuinely in charge. It fetches and executes every instruction, owns the memory, and runs CP/M natively - not in an emulator. Nothing you write is simulated. The clever chips are peripherals, and peripherals outperforming the CPU isn't new; a 1983 floppy controller often held a microprocessor doing more work than the host.


What it isn't:

It's not that fast. Whomp whomp. It won't browse the web. This is a simple, but complete-ish board. Everything is through-hole with socketed chips, so it's a reasonable first or second board to build.


Sadly, it isn't finished:

The schematic is complete and checked, the components are placed, but more work needs to be done.

The traces are not routed yet. A manufacturing files does not yet exist. The firmware is not written yet, 

The video firmware is recognized and noted as being a substantial project.


FAQ:

Why not discrete video logic or a TMS9918A?

Discrete character-cell video is a dozen more chips and a much harder board. Sourcing TMS9918A/V9958 parts may be hard to source at fair prices, and the 9918 outputs composite, not VGA. Neither fits the with the direction of this PC.


Parts list

RefPartPackageNotes
U1Z84C0008PEGDIP-40CMOS Z80, 8 MHz.
U2ATmega1284P-PUDIP-40128 KB flash, 16 KB RAM
U3AS6C1008-55PCNDIP-32128 KB SRAM (TC551001 equivalent)
U474HC32DIP-14Bank select - 2 OR gates used, 2 spare
U574HC00DIP-14WAIT latch (IOS sheet)
U674LVC245ANDIP-20Z80 data → Pico, LVC at 3.3 V
U774HCT245NDIP-20Pico data → Z80, HCT at 5 V
U874LVC245ANDIP-20Control lines, 5 V → 3.3 V
M1Raspberry Pi Pico2×20 headerVideo + keyboard. Socket it.
M2Micro SD module6-pin headerSame style as MBC2
M3DS3231 RTC module6-pin headerOptional





Real Time Clock (RTC as a module): J2 six-pin header accepts a real time clock add-on module to enable a system clock with a battery backup. DS3231 RTC module



FAQ:

Why not discrete video logic or a TMS9918A?

Discrete character-cell video is a dozen more chips and a much harder board. Sourcing TMS9918A/V9958 parts may be hard to source at fair prices, and the 9918 outputs composite, not VGA. Neither fits the with the direction of this PC.


Why three bus buffers rather than one

The obvious design is a single bidirectional 74LVC245 between the Z80 bus and the Pico. It does not work. An LVC245 running from 3.3 V drives its outputs to 3.3 V, and a CMOS Z80's input threshold can be as high as 0.7 × VCC = 3.5 V. The Z80 might read the Pico's data as a logic low. Some Z84C00 datasheets quote a TTL-level 2.2 V VIH, in which case it could work.

So the two directions are split, each using a part that is unambiguously correct:

  • Z80 → Pico (U6): 74LVC245 at 3.3 V. Its inputs are 5 V-tolerant, and it outputs a clean 3.3 V into the Pico.
  • Pico → Z80 (U7): 74HCT245 at 5 V. HCT inputs use TTL thresholds (VIH = 2.0 V), so the Pico's 3.3 V is comfortably a logic high, and the outputs swing to a full 5 V for the Z80.
  • Control lines (U8): 74LVC245 at 3.3 V, one direction only — /VID_CS, /RD, /WR, A0, A1. Five of eight channels used; the spare inputs are grounded.

U6 and U7 are never enabled at once: VID_WR_N and VID_RD_N, generated by the two spare OR gates of U4, enable exactly one of them per bus cycle. Those two gates are physically on the Memory sheet's 74HC32 but drawn on the Video sheet, which is where they are used.



LED Lights explanation:

D1 - HALT - Wired to the Z80's /HALT pin. The Z80 pulls that line low when it executes a HALT instruction, meaning it has deliberately stopped and is waiting for an interrupt. This could indicate a program crash.

D2 - DMA - Wired to /BUSACK pin. Flashes while board microcontroller loads code into memory. If it stays on, this indicates the machine is frozen.

D3 - USER - Driven from microcontroller. The firmware controls this, and it can be used for a "heartbeat", disk activity, and/or boot progress indicator.

D6 - PWR - Power light sits after the reverse-polarity diode. Indicates power is wired correctly. On = Powered!


Buttons explanation:

SW1 - RESET - System reset button. It resets the microcontroller on the board. The microcontroller then holds the Z80 processor in reset, reloads the boot code into RAM, then releases the Z80.

SW2 - USER - General purpose. Can be used to change what the machine boots into. Can be sensed in programs as "The operator pressed the button".

SW3 - PICO_RST - Resets the Raspberry Pi Pico only. Z80 CPU remains running. Useful for video firmware development and restarting graphics/keyboard input. Additional use: Put R. Pi Pico into USB drive mode.



Updated 8/1/2026

Articles are augmented by AI.