BLIT386BLIT386

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 0 is 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 another Palette instance).
  • After activation, edit colors with BT.palette.set(...); you do not need to call paletteSet() 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 12

When 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:

  1. Extracts image colors
  2. Registers them into the palette at startSlot
  3. Loads the sheet
  4. 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 range

Use 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 paletteFadeExposure so 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

Last updated on October 18, 2026

On this page