Example: Sprites
Draw sprites from a sheet: pick cells, recolor, flip, rotate, scale, pivot, and stretch to any size.
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/sprites. 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.
/*
Sprites: draw images from a sprite sheet and transform them on the way to the screen.
@description Draw sprites from a sheet: pick cells, recolor, flip, rotate, scale, pivot, and stretch to any size.
Part of the BLIT386 demo series.
Prerequisites:
- Basics: https://demos.blit386.dev/basics
- Primitives: https://demos.blit386.dev/primitives
- Colors: https://demos.blit386.dev/colors
Live version: https://demos.blit386.dev/sprites
Guides:
- Palette (index sprites): https://blit386.dev/docs/guides/palette#index-sprites-preferred-and-manual-flows
- Palette (per-draw offsets): https://blit386.dev/docs/guides/palette#per-draw-palette-offsets-zero-cost-color-variants
Reference:
- Rendering (sprites): https://blit386.dev/docs/api/rendering#flipping-rotating-and-scaling-sprites
- Rendering (tiles): https://blit386.dev/docs/api/rendering#drawing-tiles
- Assets: https://blit386.dev/docs/api/assets#loading-assets
- Palette: https://blit386.dev/docs/api/palette#fill-a-block-of-slots
---
WHAT YOU WILL SEE
-----------------
A 640x400 screen on the UI kit's navy background, split into captioned sections:
- Top-left: six shapes, each loaded from its own 16x16 PNG - Square, Circle, Tri, Star,
Heart, Gem.
- Below them: the same star four times, recolored by palette offset - Original, Fire, Ice,
Void.
- Below that: the triangle facing Up, Right, Down, and Left using flip and rotate flags,
then a fifth triangle with a Turn button beside it.
- Bottom-left: the Pivot section. Four triangles planted by their center on marked dots
(small white squares), then two pairs comparing pivot (0, 0) with no pivot.
- Top-right: the Scale section. Stars at 1x, 2x, 3x, and 4x standing on one baseline, then
uneven scales - Vector2i(3, 1), Vector2i(1, 3), and a quarter-turned triangle at (3, 1).
- Lower right: the Stretch section. A heart squeezed into a box whose width swings between
20 and 200 pixels, next to a fixed 30x50 heart.
- Along the bottom: a full-width code panel showing how a real PNG would be loaded.
The engine overlay is turned off in this demo (isOverlayEnabled: false).
WHAT YOU WILL LEARN
-------------------
- A "sprite" is a 2D image used in a game - a character, a coin, an enemy. A "sprite
sheet" is one big image holding many small sprites in a grid. You draw one by telling
the engine which part of the sheet to copy.
- BT.drawSprite(sheet, srcRect, dest, params) copies the Rect2i source rectangle to dest.
BT.drawTile(sheet, cell, pos, paletteOffset) does the same by cell number once the sheet
knows its cell size (sheet.tileSize); sheet.tileRect(cell) gives the matching Rect2i.
- Loading PNGs with SpriteSheet.load(), registering their colors in the palette with
SpriteSheet.loadColorsIntoPalette(), and calling sheet.indexize(palette) so every pixel
becomes a palette slot number.
- palette.fillBlock(start, source, transform) writes a recolored copy of a color block;
a palette offset then picks that block at draw time.
- The params object: flags (BT.FLIP_H, BT.FLIP_V, BT.ROT_90_CW, BT.ROT_180_CW,
BT.ROT_270_CW, combined with |), scale (a whole number or a Vector2i), pivot (a Vector2i
in source pixels), and paletteOffset. One object is created once and reused.
- A Rect2i destination stretches the sprite to any size; pivot must be undefined and scale
must be 1 there.
- UI kit pieces: ui.caption() for labels, ui.button() with a { key } binding, and
ui.tick() as the first line of update() so the key press is never missed.
HOW PALETTE OFFSETS WORK FOR SPRITES
------------------------------------
After sheet.indexize(palette), each pixel in the sprite is stored as a palette slot
number. When you draw the sprite:
BT.drawTile(sheet, cell, pos, 0) - uses the original colors
BT.drawTile(sheet, cell, pos, colorCount) - shifts every pixel's slot up by colorCount
BT.drawSprite() takes the same number as its 4th argument, or as params.paletteOffset
when you pass a params object. If the original colors sit in slots 10-14, an offset of 5
makes every pixel use slots 15-19 instead - a completely different color theme from the
same pixels. This is how retro games did "team colors" and environmental lighting.
This demo loads six tiny PNGs, one per shape, and shares one palette between them. The
one-call version for a single PNG looks like this:
await SpriteSheet.loadIndexed('/sprites/hero.png', palette, startSlot)
The Palette Presets demo explores the palette system in depth:
https://demos.blit386.dev/palette-presets
TRY THIS
--------
- Click or tap the Turn button, or press Space, to flip the fifth triangle between facing
right and facing left.
- Watch the stretched heart: its box width changes every frame, yet the edges stay crisp,
because each screen pixel copies exactly one sprite pixel - no blur.
- Compare the two Pivot pairs: with no pivot the sprite starts at the dot; with pivot
(0, 0) the flag has moved the source's top-left corner, so the sprite lands on the other
side of the dot.
*/
import { bootstrap, BT, Color32, Rect2i, SpriteSheet, Vector2i } from 'blit386';
import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';
/** @typedef {import('blit386').Palette} Palette */
/** @typedef {import('blit386').SpriteSheet} SpriteSheet */
// Where in the palette the sprite's original colors start. The sprites use two colors
// (fill and stroke), and the three recolored blocks below stack up to slot 17, so
// everything above that stays free for the shared UI theme (slots 240-251).
const COLOR_BASE = 10;
// Every shape is its own 16x16 PNG, so each one is a small sprite sheet of one cell.
const SHAPE_CELL = 16;
// The six shapes: the caption under each one and the PNG that holds it. This single list
// drives both the loader in init() and the captions in render(). The captions sit 50
// pixels apart, so the longer names are shortened ('Triangle', 'Gem') to keep each label
// inside its column.
const SHAPES = [
{ name: 'Square', url: '/sprites/square-16.png' },
{ name: 'Circle', url: '/sprites/circle-16.png' },
{ name: 'Triangle', url: '/sprites/triangle-16.png' },
{ name: 'Star', url: '/sprites/star-16.png' },
{ name: 'Heart', url: '/sprites/heart-16.png' },
{ name: 'Gem', url: '/sprites/gem-16.png' },
];
// Position of the star in SHAPES. The palette-offset row below the shape grid draws it
// four times.
const STAR_SHAPE = 3;
// Position of the triangle - the one shape that looks different when turned.
const TRIANGLE_SHAPE = 2;
// The triangle points up in the sheet. These flags turn it to face each way. Each flag is
// one bit of a number, so several can be combined with | ("bitwise or", which keeps every
// bit that is set on either side). The engine always applies them in the same order,
// whichever way round you write them: flips first, then the turn. So
// BT.ROT_90_CW | BT.FLIP_V mirrors the triangle top to bottom, then turns it a quarter
// turn clockwise.
const FACINGS = [
{ name: 'Up', flags: 0 },
{ name: 'Right', flags: BT.ROT_90_CW },
{ name: 'Down', flags: BT.ROT_180_CW },
{ name: 'Left', flags: BT.ROT_270_CW },
];
// Display size for this demo. 640x400 is double the usual 320-pixel width, which leaves
// room for the scale and stretch sections next to the original rows.
const DISPLAY_W = 640;
const DISPLAY_H = 400;
// Full width of the bottom code strip: ui.begin() keeps its default 4-pixel margin on
// each side.
const CODE_PANEL_WIDTH = DISPLAY_W - 8;
// Position of the heart - the stretch example squeezes it into a changing box.
const HEART_SHAPE = 4;
// Whole-number scales drawn side by side in the Scale section.
const UNIFORM_SCALES = [1, 2, 3, 4];
// Where the Pivot section sits: its title row, the y of the planted triangles' marked
// dots, and the row where the pivot (0, 0) comparison is drawn.
const PIVOT_TOP = 205;
const PIVOT_PLANTED_Y = 233;
const PIVOT_CORNER_Y = 280;
// Part 1 of the Pivot section: the same triangle, drawn with a different flag and scale
// each time.
const PLANTED_POSES = [
{ flags: 0, scale: 1 },
{ flags: BT.ROT_90_CW, scale: 1 },
{ flags: BT.ROT_180_CW, scale: 2 },
{ flags: BT.FLIP_H, scale: 2 },
];
// Part 2: the flags under which pivot (0, 0) and no pivot are compared.
const PIVOT_COMPARISONS = [
{ name: 'FLIP_H', flags: BT.FLIP_H },
{ name: 'ROT_90_CW', flags: BT.ROT_90_CW },
];
// Sprite sheets, cell picking, palette offsets, flip and rotate flags, scale, pivot, and
// stretching into a Rect2i - all on one 640x400 screen.
class Demo {
/** @type {Palette | null} */
palette = null;
// One sheet per shape, in SHAPES order. Each is a single 16x16 cell.
/** @type {SpriteSheet[]} */
sheets = [];
// Slot map for the shared UI kit theme, filled in init() by applyTheme(). theme.bg,
// theme.text, and friends are palette indices for our own drawing.
theme = null;
// How many distinct colors the sprites use (two), and the Color32 list behind them.
colorCount = 0;
baseColors = [];
// The sheets the scale, flag, and stretch sections draw from, picked out of
// this.sheets once in init().
starSheet = null;
triangleSheet = null;
heartSheet = null;
// Source rect of the triangle sheet, built once in init() (sheet.tileRect makes a new
// Rect2i each call, so keep it out of render()).
triangleRect = null;
// Source rects for the scale and stretch sections, built once in init().
starRect = null;
heartRect = null;
// Uneven scales, built once: a Vector2i scale stretches the width and height by
// different whole numbers.
wideScale = new Vector2i(3, 1);
tallScale = new Vector2i(1, 3);
// The animated stretch box. render() changes only its width each frame - no new
// Rect2i per frame.
stretchBox = new Rect2i(360, 260, 20, 40);
// A fixed, uneven stretch to compare against the animated one: 16x16 into 30x50.
fixedStretchBox = new Rect2i(560, 252, 30, 50);
// Pivot points are in source pixels, measured from the source rect's top-left, before
// any flags. Both are built once in init(): centerPivot is the middle of the 16x16
// cell, cornerPivot is its top-left corner (the same corner as no pivot - until a
// flag moves it).
centerPivot = null;
cornerPivot = null;
// Scratch values for the Pivot section: a dest point and a small square that marks
// it. render() rewrites them with set() instead of creating new ones every frame.
pivotDest = new Vector2i(0, 0);
pivotMark = new Rect2i(0, 0, 3, 3);
// One reusable params object for BT.drawSprite. Create it with every field present
// (flags, pivot, scale, paletteOffset) and rewrite the fields between draws - the
// engine reads it during the call and never keeps it. pivot: undefined means "place
// by the top-left".
drawParams = { flags: 0, pivot: undefined, scale: 1, paletteOffset: 0 };
// Which way the turnable triangle faces; the Turn button (or Space) toggles it in
// render().
isFacingLeft = false;
// A wider 640x400 screen, no engine overlay, and Space reserved for the Turn button.
configure() {
return {
displaySize: new Vector2i(DISPLAY_W, DISPLAY_H),
isOverlayEnabled: false,
// Space is the Turn button's shortcut. Without this, the browser also treats
// Space as "scroll the page down", so every press would jump the page.
isCapturingKeyboardScroll: true,
};
}
// Loads the six shape PNGs, registers their colors in the palette, and calls
// sheet.indexize() on each.
async init() {
console.log('[SpriteDemo] Initializing...');
this.palette = BT.paletteCreate(256);
// Install the shared UI theme: applyTheme() writes the twelve UI kit colors into
// high palette slots (240-251), far above this demo's sprite colors (slots
// 10-17), and returns a map of friendly names to those slots (this.theme.bg,
// .text, ...). Every caption and the code panel draw with these shared colors.
this.theme = applyTheme(this.palette);
try {
// Read every distinct color off the first PNG into palette slots starting at
// COLOR_BASE, and remember them so the recolored blocks can be derived. All
// six PNGs use the same two colors, so one pass covers them all. The option
// { sort: 'none' } keeps the order the colors first appear in the image.
this.baseColors = await SpriteSheet.loadColorsIntoPalette(SHAPES[0].url, this.palette, COLOR_BASE, {
sort: 'none',
});
this.colorCount = this.baseColors.length;
const colorCount = this.colorCount;
// Build the theme blocks: Fire, Ice, and Void are static once written here.
// palette.fillBlock(start, source, transform) writes transform(baseColor)
// into one slot per base color, starting at `start` - it replaces a
// hand-written for loop.
// Fire: push red up and pull blue down.
this.palette.fillBlock(
COLOR_BASE + colorCount,
this.baseColors,
(base) => new Color32(Math.min(255, base.r + 80), base.g, Math.max(0, base.b - 80)),
);
// Ice: pull red down and push blue up.
this.palette.fillBlock(
COLOR_BASE + colorCount * 2,
this.baseColors,
(base) => new Color32(Math.max(0, base.r - 60), base.g, Math.min(255, base.b + 80)),
);
// Void: darken every channel toward black.
this.palette.fillBlock(
COLOR_BASE + colorCount * 3,
this.baseColors,
(base) => new Color32(Math.floor(base.r * 0.25), Math.floor(base.g * 0.25), Math.floor(base.b * 0.25)),
);
// Load every PNG and convert its pixels to palette slot numbers. Each file is
// one SHAPE_CELL square, so tileSize makes cell 0 the whole image and
// BT.drawTile can draw it with no Rect2i bookkeeping.
this.sheets = await Promise.all(SHAPES.map(({ url }) => SpriteSheet.load(url, { tileSize: SHAPE_CELL })));
for (const sheet of this.sheets) {
sheet.indexize(this.palette);
}
this.starSheet = this.sheets[STAR_SHAPE];
this.triangleSheet = this.sheets[TRIANGLE_SHAPE];
this.heartSheet = this.sheets[HEART_SHAPE];
this.triangleRect = this.triangleSheet.tileRect(0);
this.starRect = this.starSheet.tileRect(0);
this.heartRect = this.heartSheet.tileRect(0);
this.centerPivot = new Vector2i(SHAPE_CELL / 2, SHAPE_CELL / 2);
this.cornerPivot = new Vector2i(0, 0);
BT.paletteSet(this.palette);
console.log(`[SpriteDemo] Loaded ${SHAPES.length} shape sheets, ${colorCount} unique colors`);
} catch (error) {
console.error('[SpriteDemo] Failed to load shape sheets:', error);
return false;
}
console.log('[SpriteDemo] Initialization complete!');
return true;
}
// Nothing moves on its own here; update() only feeds the UI kit.
update() {
// Always first: this latches the Turn button's Space shortcut, so a quick key
// press is never missed (the button itself is declared in render()).
ui.tick();
}
// Draws every section. The left column holds the shape grid, palette offsets, flags,
// and pivot; the right column holds scale and stretch; the code panel runs along the
// bottom.
render() {
// Clear the whole screen with the shared UI theme's background color.
BT.clear(this.theme.bg);
const params = this.drawParams;
// Left column.
// Row 1: six shapes - each draw call picks a different sheet.
const shapeY = 24;
const shapeSpacing = 55;
ui.caption(12, 11, 'Source rects - one region per shape', { color: 'dim' });
for (let i = 0; i < SHAPES.length; i++) {
const destX = 12 + i * shapeSpacing;
BT.drawTile(this.sheets[i], 0, new Vector2i(destX, shapeY), 0);
ui.caption(destX, shapeY + 24, SHAPES[i].name, { color: 'dim' });
}
// Row 2: palette offsets on the star shape. Offset n * i shifts every pixel's
// slot into the i-th color block (0 = original, 1 = Fire, 2 = Ice, 3 = Void).
const n = this.colorCount;
const themeY = 90;
const themeSpacing = 72;
const themes = ['Original', 'Fire', 'Ice', 'Void'];
ui.caption(12, themeY - 13, 'Palette offsets - one star, four color blocks', { color: 'dim' });
for (let i = 0; i < themes.length; i++) {
const destX = 12 + i * themeSpacing;
BT.drawTile(this.starSheet, 0, new Vector2i(destX, themeY), n * i);
ui.caption(12 + i * themeSpacing, themeY + 24, themes[i], { color: 'dim' });
}
// Row 3: flags. The same triangle cell, turned by params.flags instead of being
// drawn four times in the sheet.
const facingY = 156;
ui.caption(12, facingY - 13, 'Flags - flip and quarter turns', { color: 'dim' });
params.scale = 1;
for (let i = 0; i < FACINGS.length; i++) {
const destX = 12 + i * shapeSpacing;
params.flags = FACINGS[i].flags;
BT.drawSprite(this.triangleSheet, this.triangleRect, new Vector2i(destX, facingY), params);
ui.caption(destX, facingY + 24, FACINGS[i].name, { color: 'dim' });
}
// The turnable one: right or left, with a 20-pixel gap after the four fixed
// examples.
const playerX = 12 + 4 * shapeSpacing;
// The Turn button sits just right of the 16-pixel-wide triangle. Tapping it,
// clicking it, or pressing Space flips which way the triangle faces.
ui.begin(UI_ANCHORS.TOP_LEFT, { x: playerX + 16, y: facingY - 2 });
if (ui.button('Turn', { key: 'Space' })) {
this.isFacingLeft = !this.isFacingLeft;
}
ui.end();
// ROT_90_CW points the triangle right, ROT_270_CW points it left.
params.flags = this.isFacingLeft ? BT.ROT_270_CW : BT.ROT_90_CW;
BT.drawSprite(this.triangleSheet, this.triangleRect, new Vector2i(playerX, facingY), params);
this.renderScale(params); // right column: scale
this.renderPivot(params); // left column, below flags: pivot
this.renderStretch(params); // lower band: stretch to a Rect2i
this.renderCodeSnippet();
}
// The Scale section. params.scale takes whole numbers. A plain number scales both
// ways; a Vector2i scales width and height separately. Scaling happens after the
// flags, in screen directions, so scale.x is always the on-screen width - even for a
// sprite turned a quarter turn.
renderScale(params) {
const left = 360;
// Every y below hangs off this one line - the bottom edge the 1x-4x stars stand
// on - so the whole section moves together if you change it.
const baselineY = 90;
ui.caption(left, 11, 'Scale - whole numbers only', { color: 'dim' });
// 1x, 2x, 3x, 4x side by side: 16, 32, 48, 64 pixels wide, all sitting on one
// baseline.
params.flags = 0;
let x = left;
for (const s of UNIFORM_SCALES) {
params.scale = s;
BT.drawSprite(this.starSheet, this.starRect, new Vector2i(x, baselineY - SHAPE_CELL * s), params);
ui.caption(x, baselineY + 10, `${s}x`, { color: 'dim' });
x += SHAPE_CELL * s + 12;
}
// Uneven whole-number scales: 3x wide, then 3x tall.
params.scale = this.wideScale;
BT.drawSprite(this.starSheet, this.starRect, new Vector2i(left, baselineY + 36), params);
ui.caption(left, baselineY + 60, 'Vector2i(3, 1)', { color: 'dim' });
params.scale = this.tallScale;
BT.drawSprite(this.starSheet, this.starRect, new Vector2i(left + 100, baselineY + 24), params);
ui.caption(left + 100, baselineY + 80, 'Vector2i(1, 3)', { color: 'dim' });
// Turned a quarter turn, then 3x wide: still 48 pixels wide on screen.
params.flags = BT.ROT_90_CW;
params.scale = this.wideScale;
BT.drawSprite(this.triangleSheet, this.triangleRect, new Vector2i(left + 160, baselineY + 36), params);
ui.caption(left + 160, baselineY + 60, 'ROT_90_CW + (3, 1)', { color: 'dim' });
// Leave the shared params object neutral for the next section.
params.flags = 0;
params.scale = 1;
}
// Marks the dest point with a small square so you can see where the sprite is
// planted.
markPivotDest(x, y) {
BT.drawRectFill(this.pivotMark.set(x - 1, y - 1, 3, 3), this.theme.text);
}
// The Pivot section. Normally dest is where the sprite's top-left lands. params.pivot
// picks a different point of the sprite - in source pixels, before flags - to put on
// dest instead. Part 1 plants a triangle by its center: flags and scale change, but
// the marked point never moves. Part 2 shows that pivot (0, 0) is not the same as no
// pivot: it is the source's top-left corner, and a flip or turn moves that corner to
// the other side of the sprite.
renderPivot(params) {
ui.caption(12, PIVOT_TOP, 'Pivot - place a sprite by a point', { color: 'dim' });
// Part 1: the center pivot. Each triangle uses the same pivot but a different
// flag and scale, and each is drawn at its own marked point.
params.pivot = this.centerPivot;
for (let i = 0; i < PLANTED_POSES.length; i++) {
const destX = 40 + i * 70;
params.flags = PLANTED_POSES[i].flags;
params.scale = PLANTED_POSES[i].scale;
BT.drawSprite(this.triangleSheet, this.triangleRect, this.pivotDest.set(destX, PIVOT_PLANTED_Y), params);
this.markPivotDest(destX, PIVOT_PLANTED_Y);
}
ui.caption(12, PIVOT_PLANTED_Y + 26, 'pivot (8, 8): the dot never moves', { color: 'dim' });
// Part 2: no pivot vs pivot (0, 0), drawn at the same marked point. With no pivot
// the sprite's footprint starts at the dot; with (0, 0) the source's top-left
// corner is on the dot, and the flag has moved that corner, so the footprint ends
// up on the other side.
params.scale = 1;
for (let i = 0; i < PIVOT_COMPARISONS.length; i++) {
const destX = 60 + i * 130;
params.flags = PIVOT_COMPARISONS[i].flags;
params.pivot = undefined;
BT.drawSprite(this.triangleSheet, this.triangleRect, this.pivotDest.set(destX, PIVOT_CORNER_Y), params);
params.pivot = this.cornerPivot;
BT.drawSprite(this.triangleSheet, this.triangleRect, this.pivotDest.set(destX, PIVOT_CORNER_Y), params);
this.markPivotDest(destX, PIVOT_CORNER_Y);
// Left sprite is the (0, 0) pivot, right sprite is no pivot.
const label = `${PIVOT_COMPARISONS[i].name}: (0,0) | none`;
ui.caption(destX - 40, PIVOT_CORNER_Y + 24, label, { color: 'dim' });
}
}
// The Stretch section. A Rect2i destination stretches the sprite to any size, even
// one that is not a whole multiple. Each screen pixel copies the sprite pixel under
// its center, so the result stays crisp - no blur - and looks the same on every
// backend. scale must stay 1 here: the box already sets the size.
renderStretch(params) {
const top = this.stretchBox.y;
ui.caption(360, top - 16, 'Stretch to a Rect2i - any size', { color: 'dim' });
// The box width swings between 20 and 200 pixels and back, about every four
// seconds. Math.sin() wanders between -1 and +1; adding 1 and halving turns that
// into 0 to 1.
const swing = (Math.sin(BT.ticks / 40) + 1) / 2;
this.stretchBox.width = Math.round(20 + swing * 180);
// A Rect2i dest sets the size itself, so a pivot with it throws. Clear it first.
params.pivot = undefined;
params.flags = 0;
params.scale = 1;
BT.drawSprite(this.heartSheet, this.heartRect, this.stretchBox, params);
ui.caption(360, top + 44, `16x16 into ${this.stretchBox.width}x40`, { color: 'dim' });
BT.drawSprite(this.heartSheet, this.heartRect, this.fixedStretchBox, params);
ui.caption(this.fixedStretchBox.x, top + 44, '30x50', { color: 'dim' });
}
// The "how you would load a real PNG" cheat sheet, as a full-width kit panel along
// the bottom edge of the screen.
renderCodeSnippet() {
ui.begin(UI_ANCHORS.BOTTOM_LEFT, { width: CODE_PANEL_WIDTH });
ui.panel('Production PNG load:');
ui.label("const indexed = await SpriteSheet.loadIndexed('/sprites/test.png', palette, 10);", {
color: 'info',
});
ui.label('const params = { flags: 0, pivot: undefined, scale: 1, paletteOffset: 0 };', { color: 'info' });
ui.end();
}
}
bootstrap(Demo);