Example: Tilemap
Build a grid world from a two-dimensional array and scroll a camera across it, drawing visible tiles.
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/tilemap. 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.
/*
Tilemap: build a grid world from a 2D array and scroll the camera across it.
@description Build a grid world from a two-dimensional array and scroll a camera across it, drawing visible tiles.
Part of the BLIT386 demo series.
Prerequisites:
- Basics: https://demos.blit386.dev/basics
- Primitives: https://demos.blit386.dev/primitives
- Camera: https://demos.blit386.dev/camera
Live version: https://demos.blit386.dev/tilemap
Guides:
- Game Loop: https://blit386.dev/docs/guides/game-loop#smoothing-motion-between-updates
- Palette: https://blit386.dev/docs/guides/palette#layout-swap-vs-value-swap
- Overlay: https://blit386.dev/docs/guides/overlay#optional-bands
Reference:
- Camera: https://blit386.dev/docs/api/camera
- Game Loop: https://blit386.dev/docs/api/game-loop#interpolating-render-state-with-renderalpha
- Overlay: https://blit386.dev/docs/api/overlay#renderer-diagnostics-optional
---
WHAT YOU WILL SEE
-----------------
A 30 by 20 tile world (480 by 320 pixels at 16 pixels per tile) seen through a 320 by
240 screen. From top to bottom: sky blue air, a row of grass with two rows of dirt under
it, two dirt hills poking up at the left and right, dark green tree canopies standing on
the grass and hills, two more rows of dirt, and a pond filling the bottom six rows with
gray stepping stones scattered in it and two stones on the shoreline. The camera slowly
sweeps across the whole map in a wave pattern, both sideways and up and down.
A UI kit panel in the top-left corner names the demo and its size. A mini-map in the
bottom-right corner shows the entire map at 3 pixels per tile inside a kit panel, with a
yellow box marking the part of the world the screen is showing right now. The pond on
the main map shimmers; the mini-map's water is a calm, still blue. The engine overlay
has the timing chart with rich GPU markers and the renderer diagnostics row turned on.
WHAT YOU WILL LEARN
-------------------
- Storing a level as a 2D array - a list of rows, each row a list of small numbers (tile
IDs) - and naming those numbers with constants like TILE_GRASS.
- Walking the grid with nested loops: one loop for rows, one for columns.
- Culling: working out which rows and columns overlap the screen from the camera
position (Math.floor for the first tile, Math.ceil for the last) and drawing only
those.
- BT.cameraSet(offset), BT.cameraReset(), and BT.cameraClamp(pos, worldSize, viewSize),
plus the BT.renderAlpha smoothing introduced in the Camera demo.
- Palette animation: update() rewrites one slot (C_WATER) with palette.set() every tick,
and render() keeps passing that slot number - the water shimmers for free.
- Reusing a single Rect2i with tileRect.set() for every tile instead of creating one per
tile.
- UI kit pieces: ui.panel(), ui.label(), ui.spacer(), and ui.drawRectFill() /
ui.drawRect() for artwork that has to land on top of a kit panel.
- configure() overlay options: isOverlayTimingChartEnabled,
overlayTimingChartDiagnostics: 'rich', and isOverlayRendererDiagnosticsBarEnabled.
WHAT IS A TILEMAP?
------------------
A tilemap is like a floor made of same-sized square tiles. Each cell in a 2D array stores
a small number that means "which kind of tile goes here" - like a Lego instruction sheet
that says which brick color fits each stud. The computer walks the grid with nested loops
and draws only the tiles the camera can see, which is faster than drawing thousands of
off-screen tiles nobody would see.
TRY THIS
--------
- Press the backquote key (`) to open the engine overlay. The timing chart carries rich
GPU markers, and the renderer diagnostics row below the frame metrics shows what the
renderer is doing.
- Compare the pond on the main map with the water on the mini-map: the first uses the
animated C_WATER slot, the second the fixed C_MINIMAP_WATER slot.
*/
import { bootstrap, BT, Color32, Rect2i, Vector2i } from 'blit386';
import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';
/** @typedef {import('blit386').Palette} Palette */
// These numbers are the "tile IDs" stored inside the 2D array. Naming them helps you
// remember what each number means when you read the map.
const TILE_SKY = 0; // empty air - skipped, the sky clear color shows through
const TILE_GRASS = 1; // green ground
const TILE_DIRT = 2; // brown earth below or beside grass
const TILE_STONE = 3; // gray rocks
const TILE_WATER = 4; // blue water (its shade is animated a little each tick)
const TILE_TREE_TOP = 5; // dark green tree canopy
// How many tiles wide and tall the world is. Multiply by TILE_SIZE to get the pixel size.
const MAP_WIDTH_TILES = 30;
const MAP_HEIGHT_TILES = 20;
// Every tile is a small square on a pixel grid. 16 is a common retro size.
const TILE_SIZE = 16;
// World size in pixels: 30 * 16 = 480 wide, 20 * 16 = 320 tall.
const WORLD_WIDTH_PX = MAP_WIDTH_TILES * TILE_SIZE;
const WORLD_HEIGHT_PX = MAP_HEIGHT_TILES * TILE_SIZE;
// Each color in this demo has a reserved palette slot (a number from 1 upward). Slot 0 is
// always transparent. Giving each slot a name makes the drawing code easier to read -
// "draw in C_GRASS" is clearer than "draw in slot 5". The caption panel and the mini-map
// frame use the shared UI kit theme instead, which applyTheme() installs into slots
// 240-251.
const C_SKY = 2; // soft sky blue: fills the screen background
const C_HUD_BAR = 3; // semi-transparent black: the engine overlay bar color
const C_TEXT_DIM = 4; // dimmed white: the engine overlay text color
const C_GRASS = 5; // green: grass tiles
const C_DIRT = 6; // brown: dirt tiles
const C_STONE = 7; // gray: stone/rock tiles
const C_TREE_TOP = 8; // dark green: tree canopy tiles
const C_MINIMAP_WATER = 9; // still blue: water on the mini-map only (never animated)
const C_CHART_WARNING = 11; // near-white: overlay timing chart warning color
const C_VIEWPORT = 12; // yellow: rectangle showing the camera view on the mini-map
const C_CHART_TAG = 13; // dim gray: overlay timing chart tag labels
const C_WATER = 14; // animated water tile color, rewritten every tick in update()
// A scrolling tile-based landscape with a mini-map and animated water.
class Demo {
// The tilemap is an array of rows: map[row][column] is one cell. Row 0 is the top of
// the world; column 0 is the left edge. Think of it like a spreadsheet: the first
// index is how far down, the second is how far right.
map = [];
// The top-left corner of the world (in pixels) that appears at the top-left of the
// screen. When this moves right, the world seems to slide left.
cameraPos = new Vector2i(0, 0);
// Where the camera was at the start of the most recent update() tick, before that
// tick's sine math moved it. render() blends between cameraPrevPos and cameraPos
// using BT.renderAlpha, so the camera pans smoothly between ticks instead of jumping
// - see the Game Loop guide linked in the header.
cameraPrevPos = new Vector2i(0, 0);
// Reused every render() call for the blended camera position, so no new Vector2i is
// allocated per frame. Both the actual camera offset and the visible-tile culling
// window use this same value, so they never disagree about which pixel row and column
// the camera is looking at.
cameraRenderPos = new Vector2i(0, 0);
// The palette holds all the colors this demo uses. It is filled in init() so the
// engine knows every color before drawing begins.
/** @type {Palette | null} */
palette = null;
// Slot map for the shared UI kit theme, filled in init() by applyTheme(). It tells us
// which palette slots hold the kit's panel, border, and text colors.
theme = null;
// One rectangle object rewritten for every tile we draw. Reusing it avoids making a
// new Rect2i per tile, which would create garbage for the browser to clean up.
tileRect = new Rect2i(0, 0, TILE_SIZE, TILE_SIZE);
// Turns on the overlay timing chart with rich GPU markers and the renderer
// diagnostics row, and picks palette slots for the overlay bars, text, and chart.
configure() {
return {
isOverlayTimingChartEnabled: true,
overlayTimingChartDiagnostics: 'rich',
isOverlayRendererDiagnosticsBarEnabled: true,
overlayStyle: {
barPaletteIndex: C_HUD_BAR,
textPaletteIndex: C_TEXT_DIM,
gapPaletteIndex: C_HUD_BAR,
},
overlayTimingChartStyle: {
updateBarPaletteIndex: C_VIEWPORT,
renderBarPaletteIndex: C_GRASS,
warningPaletteIndex: C_CHART_WARNING,
errorPaletteIndex: C_STONE,
tagPaletteIndex: C_CHART_TAG,
},
};
}
// Runs once at startup: builds the palette and fills the 2D tilemap array.
async init() {
// A palette is like an artist's paint tray - we choose all our colors before
// drawing anything. Each color gets a number (a slot) that we use in draw calls.
this.palette = BT.paletteCreate(256);
// Static (fixed) colors: these never change from frame to frame. Each slot's role
// is described on its C_* constant at the top of the file.
this.palette.set(C_SKY, new Color32(135, 206, 250));
this.palette.set(C_HUD_BAR, new Color32(0, 0, 0, 185));
this.palette.set(C_TEXT_DIM, new Color32(200, 200, 200));
this.palette.set(C_GRASS, new Color32(50, 160, 60));
this.palette.set(C_DIRT, new Color32(130, 90, 55));
this.palette.set(C_STONE, new Color32(120, 120, 130));
this.palette.set(C_TREE_TOP, new Color32(15, 90, 30));
this.palette.set(C_MINIMAP_WATER, new Color32(30, 110, 200));
this.palette.set(C_CHART_WARNING, new Color32(240, 240, 240));
this.palette.set(C_VIEWPORT, new Color32(255, 230, 60));
this.palette.set(C_CHART_TAG, new Color32(160, 160, 160));
// Dynamic color: the animated water tile. It gets a starting value here so there
// is no empty slot on the very first frame before update() runs.
this.palette.set(C_WATER, new Color32(30, 110, 210));
// Install the shared UI kit theme. applyTheme() writes twelve UI colors into high
// palette slots (240-251), far above this demo's scene slots (2-14), and returns
// a map of slot numbers we can draw with (panel, border, text...).
this.theme = applyTheme(this.palette);
// Tell the engine to use this palette for all drawing from now on.
BT.paletteSet(this.palette);
// Fill the tilemap with a simple outdoor scene: sky, grass, dirt, trees, water,
// rocks.
this.buildLandscape();
return true;
}
// Runs at a fixed rate (60 times per second) for game logic. Moves the camera and
// rewrites the animated water color in the palette so render() can use C_WATER as a
// plain slot number.
update() {
// Remember where the camera was before this tick's math moves it, so render() has
// an "old" and a "new" position to blend between.
this.cameraPrevPos.set(this.cameraPos.x, this.cameraPos.y);
// BT.ticks counts how many fixed updates have happened since the demo started.
// Multiplying by a small number makes the wave change slowly over time.
const t = BT.ticks * 0.028;
// The camera can scroll from 0 up to "world size minus screen size" on each axis.
// Math.sin(t) wiggles forever between -1 and +1, like a gentle wave on water;
// centering it in that range and scaling it by half the range makes the camera
// sweep the whole map. Math.floor turns the result into a whole pixel position
// (BLIT386 uses integer pixels).
const viewSize = BT.displaySize;
const maxCamX = WORLD_WIDTH_PX - viewSize.x;
const maxCamY = WORLD_HEIGHT_PX - viewSize.y;
const centerX = maxCamX / 2;
const centerY = maxCamY / 2;
const amplitudeX = Math.max(0, maxCamX / 2);
const amplitudeY = Math.max(0, maxCamY / 2);
this.cameraPos.x = Math.floor(centerX + Math.sin(t) * amplitudeX);
this.cameraPos.y = Math.floor(centerY + Math.sin(t * 0.65) * amplitudeY);
// Clamping keeps the camera inside the world so you never see empty void past the
// edge.
this.cameraPos = BT.cameraClamp(this.cameraPos, new Vector2i(WORLD_WIDTH_PX, WORLD_HEIGHT_PX), viewSize);
// BT.cameraSet() happens in render(), using a position blended between
// cameraPrevPos and cameraPos.
// Update the animated water color in the palette. Instead of computing a new
// Color32 inside render() every frame, we compute it here in update() and store
// it in the reserved C_WATER slot. render() can then just write C_WATER as a
// plain number - no Color32 needed there. This "palette animation" technique is
// how retro hardware made water shimmer.
// waterPulse is a gentle wave between -1 and +1; it moves the blue channel 28
// units up and down around 210.
const waterPulse = Math.sin(BT.ticks * 0.12);
const waterBlue = Math.floor(210 + waterPulse * 28);
this.palette.set(C_WATER, new Color32(30, 110, waterBlue));
}
// Runs once per screen refresh. Clears the sky, draws the visible tiles, then draws
// the HUD in screen space after resetting the camera.
render() {
// Soft sky blue behind everything. Tiles with ID 0 (sky) are not drawn, so this
// color shows through in "empty" cells.
BT.clear(C_SKY);
// Blend cameraPrevPos toward cameraPos by BT.renderAlpha - a fraction from 0 (a
// tick just finished) to just under 1 (the next tick is about to happen) - so the
// camera's on-screen position matches this exact render moment instead of only
// its last-tick position. Both the camera offset and the visible-tile culling
// window below use this same smoothed value.
this.cameraRenderPos.set(
Math.floor(this.cameraPrevPos.x + (this.cameraPos.x - this.cameraPrevPos.x) * BT.renderAlpha),
Math.floor(this.cameraPrevPos.y + (this.cameraPos.y - this.cameraPrevPos.y) * BT.renderAlpha),
);
BT.cameraSet(this.cameraRenderPos);
// Draw the chunk of the world that might be visible right now.
this.renderVisibleTiles();
// The HUD and mini-map should stick to the screen, not scroll away with the
// world.
BT.cameraReset();
this.renderHud();
}
// Creates the 2D array and paints a simple slice of nature: sky, a ground strip,
// trees, a pond at the bottom, and a few stone patches in the water.
buildLandscape() {
// Start with a fresh empty array we will push rows into.
this.map = [];
// Outer loop: each row is one horizontal line of tiles from left to right.
for (let row = 0; row < MAP_HEIGHT_TILES; row++) {
// The tile IDs for this row.
const line = [];
for (let col = 0; col < MAP_WIDTH_TILES; col++) {
// Default every cell to sky until we overwrite it below.
line.push(TILE_SKY);
}
// Attach the finished row to the map (rows stack from top to bottom).
this.map.push(line);
}
// Ground band: a row of grass with two rows of dirt under it, just below the
// middle of the map.
const grassRow = 9;
for (let col = 0; col < MAP_WIDTH_TILES; col++) {
this.map[grassRow][col] = TILE_GRASS;
this.map[grassRow + 1][col] = TILE_DIRT;
this.map[grassRow + 2][col] = TILE_DIRT;
}
// Small hills of dirt sticking up into the sky on the left and right.
for (let col = 2; col < 8; col++) {
this.map[grassRow - 1][col] = TILE_DIRT;
}
for (let col = 22; col < 28; col++) {
this.map[grassRow - 1][col] = TILE_DIRT;
}
// Tree tops sit on the grass (or on a hill) like broccoli on a plate: two tiles
// tall in each of these columns.
const treeCols = [4, 5, 14, 15, 16, 25, 26];
for (const col of treeCols) {
this.map[grassRow - 1][col] = TILE_TREE_TOP;
this.map[grassRow - 2][col] = TILE_TREE_TOP;
}
// Wider tree: two more canopy tiles on the upper canopy row only.
this.map[grassRow - 2][6] = TILE_TREE_TOP;
this.map[grassRow - 2][7] = TILE_TREE_TOP;
// Lower area: two more rows of dirt, then water filling the bottom of the map.
const waterTopRow = 14;
for (let row = grassRow + 3; row < waterTopRow; row++) {
for (let col = 0; col < MAP_WIDTH_TILES; col++) {
this.map[row][col] = TILE_DIRT;
}
}
// Pond: water across the bottom six rows.
for (let row = waterTopRow; row < MAP_HEIGHT_TILES; row++) {
for (let col = 0; col < MAP_WIDTH_TILES; col++) {
this.map[row][col] = TILE_WATER;
}
}
// Stepping stones and little islands (stone replaces water in those cells). Each
// entry is [row, col].
const stoneSpots = [
[15, 16],
[15, 17],
[16, 16],
[17, 8],
[17, 9],
[18, 20],
[18, 21],
[19, 12],
[19, 13],
[19, 14],
];
for (const [row, col] of stoneSpots) {
this.map[row][col] = TILE_STONE;
}
// Two stone tiles on the shoreline, in the last dirt row above the water.
this.map[waterTopRow - 1][10] = TILE_STONE;
this.map[waterTopRow - 1][11] = TILE_STONE;
}
// Figures out which tile rows and columns overlap the screen and draws only those.
// That is a simple "culling" optimization: the work scales with the visible tiles,
// not the whole 30x20 map (though for a map this small either way would be fine).
renderVisibleTiles() {
// How big is the virtual screen in pixels?
const viewW = BT.displaySize.x;
const viewH = BT.displaySize.y;
// The camera position is the world pixel at the top-left of the view. Use the
// same blended position render() just passed to BT.cameraSet(), so the culling
// window always agrees with what actually got drawn.
const camX = this.cameraRenderPos.x;
const camY = this.cameraRenderPos.y;
// Convert pixel edges to tile indices: Math.floor for the left/top tile,
// Math.ceil for the pixel just past the right/bottom edge so partly visible tiles
// are included. Math.max and Math.min keep the range inside the map.
const startCol = Math.max(0, Math.floor(camX / TILE_SIZE));
const startRow = Math.max(0, Math.floor(camY / TILE_SIZE));
const endCol = Math.min(MAP_WIDTH_TILES - 1, Math.ceil((camX + viewW) / TILE_SIZE) - 1);
const endRow = Math.min(MAP_HEIGHT_TILES - 1, Math.ceil((camY + viewH) / TILE_SIZE) - 1);
// Nested loops: the outer one walks down the rows, the inner one across the
// columns. This visits every visible cell exactly once.
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
// Read which tile kind lives in this grid cell.
const id = this.map[row][col];
// Sky tiles are not drawn at all; the clear color already painted the
// sky.
if (id === TILE_SKY) {
continue;
}
// World pixel position of this tile's top-left corner.
const worldX = col * TILE_SIZE;
const worldY = row * TILE_SIZE;
// Reuse the same Rect2i: set(x, y, width, height).
this.tileRect.set(worldX, worldY, TILE_SIZE, TILE_SIZE);
// Pick the palette slot for this tile ID. if / else if is like a menu:
// the first match wins. We pass just a number (C_GRASS, C_DIRT, and so
// on) - the palette knows the actual color.
if (id === TILE_GRASS) {
BT.drawRectFill(this.tileRect, C_GRASS);
} else if (id === TILE_DIRT) {
BT.drawRectFill(this.tileRect, C_DIRT);
} else if (id === TILE_STONE) {
BT.drawRectFill(this.tileRect, C_STONE);
} else if (id === TILE_WATER) {
// C_WATER is the animated water slot - update() already set its
// color.
BT.drawRectFill(this.tileRect, C_WATER);
} else if (id === TILE_TREE_TOP) {
BT.drawRectFill(this.tileRect, C_TREE_TOP);
}
}
}
}
// Draws the caption panel and the mini-map after the camera is reset, so they stay on
// the screen.
renderHud() {
// The caption panel is built with the shared UI kit. Widgets declared between
// ui.begin() and ui.end() stack into one anchored group; the kit measures the
// rows, draws the panel background, and places the group for us. The kit draws in
// whatever camera space is active, so this must run after BT.cameraReset() -
// otherwise the panel would scroll away with the world.
ui.begin(UI_ANCHORS.TOP_LEFT);
ui.panel('Tilemap');
ui.label('30x20 tiles, 16px each', { color: 'dim' });
ui.label('Camera scrolls automatically', { color: 'dim' });
ui.end();
// The mini-map sits in the bottom-right, like the corner fold of a treasure map.
this.renderMiniMap();
}
// Scales the whole tilemap down so each tile is a 3x3 pixel square on the HUD, and
// draws a yellow rectangle around the area the main camera is showing.
renderMiniMap() {
const mapX = 221;
const mapY = 171;
const scale = 3; // each world tile becomes a 3 by 3 block on the mini-map
const mapPixelW = MAP_WIDTH_TILES * scale;
const mapPixelH = MAP_HEIGHT_TILES * scale;
// Backing panel from the shared UI kit (same look as every other demo panel).
// spacer() reserves the tile area; the tiles are drawn on top after end(). The
// mini-map uses ui.drawRectFill() and ui.drawRect(), not the BT.* versions the
// main map uses: kit panels are sprites, and the engine draws every
// BT.drawRectFill() before any sprite - so plain ones would hide under the panel.
// The ui.* versions take the same rectangle and palette slot, but draw sprites,
// which land on top in call order.
ui.begin(UI_ANCHORS.TOP_LEFT, {
x: mapX - 5,
y: mapY - 5,
width: mapPixelW + 10,
pad: 9,
});
ui.panel();
ui.spacer(mapPixelH);
ui.end();
// Walk every tile in the entire world (the map is small, so this is cheap).
for (let row = 0; row < MAP_HEIGHT_TILES; row++) {
for (let col = 0; col < MAP_WIDTH_TILES; col++) {
const id = this.map[row][col];
const px = mapX + col * scale;
const py = mapY + row * scale;
// Pick a palette slot for each tile type. Sky tiles use C_SKY, so the map
// background is the sky color. Water uses C_MINIMAP_WATER - a still
// shade, not the animated C_WATER - so the mini-map stays calm while the
// pond shimmers.
let c = C_SKY;
if (id === TILE_GRASS) {
c = C_GRASS;
} else if (id === TILE_DIRT) {
c = C_DIRT;
} else if (id === TILE_STONE) {
c = C_STONE;
} else if (id === TILE_WATER) {
c = C_MINIMAP_WATER;
} else if (id === TILE_TREE_TOP) {
c = C_TREE_TOP;
}
this.tileRect.set(px, py, scale, scale);
ui.drawRectFill(this.tileRect, c);
}
}
// Viewport indicator: where is the 320x240 window inside the 480x320 world? Use
// this.cameraPos, not BT.camera: render() already called BT.cameraReset(), which
// zeroes BT.camera - our own field still remembers the real position.
const cam = this.cameraPos;
const disp = BT.displaySize;
const vx = mapX + Math.floor((cam.x / WORLD_WIDTH_PX) * mapPixelW);
const vy = mapY + Math.floor((cam.y / WORLD_HEIGHT_PX) * mapPixelH);
const vw = Math.max(1, Math.floor((disp.x / WORLD_WIDTH_PX) * mapPixelW));
const vh = Math.max(1, Math.floor((disp.y / WORLD_HEIGHT_PX) * mapPixelH));
this.tileRect.set(vx, vy, vw, vh);
ui.drawRect(this.tileRect, C_VIEWPORT);
}
}
// bootstrap() wires this class into the engine: it creates an instance and runs the loop.
bootstrap(Demo);