05 — Making It Beautiful
Dark theme, transparent windows, and a custom palette — Diablo aesthetics on a GBA
The Starting Point
After fixing crashes and string encoding, the UIs worked. They also looked terrible. Bright blue backgrounds, garbled color schemes, misaligned text, visible borders eating screen space. It looked like a debug menu, not an RPG loot screen.
The vision was always Diablo — dark, atmospheric, with rarity colors that pop. But GBA has 15-bit color (32,768 possible colors), 16-color palettes, and 240×160 resolution. How do you make that feel like a modern ARPG?
Phase 1: Kill the Borders
FireRed’s standard windows have a 1-tile (8px) border on each side — that “frame” look from the vanilla menus. For my screens, that was 16 pixels of wasted space in each dimension. On a 240-pixel-wide screen, that’s significant.
Claude switched to full-bleed windows: x=0, width=30 (the full screen width in tiles). This immediately made everything feel more spacious. But it revealed a new problem — black bars appeared between adjacent windows because the GBA’s backdrop color (palette index 0 of BG palette 0) was black.
Fix: set gPlttBufferUnfaded[0] to match the window fill color after loading palettes. The backdrop becomes invisible.
Phase 2: The Custom Palette
FireRed ships with 5 standard palettes (stdpal_0 through stdpal_4). None of them work for a dark loot UI:
stdpal_0/1/2: Light themes (white/cream backgrounds)stdpal_3: Light blue/tealstdpal_4: Dark blue-gray#405868— closest to what I wanted
Rather than modifying the standard palettes (which would affect every menu in the game), I had Claude create gGearUIPalette — a custom 16-color palette loaded at BG palette slot 15:
const u16 gGearUIPalette[16] = {
RGB(3, 4, 7), // 0: bg dark navy #182038
RGB(31, 30, 28), // 1: off-white (Common)
RGB(12, 28, 28), // 2: cyan (Uncommon)
RGB(8, 16, 31), // 3: bright blue (Rare)
RGB(22, 10, 28), // 4: purple (Epic)
RGB(30, 22, 4), // 5: amber gold (Legendary)
RGB(30, 22, 4), // 6: gold (titles/currency)
RGB(20, 20, 22), // 7: light gray (secondary text)
RGB(8, 9, 12), // 8: shadow
// ... etc
};
One palette. 16 colors. Shared across all 8 custom UIs. This became the single source of truth for the entire visual identity.
Phase 3: Rarity Colors
Colors carry information in loot games. You should be able to tell an item’s rarity at a glance:
| Rarity | Color | Hex | GBA RGB |
|---|---|---|---|
| Common | Off-white | #F8F0E0 |
RGB(31,30,28) |
| Uncommon | Cyan | #60E0E0 |
RGB(12,28,28) |
| Rare | Blue | #4080F8 |
RGB(8,16,31) |
| Epic | Purple | #B050E0 |
RGB(22,10,28) |
| Legendary | Gold | #F0B020 |
RGB(30,22,4) |
GetRarityTextColor() returns the correct 3-index color array (background, foreground, shadow) for any rarity. Every UI uses it. No hardcoded rarity colors anywhere.
Phase 4: The BG Texture
Flat dark backgrounds felt empty. I wanted the subtle visual texture of Diablo’s item tooltips — that aged, slightly worn look.
Claude created a dark grunge tileset: 580 unique 8×8 tiles forming a 240×160 repeating pattern. Dark grays and navy blues with subtle noise and variation. This lives on BG1, behind the text on BG0:
BG0: Text windows (charBase 0, priority 0) — in front
BG1: Grunge texture (charBase 2, priority 1) — behind
The key trick: windows are filled with PIXEL_FILL(0) — palette index 0, which is transparent. The dark grunge texture shows through the windows, giving every screen a rich, textured look without any per-screen art.
The Palette Loading Dance
Getting all of this to work required a precise initialization sequence:
FillPalette(0, 0, PLTT_SIZE)— clear ALL 256 palette entries to black (kills stale overworld data)LoadPalette(gGearUIPalette, BG_PLTT_ID(15), ...)— load the custom palette at slot 15LoadPalette(sMenuBgPalette, BG_PLTT_ID(14), ...)— load texture palette at slot 14gPlttBufferUnfaded[0] = gPlttBufferUnfaded[15*16+15]— set backdrop to match the BG
Miss step 1 and you get the “red screen” bug — stale stdpal_2 data (which has RED at index 4) bleeds through during the fade-in, causing an alarming flash of red/orange before the correct palette stabilizes.
Miss step 4 and the hardware backdrop color is wrong, creating visible seams between windows.
Phase 5: The 3-Zone Layout
After applying the dark theme to all screens independently, inconsistencies crept in. Claude and I standardized a 3-zone layout:
┌──────────────────────────────┐
│ ★ SCREEN TITLE ¥42 │ Zone 1: Gold title + currency
├──────────────────────────────┤
│ │
│ Content area │ Zone 2: Lists, info, details
│ (varies per screen) │
│ │
├──────────────────────────────┤
│ (A) Select (B) Back │ Zone 3: Icon-based controls
└──────────────────────────────┘
- Zone 1 (rows 0-1): Gold
sColorGoldtitle, right-aligned currency - Zone 2 (rows 2-17): Screen-specific content
- Zone 3 (rows 18-19): Button glyphs (
{A_BUTTON},{B_BUTTON}) + action labels
This standardization happened after implementing the same screen 5 different ways and realizing each one looked slightly different. A single pass through all 5 full-screen UIs brought them into alignment.
The Result
From “debug menu that happens to have gear data” to “dark RPG interface that feels like it belongs in a loot game.” The GBA’s constraints actually helped — with only 16 colors, every shade matters. The limited palette forced intentional color choices rather than the gradient soup you’d get with more colors.
The closest reference points: Diablo III’s item tooltips and Path of Exile’s dark item panels, adapted to 240×160 pixels and 15-bit color.
By the Numbers
| Metric | Value |
|---|---|
| Active | ~16 hours |
| Commits | 59 |
| Lines of C added | ~2,300 |
| New source files | 4 (codex, drop notify, gear bag, menu BG) |
| Fix/crash commits | 27 |
| Major crises | 3 (grey screen crash, string encoding, palette bleed) |
| Copilot requests | 83 |
| Tool executions | ~3,200 |
| Sub-agents | 11 |