11 — The Pixel Wars

18 commits, 4 crashes, 1 tile that kept destroying Charmander’s name


The Morning After

After the overnight sprint merged — 27 commits across 8 branches, all building clean — I ran a focused play session. Within an hour I had a list.

Merchants stood on water. The EMBER menu text was clipped. The scrapper layout was broken. The gear confirm screen had a black band across the bottom third. And opening a gear item crashed the game.

Day 4 was supposed to be polish. It became a pixel-by-pixel fight with the GBA’s rendering pipeline.

The Crash That Started Everything

First hit: a hard crash when opening the gear detail view on the party screen. The culprit was GetGearName — it wrote past the end of a buffer when a name ran long. A 10-character buffer for a name like “Flame Amulet of the Storm” does not end well. Buffer overflow, corrupted stack, white screen.

Fix: wider buffer, truncation with ellipsis. But this was the opening act.

NPC Placement and the Map Viewer

Every town now had merchants, chests, altars, and scrappers — but several stood on blocked tiles, water, or over warps. The JSON coordinates told nothing, and loading each town in an emulator was slow.

So I built a tool: map_viewer.py. It parses raw blockdata and metatile attributes, cross-references NPC positions from map.json, and prints ASCII walkability maps. Green dots for walkable tiles, blue for water, red for blocked, letters for NPCs.

python3 tools/map_viewer.py --all-towns --check-only

One command, every town checked. It found 12 bad placements across 8 towns. All fixed in a single commit.

The Tile Overflow

The gear confirm screen — the one showing “Equip Fire Amulet to CHARMANDER?” — had a black artifact over its bottom third. This one was subtle: the window’s baseBlock value sets where its tile data lives in VRAM. With the full-height redesign expanding the window from 6 to 16 tiles tall, its tiles pushed past 1024 — the maximum tile index for BG text layers on the GBA.

Fix: recalculate every window’s baseBlock to pack them tighter. The broader point: on the GBA, window layout is memory allocation.

The Dark Side

With the mechanics working, I turned to aesthetics. The default Pokémon UI — white backgrounds, blue borders, bright — clashed with the Diablo-inspired gear system. I wanted something darker.

Act 1: The Background

First pass: replace the menu background texture. I designed a dark grunge image, exported it as an indexed 4bpp tileset, and loaded it onto BG1. Every full-screen menu — merchant, bag, altar, forge, codex — now renders its text windows as transparent overlays on this dark texture.

The transparent window trick is clean: PIXEL_FILL(0) fills with GBA hardware-transparent pixels, so the BG1 texture shows through everywhere text is not drawn. Dark background, bright text, zero extra memory.

Act 2: The Ember Frame

The gear overlay on the party screen does not get its own background layer — it is a popup over the existing party menu. It needed a custom window frame.

I designed a 24×24 pixel frame: purple gradient border, inner shadow, dark grey fill. Nine tiles in a 3×3 grid. On paper, straightforward. In practice, it started a fight that ran most of the afternoon.

Round 1: Palette collision. I loaded the frame palette into slot 12. That is where the text window palette lives. Every standard text window in the game turned purple. Moved to slot 11 — safe — but now I needed to merge text colors and border colors into a single 16-entry palette.

Round 2: The tile that ate Charmander. Frame tiles placed at VRAM offset 0x70. Looked fine on load. Then Charmander’s name turned to random pixels where “CHARMANDER Lv5” should be. The party menu reserves slots 0x63 through 0x1B6 for the six party slot windows. My tiles at 0x70 wrote over the lead Pokémon’s name tiles.

I mapped every tile allocation in the party menu — 62 tiles of BG graphics, 9 for the user frame, 9 for the standard frame, 70 per party slot, message bars, action windows. Found a 17-tile gap between 0x3E and 0x4E. Moved the frame there. Charmander survived.

Round 3: The transparent shadow. The frame has rounded corners that should show the party menu through them, plus an inner shadow between the border and fill area. Both used palette index 14 in the source art. GBA BG tiles treat index 0 as hardware transparent. I needed corner pixels transparent but shadow pixels opaque — same source index, different behavior.

Blanket replacement broke the shadow. The fix was a flood-fill: seed from each tile’s outer edge, walk through connected index-14 pixels, convert those to index 0. Inner shadow pixels — not reachable from any edge — stay opaque.

Round 4: The white cursor. The engine’s built-in cursor system (Menu_RedrawCursor) erases the old cursor position by filling with pixel value 1 — white. On a white background, invisible. On the dark frame, a bright white rectangle. I replaced the cursor system with manual tracking: erase with PIXEL_FILL(15) (dark fill), redraw with AddTextPrinterParameterized4.

Four rounds. Four different GBA subsystems. Not bad.

Consistency Pass

With the frame working, I checked every menu screen for consistency:

Then I wrote it all down. The STYLEGUIDE got a section on the ember frame system, hint bar conventions, and text formatting rules. AI_CONTEXT got a full VRAM tile allocation map for the party menu — the kind of reference that would have saved hours during the tile collision debugging.

What the GBA Teaches

The GBA has no layers of abstraction — it has layers of hardware. Palettes are 16-color slots in a flat array. Tiles are offsets in a shared VRAM bank. Windows claim ranges of that tile space. Touch one thing and something three layers away breaks. No error messages. Only wrong pixels.

Tool-building pays for itself fast. The map viewer took one commit to build and saved hours of manual NPC placement checking. Every tool I build for this project has returned its cost inside the same day.

Dark UIs on a light engine require custom everything. The GBA library assumes white backgrounds. Cursors erase with white. Frames load with white fill. Palettes expect text-on-white contrast. Going dark means replacing each assumption one by one, and the replacements cannot use the convenient helper functions because those encode the assumption.

By the Numbers

Metric Value
Commits 18
Copilot requests ~50
Tool executions ~849
Sub-agents 13

18 commits. A map viewer tool, a custom window frame system, 12 NPC relocations, 4 tile/palette bugs, and every menu screen aligned to a single visual standard. The game doesn’t just work now — it looks like it belongs to itself.

Back to README