Palette
The palette-first rendering workflow: setup, slot offsets, and palette effects.
BLIT386 is palette-first: every visible pixel stores a palette slot index, and final RGB color comes from the active
Palette. Changing palette data changes every pixel that references those slots.
This guide covers the end-to-end workflow: setup, indexed sprites, palette offsets, runtime effects, and when to call
BT.spritesRefresh().
For terminology (slot vs. paletteIndex vs. paletteOffset) and when to call BT.paletteSet() vs. mutating
BT.palette, see Palette addressing in the API palette reference.
Create and activate a palette
import { , , } from 'blit386';
const = .(); // or new Palette(256)
.(1, new (20, 30, 40, 255)); // custom background slot
.();Key rules:
- Slot
0is always transparent. - Valid sizes are
2, 4, 16, 32, 64, 128, 256. BT.paletteSet()activates a palette before the first draw (or when swapping to anotherPaletteinstance).- After activation, edit colors with
BT.palette.set(...); you do not need to callpaletteSet()again for value-only changes (see Palette addressing).
Draw using absolute palette indices
Primitive and clear APIs take an absolute paletteIndex (the exact slot written to the framebuffer), not direct RGBA
values. See Palette addressing.
.(1); // absolute paletteIndex 1
.(, 6); // absolute paletteIndex 6
.(, , 12); // absolute paletteIndex 12When slot 6 changes in the active palette, every pixel drawn with absolute index 6 changes automatically.
Index sprites (preferred and manual flows)
Use this when loading sprites for normal game/demo work.
const = await .('sprites/hero.png', , 32, { : 'luminance' });
.();
.(., ., );What it does:
- Extracts image colors
- Registers them into the palette at
startSlot - Loads the sheet
- Converts source pixels to palette indices
Per-draw palette offsets (zero-cost color variants)
BT.drawSprite(..., paletteOffset) adds a shift to each stored sprite index before lookup (not an absolute slot). See
Palette addressing.
.(, , , 0); // base range
.(, , , 16); // same art, shifted color rangeUse this for:
- Team colors
- Seasonal variants
- Damage-state tint sets
- Faction/UI themes
No duplicate textures required.
Runtime palette effects
Palette effects mutate slots over time. They advance once per fixed update, right after your update(), on the tick
clock (BT.ticks * 1000 / BT.targetFPS) rather than on wall time, so they follow the fixed-step loop, not the display's
refresh rate, and a BT.renderAt seek reproduces them exactly. An effect's
time counts from the moment it was added, BT.ticksReset() pauses effects for one update instead of running them
backwards, and a palette write made in render() lands after that frame's effects, not before them.
// Cycling: water/lava/plasma
.(240, 248, 4);
// Full-palette fade
.(, 2000, 'ease-in-out');
// Full-palette fade shaped like an iris pull
.(, 2000);
// Range-only fade
.(32, 63, , 400, 'ease-out');
// Temporary flash (slot 0 transparency preserved)
.(., 120);
// Instant swap
.(10, 11);
// Cancel running effects
.();Exposure fades vs. crossfades
paletteFade interpolates the encoded 8-bit values a palette stores. That is precisely what a post-production crossfade
does: every color drops by the same proportion, the whole way, and the image sags uniformly into a muddy gray-black. A
camera does not behave that way. Closing an iris multiplies light, and only then does the sensor encode it, so the image
holds its brightness and then collapses near the end.
paletteFadeExposure works in that same linear light: it interpolates each entry's RGB between its starting color and
its target there, rather than in the encoded values. Fading up from black or down to black - the case a cinematic fade
almost always is - that interpolation reduces to scaling light up or down, exactly as an iris does.
Worth recording so nobody re-derives it wrong later: fading in linear light does not by itself make highlights linger
relative to shadows. sRGB is close to a pure power curve, and a power curve is scale-invariant - multiplying linear
light by k multiplies every encoded value by roughly the same factor. You get a better-shaped curve, not a
differential between white and dark gray.
On real film that differential comes from headroom. A practical lamp in frame can sit far above diffuse white in linear
scene terms, so stopping down two stops leaves it still clipping at pure white while everything else has visibly
dropped. A palette engine has no headroom: 255 is the ceiling. So paletteFadeExposure approximates it by offsetting
each entry's schedule by its luminance - bright entries start rising earlier and finish falling later, dark entries
start late and crush early. That is the toe and shoulder behavior, with one knob (highlightLead).
Every entry still reaches the target exactly at the end, so nothing drifts and no color is left one step off after the fade lands.
A palette fade is per-index, not per-pixel
The schedule offset comes from a palette entry's own luminance, so a dark object sitting in a bright scene fades on the
dark schedule regardless of what surrounds it - the engine only knows its palette slot, and real exposure has no such
notion. For a fade up from black or down to black this reads correctly. In a busier scene, look at the result before
committing to it, or fall back to paletteFade.
Layout swap vs. value swap
This distinction is critical
Refresh sprites only when slot positions change, never when only slot values change. Refreshing after a value-only change is wasteful and can fail reindexing if the original RGBA is gone.
Value swap (no sprite refresh)
If slot numbers stay the same and only RGB values change, do not refresh sprites.
Examples:
- Day/night tinting by editing slot colors
- Palette cycling/fade/flash effects
- Theme tweaks in place
Layout swap (refresh required)
If the same colors move to different slot indices, call:
.();
.();Why: sprite textures store indices. If your palette layout changes, old indices point to wrong colors until re-indexed.
Practical palette-first patterns
- Cycling water: reserve 8 contiguous slots and run
paletteCycle. - Damage flash: trigger
paletteFlash(Color32.white, durationMs). - Day/night: prebuild day/night palettes and use
paletteFade. - Title-card open: fade up from black with
paletteFadeExposureso the highlights arrive first. - HUD stability: reserve a dedicated slot range and avoid cycling over it.
- Variant economy: organize palette in fixed ranges and use sprite offsets instead of duplicate art.
Performance notes
Palette-first rendering minimizes color-update cost:
- Color changes are palette writes, not full scene texture rewrites.
- Sprite textures are indexed (one byte per pixel) rather than four-channel RGBA storage.
- Primitive and sprite draws reuse compact index-based pipelines.
For the benchmark and local comparison workflow, see Performance Testing.
API history
See also
API: Palette
API: Assets
API: Rendering
Palette Presets
Testing
Post-Process Effects
Performance Best Practices
Last updated on October 18, 2026