Example: Palette Cycling
Classic retro color rotation with BT.paletteCycle: rotate palette slots to make a still image flow.
Written for blit386 1.8.0. The rest of blit386.dev documents the newest engine, so an API you find there may be missing from a game pinned to 1.8.0.
Live version: https://demos.blit386.dev/palette-cycling. This is the demo-site source, not a standalone file: it imports and calls helpers from ./shared/ (themed panels, on-screen D-pad, post-process fallbacks), which are not part of blit386 and are not on npm. To run it as is, keep those files from https://github.com/blit386/blit386/tree/main/packages/demos/src/shared at the same relative path. To adapt it, remove or replace the helper imports and every call to them.
/*
Palette Cycling: classic retro color rotation using BT.paletteCycle().
@description Classic retro color rotation with BT.paletteCycle: rotate palette slots to make a still image flow.
Part of the BLIT386 demo series.
Prerequisites:
- Basics: https://demos.blit386.dev/basics
- Primitives: https://demos.blit386.dev/primitives
- Palette Animation: https://demos.blit386.dev/palette-animation
Live version: https://demos.blit386.dev/palette-cycling
Guides:
- Palette: https://blit386.dev/docs/guides/palette#runtime-palette-effects
- Palette patterns: https://blit386.dev/docs/guides/palette#practical-palette-first-patterns
- Overlay: https://blit386.dev/docs/guides/overlay#colors-and-hud-palette-slots
Reference:
- Palette: https://blit386.dev/docs/api/palette#palette-effects
---
WHAT YOU WILL SEE
-----------------
Three kit panels stacked down the screen, each titled with the exact BT.paletteCycle()
call that drives it and filled with rectangles that never move but whose colors flow:
1. Sky (top) - ten full-width purple-to-pink stripes, one per slot (10-19), cycling
very slowly at 0.5 steps per second.
2. Fire (middle) - 24 narrow columns of 8 blocks from 6 slots (30-35), dark red to
bright yellow. Each column starts a little further along the gradient than its
neighbor, following a sine wave, so the bands bend into flame tongues. They cycle at
6 steps per second with a negative speed, so the other way round.
3. Water (bottom) - a grid of 36 by 8 small blocks from 8 slots (50-57), dark blue to
cyan, laid out diagonally and cycling at 4 steps per second.
Every 3 seconds the demo also swaps two fire slots with BT.paletteSwap() and names them
for about a second in orange, on the right of the fire panel's title row.
WHAT YOU WILL LEARN
-------------------
- BT.paletteCycle(start, end, speed) rotates the colors in slots start to end (both
inclusive) at `speed` steps per second, forever. A positive speed moves every color one
slot down (slot 11's color lands in slot 10, and slot 10's wraps to the end); a negative
speed moves them the other way.
- BT.paletteSwap(indexA, indexB) exchanges two slots instantly - not an effect, a one-off.
- Palette effects run inside the engine once per tick, so init() starts them and the demo
never touches those colors again.
- Filling a run of slots with a gradient from a tiny helper and a callback.
- Keeping the cycled ranges clear of the slots the UI kit and the overlay own.
WHAT IS PALETTE CYCLING?
------------------------
On old hardware, colors were stored in numbered "slots" called a palette. Every pixel on
screen was just a number pointing to a slot. If you ROTATE the colors - move each color
one slot over and wrap the one that falls off the end back to the start - everything on
screen that uses those slots appears to ripple or flow. The picture data never changes;
only the paint-bucket labels shift around.
This trick powered water, lava, plasma, and aurora effects in classic games like Sonic,
Secret of Mana, and Chrono Trigger - all without redrawing a single pixel.
BLIT386 gives you BT.paletteCycle(start, end, speed) to do exactly that. Call it once in
init(), and the engine rotates the colors automatically every tick.
UPDATE VS RENDER
----------------
- init() fills the gradient slots and starts three BT.paletteCycle() effects. The engine
rotates those slot ranges on its own every tick until BT.paletteClearEffects() is
called - which this demo never does.
- update() only runs the periodic BT.paletteSwap() and hides its caption.
- render() draws fixed rectangles using palette index numbers; the bands appear to flow
because the engine shifted the slot colors, not because render() recomputes Color32
values.
The band headings are drawn with the shared UI kit (src/shared/ui.js); its theme colors
live in slots 240-251, far away from every cycling range.
TRY THIS
--------
- Press the backquote key (`) to open the engine overlay. The timing chart shows that
update() does almost nothing - the rotation happens inside the engine.
- Watch the fire panel's title row: every 3 seconds it names the two slots that just
swapped. The two colors trade places in the rotating ring for good, so the fire
gradient slowly gets scrambled.
*/
import { bootstrap, BT, Color32, Rect2i } from 'blit386';
import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';
/** @typedef {import('blit386').Palette} Palette */
// Water: 8 blue slots cycling at 4 steps per second.
const WATER_SLOTS = 8;
const WATER_SPEED = 4;
// Fire: 6 orange-yellow slots cycling the other way at 6 steps per second.
const FIRE_SLOTS = 6;
const FIRE_SPEED = -6;
// Sky: 10 purple-pink slots cycling very slowly at 0.5 steps per second.
const SKY_SLOTS = 10;
const SKY_SPEED = 0.5;
// Palette swap demo: every SWAP_INTERVAL ticks, swap two fire slots to show
// BT.paletteSwap(). A tick is one fixed update; 180 ticks is 3 seconds at 60 ticks per
// second.
const SWAP_INTERVAL = 180;
// Layout of the three kit panels on the 320x240 screen, 4 pixels from the edges and 8
// apart, stacked top to bottom. The bottom 13 pixels stay clear for the overlay's toggle
// hint.
const EDGE = 4;
const PANEL_GAP = 8;
const PANEL_W = 320 - 2 * EDGE;
// A kit panel with a title starts its content 22 pixels below its top edge and ends 11
// pixels below the last row (the rim and the title strip). Text inside starts 10 pixels
// in from the panel edge.
const PANEL_CONTENT_TOP = 22;
const PANEL_BOTTOM = 11;
const PANEL_TEXT_INSET = 10;
// How tall the artwork band inside each panel is, and where each panel starts.
const SKY_ART_H = 32;
const FIRE_ART_H = 40;
const WATER_ART_H = 40;
const SKY_PANEL_Y = EDGE;
const FIRE_PANEL_Y = SKY_PANEL_Y + PANEL_CONTENT_TOP + SKY_ART_H + PANEL_BOTTOM + PANEL_GAP;
const WATER_PANEL_Y = FIRE_PANEL_Y + PANEL_CONTENT_TOP + FIRE_ART_H + PANEL_BOTTOM + PANEL_GAP;
// The fire and water grids: 24 fire columns 12 pixels apart and 36 water columns 8 pixels
// apart, both 288 pixels wide, with rows of 5-pixel-tall blocks.
const FIRE_COLS = 24;
const FIRE_COL_STEP = 12;
const WATER_COLS = 36;
const WATER_COL_STEP = 8;
const BLOCK_H = 5;
const GRID_W = 288;
// Slot 0 is always transparent (reserved by the engine).
// Engine overlay style slots. configure() runs BEFORE init() installs the shared UI
// theme, so the overlay style cannot use theme slots - instead it points at these six
// low slots, which init() fills by hand with fixed colors.
const C_OVERLAY_TAG = 1; // chart milestone tags (white)
const C_OVERLAY_BAR = 2; // overlay bar background (very dark navy)
const C_OVERLAY_RENDER = 3; // timing chart render bars (dark blue)
const C_OVERLAY_TEXT = 4; // overlay text and chart update bars (golden yellow)
const C_OVERLAY_WARN = 5; // timing chart warnings (cool gray-blue)
const C_OVERLAY_ERR = 6; // timing chart error bars (dark gray-violet)
// Sky gradient: slots 10-19 (10 slots).
const C_SKY_BASE = 10;
// Fire gradient: slots 30-35 (6 slots).
const C_FIRE_BASE = 30;
// Water gradient: slots 50-57 (8 slots).
const C_WATER_BASE = 50;
// Fills a run of palette slots with a smooth gradient. For each slot we compute
// t = i / (count - 1): think of t as "how far along the gradient are we?" - the first
// slot gets t = 0 (the start), the last slot gets t = 1 (the end), and the slots between
// get evenly spaced fractions like 0.25 or 0.5 (exactly halfway). The `colorAt` callback
// turns that fraction into the actual Color32 for the slot, so each gradient only has to
// describe its own start and end colors. `baseSlot` is the first slot of the run.
function fillGradient(palette, baseSlot, count, colorAt) {
for (let i = 0; i < count; i++) {
palette.set(baseSlot + i, colorAt(i / (count - 1)));
}
}
// Demonstrates BT.paletteCycle() for automatic palette rotation and BT.paletteSwap() for
// an instant exchange of two slots.
class Demo {
// The active palette, built in init().
/** @type {Palette | null} */
palette = null;
// Palette slot map for the shared UI kit theme, filled in init() by applyTheme().
// theme.bg, theme.dim, and friends are palette indices ready for BT draw calls.
theme = null;
// Tick of the last swap, so we know when the next one is due.
lastSwapTick = 0;
// Which two slots were last swapped, for the caption.
swappedA = 0;
swappedB = 0;
// Whether the swap caption is currently shown.
showSwapLabel = false;
// Reused for every rectangle drawn in render(), so drawing allocates no new Rect2i.
tempRect = new Rect2i(0, 0, 0, 0);
// Palette cycling runs in the engine every tick; the timing chart shows update vs
// render time. The overlay style colors come from the dedicated low slots set in
// init().
configure() {
return {
isOverlayPaletteEnabled: true,
isOverlayVisibleAtStart: true,
overlayPaletteRowsVisible: 2,
overlayStyle: {
barPaletteIndex: C_OVERLAY_BAR,
textPaletteIndex: C_OVERLAY_TEXT,
gapPaletteIndex: C_OVERLAY_BAR,
},
};
}
// Builds the palette with gradient colors and starts the cycling effects.
async init() {
console.log('[PaletteCyclingDemo] Initializing...');
this.palette = BT.paletteCreate(256);
// Colors for the engine overlay (the stats HUD). They live in low slots so
// configure() could name them before the UI theme existed.
this.palette.set(C_OVERLAY_TAG, new Color32(255, 255, 255));
this.palette.set(C_OVERLAY_BAR, new Color32(10, 12, 20));
this.palette.set(C_OVERLAY_RENDER, new Color32(20, 24, 36));
this.palette.set(C_OVERLAY_TEXT, new Color32(255, 210, 80));
this.palette.set(C_OVERLAY_WARN, new Color32(120, 130, 160));
this.palette.set(C_OVERLAY_ERR, new Color32(70, 70, 90));
// The three gradients below all use the fillGradient() helper defined above the
// class; each one only supplies its own start and end channel values.
// Sky gradient: deep purple to soft pink (red 40-160, green 20-60, blue 80-180).
fillGradient(
this.palette,
C_SKY_BASE,
SKY_SLOTS,
(t) => new Color32(Math.floor(40 + t * 120), Math.floor(20 + t * 40), Math.floor(80 + t * 100)),
);
// Fire gradient: dark red to bright yellow (red 80-255, green 0-200, blue 0-40).
fillGradient(
this.palette,
C_FIRE_BASE,
FIRE_SLOTS,
(t) => new Color32(Math.floor(80 + t * 175), Math.floor(t * 200), Math.floor(t * 40)),
);
// Water gradient: dark blue to bright cyan (red 0-60, green 40-200, blue
// 100-255).
fillGradient(
this.palette,
C_WATER_BASE,
WATER_SLOTS,
(t) => new Color32(Math.floor(t * 60), Math.floor(40 + t * 160), Math.floor(100 + t * 155)),
);
// Install the shared UI kit colors (panel fills, borders, headings, dim text).
// applyTheme() writes 12 colors into slots 240-251 - far above every range the
// engine cycles here (sky 10-19, fire 30-35, water 50-57), so the rotation can
// never touch the UI theme.
this.theme = applyTheme(this.palette);
// Make this palette the one the engine draws with.
BT.paletteSet(this.palette);
// Start the cycling effects. The engine runs them once per tick until
// BT.paletteClearEffects() is called - this demo never calls it, so they run
// forever. Each call names the first and last slot of a range (both inclusive)
// and a speed in steps per second.
BT.paletteCycle(C_SKY_BASE, C_SKY_BASE + SKY_SLOTS - 1, SKY_SPEED);
BT.paletteCycle(C_FIRE_BASE, C_FIRE_BASE + FIRE_SLOTS - 1, FIRE_SPEED);
BT.paletteCycle(C_WATER_BASE, C_WATER_BASE + WATER_SLOTS - 1, WATER_SPEED);
console.log('[PaletteCyclingDemo] Initialized');
return true;
}
// Swaps two fire slots every SWAP_INTERVAL ticks to demonstrate BT.paletteSwap(), and
// hides the caption a second later.
update() {
const tick = BT.ticks;
// The pair is not random - it is computed from the tick counter, so each swap
// picks a predictable pair: tick % FIRE_SLOTS and (tick + 3) % FIRE_SLOTS. `%` is
// the remainder after division, so both land inside the 6 fire slots.
if (tick - this.lastSwapTick >= SWAP_INTERVAL) {
// Two slots within the fire range, always 3 apart.
this.swappedA = C_FIRE_BASE + (tick % FIRE_SLOTS);
this.swappedB = C_FIRE_BASE + ((tick + 3) % FIRE_SLOTS);
// The two slots can never be equal here, so this check is just a safety net.
if (this.swappedA !== this.swappedB) {
BT.paletteSwap(this.swappedA, this.swappedB);
this.showSwapLabel = true;
}
this.lastSwapTick = tick;
}
// Hide the swap caption after 60 ticks (1 second).
if (this.showSwapLabel && tick - this.lastSwapTick > 60) {
this.showSwapLabel = false;
}
}
// Draws the three animated bands and their kit-panel headings. No Color32 objects
// here - only palette indices.
render() {
BT.clear(this.theme.bg);
this.renderSkyPanel();
this.renderFirePanel();
this.renderWaterPanel();
}
// Opens and closes a titled kit panel with an empty body `artH` pixels tall. The
// artwork is drawn after it with ui.drawRectFill(), not BT.drawRectFill(): kit panels
// are sprites, and the engine draws every BT.drawRectFill() before any sprite - so a
// plain one would hide under the panel. ui.drawRectFill() takes the same rectangle
// and palette slot, but draws a sprite, which lands on top in call order.
renderPanel(y, artH, title) {
ui.begin(UI_ANCHORS.TOP_LEFT, { x: EDGE, y, width: PANEL_W });
ui.panel(title);
ui.spacer(artH);
ui.end();
}
// Fills one block of artwork on top of a panel.
fillBlock(x, y, width, height, slot) {
this.tempRect.set(x, y, width, height);
ui.drawRectFill(this.tempRect, slot);
}
// Sky: ten full-width stripes, one per sky slot, 3 pixels tall. The slow cycling
// makes the twilight colors creep up the band. The panel title is the exact call
// from init() that drives it.
renderSkyPanel() {
this.renderPanel(SKY_PANEL_Y, SKY_ART_H, 'BT.paletteCycle(10, 19, 0.5)');
const x = EDGE + PANEL_TEXT_INSET;
const top = SKY_PANEL_Y + PANEL_CONTENT_TOP + 1;
for (let i = 0; i < SKY_SLOTS; i++) {
this.fillBlock(x, top + i * 3, PANEL_W - 2 * PANEL_TEXT_INSET, 3, C_SKY_BASE + i);
}
}
// Fire: 24 narrow columns of 8 blocks. Down each column the slot goes up by one per
// row, so every column is a copy of the gradient, but each column starts a little
// further along it than its neighbor. The start point follows a sine wave, so it
// rises and falls smoothly across the panel and the color bands bend into flame
// tongues instead of lying flat.
renderFirePanel() {
this.renderPanel(FIRE_PANEL_Y, FIRE_ART_H, 'BT.paletteCycle(30, 35, -6)');
const x0 = EDGE + (PANEL_W - GRID_W) / 2;
const top = FIRE_PANEL_Y + PANEL_CONTENT_TOP;
for (let col = 0; col < FIRE_COLS; col++) {
// Math.sin() swings between -1 and 1; + 1 makes that 0 to 2, and * 1.5 makes
// it 0 to 3. Math.floor() turns it into a whole step: 0, 1, 2, or 3.
const phase = Math.floor((Math.sin(col * 0.8) + 1) * 1.5);
for (let row = 0; row < FIRE_ART_H / BLOCK_H; row++) {
const slot = C_FIRE_BASE + ((row + phase) % FIRE_SLOTS);
this.fillBlock(x0 + col * FIRE_COL_STEP, top + row * BLOCK_H, FIRE_COL_STEP - 1, BLOCK_H, slot);
}
}
// The swap caption, while it is active: right-aligned in the panel's title row,
// clear of the artwork. Every character of the system font is 6 pixels wide, so
// the text is label.length * 6 pixels wide.
if (this.showSwapLabel) {
const label = `swapped ${this.swappedA} <-> ${this.swappedB}`;
const labelX = EDGE + PANEL_W - PANEL_TEXT_INSET - label.length * 6;
ui.caption(labelX, FIRE_PANEL_Y + 11, label, { color: 'warm' });
}
}
// Water: a grid of 36 columns by 8 rows. The slot goes up by one per column and one
// per row, so the gradient runs diagonally, and the forward cycling makes the
// diagonal waves roll across the grid.
renderWaterPanel() {
this.renderPanel(WATER_PANEL_Y, WATER_ART_H, 'BT.paletteCycle(50, 57, 4)');
const x0 = EDGE + (PANEL_W - GRID_W) / 2;
const top = WATER_PANEL_Y + PANEL_CONTENT_TOP;
for (let row = 0; row < WATER_ART_H / BLOCK_H; row++) {
for (let col = 0; col < WATER_COLS; col++) {
const slot = C_WATER_BASE + ((col + row) % WATER_SLOTS);
this.fillBlock(x0 + col * WATER_COL_STEP, top + row * BLOCK_H, WATER_COL_STEP - 1, BLOCK_H, slot);
}
}
}
}
bootstrap(Demo);