Block maps
A block map draws a terraced terrain from rows of characters: each cell is a block of a block set, standing at a height in whole steps. defineBlockSet declares what each block looks like and is, and BlockMap draws the cells and stands a collider under them. Godot calls the same pieces a MeshLibrary and a GridMap, and Roblox builds its terrain from voxels too.
A block map is lit 3D geometry that writes depth and casts shadows, so it suits a tilted camera, such as a pixel diorama under Camera2D’s perspective mode. A flat 2D game’s ground is a tile map instead.
Declare a block set and draw a map
Section titled “Declare a block set and draw a map”Declare each block set once, at module scope, in a module the room and every page import, under a name of lower case words between hyphens. A map file finds the set by that name. This terrace has a stair down to a cobbled path and a river:
import { BlockMap, defineBlockSet } from "@spawnite/engine";
export const village = defineBlockSet("village", { blocks: { grass: { top: "#5f8f3e", side: "#6e5a46", lip: "#4f7a36" }, cobblestone: { top: "#8a8478", side: "#5a5650" }, riverbed: { top: "#6b6250", water: { flowSpeed: 1.5 } }, stairs: { top: "#9a9384", riser: "#4a463f", stairs: true }, tree: { top: "#2f5a2c", blocked: true }, },});
// At module scope, so the map spawns once.const legend = { g: "grass", c: "cobblestone", w: "riverbed", s: "stairs", T: "tree",} as const;const cells = ["ggggT", "ccscc", "wwwww"];const heights = [ [2, 2, 2, 2, 2], [1, 1, 1, 1, 1], [-1, -1, -1, -1, -1],];
export function Terraces() { return ( <BlockMap name="terraces" blockSet={village} legend={legend} cells={cells} heights={heights} sea={0.2} position={[-2.5, 0, -1.5]} /> );}The rows read north to south, and each character names a block through the legend. A dot is a void cell, with no block and no collider: a character spawned over one stands on a collider under it, such as a bridge’s deck, and an NPC with nothing under it is refused a body. heights gives each cell’s height in whole steps, negative allowed. position places cell [0, 0]’s north-west corner, as a tile map’s.
A block takes the following faces, each a plain colour or a pixel texture from definePixelTexture:
top: the cell’s top.side: the vertical faces under the top. Left out, the top’s.lip: the top metre of a vertical face, such as a grass band over a cliff. Left out, the side’s.riser: a stair’s tread fronts. Left out, the side’s.
What a map draws
Section titled “What a map draws”A cell draws a vertical face only toward a neighbour whose top stands lower, down to that neighbour’s top. At the map’s edge and beside a void cell, a face drops to the base, 2 m under the lowest top, which is the diorama’s cut.
A block with stairs climbs one step over its cell in four treads, each with a riser. It faces its one neighbour a step higher, or the way the legend gives, as ">": { block: "stairs", facing: BlockFacing.East }. A stair with no such neighbour, or with two, is refused with the cell’s column and row.
A block with water is a bed under water. Its surface stands at the legend’s waterLevel where given. Otherwise it stands at the map’s sea, in metres, and at least 0.35 m over the bed, so a pond on a plateau needs no setting. The water draws as pixel water, one mesh for each chunk and each water look, with its sides where the ground beside it stands lower. water takes the pixel water’s options, its colours, its glints and its flowSpeed among them, and that page says which way a block’s water flows.
The map draws one merged Lambert mesh for each 32 by 32 chunk and each face, its tops apart from its vertical faces. Tops cast no shadow and faces do. A texture face is laid in world space at its own texels per metre, so it runs on across every cell and chunk, and varies by the texture’s own variation. Declare a coursed wall’s texture and a lip’s with TextureVariation.None or Mirror, so its rows stay rows and its grass hangs down. A lip band’s texture starts at the face’s top, so the grass at its top row hangs from the top’s edge.
Dress the edges and the light
Section titled “Dress the edges and the light”A block’s fringe is a whole-number rank, 0 unless you set it. A block ranked above 0 dresses its top’s edges with its top’s own look, grass, sand or snow alike, cut ragged by one mask the engine paints:
- A brim hangs over every drop of 0.24 m or more, the map’s cut and a drop to a water bed included: from 1/16 m inside the edge to 3/16 m out, with a skirt down the face of 0.72 of the drop, never more than 7/16 m. The skirt covers the top of the lip band under it.
- A fringe spills 0.375 m onto each neighbour at the same height whose rank is lower, with rounded pieces at outer corners. It never spills onto a stair or a water bed.
Rank dark grass over grass over flowered grass, and grass over a path, as the reference ranks them 3, 2 and 1 over 0. Where two ranks spill onto one cell, the higher lies on top.
The map bakes its light for you, into its vertex colours; nothing here is yours to set:
- Each top is a lattice of 4 by 4 squares, shaded by how much of the sky the ground around it hides, out to 1.75 m. The shade turns toward blue, with a slow warm and cool tint, and darkens within 0.35 m of water. Where the light needs no lattice, the top draws as one square.
- Each wall darkens toward its foot over 1.15 m, and over 2.8 m on the map’s cut, at a corner where a neighbour stands in front of it, and at the water line.
The look’s wallBounce adds that share of the sun’s colour to every upright face, scaled by the sun’s height, as light thrown back off the ground. The diorama look sets 0.44, the reference’s; every other look sets none.
Grow ground cover
Section titled “Grow ground cover”A block’s cover lists what its top grows: upright cards, each a frame of a sprite strip, yawed to the camera, rooted at their bottom middle, darkened toward the root and swayed by the look’s wind. This green ranks its grass over the path a step below and grows tufts on both:
import { BlockMap, defineBlockSet, definePixelTexture, TextureVariation, type BlockCover,} from "@spawnite/engine";
// A lawn: two greens in a fine check.export const lawn = definePixelTexture({ name: "lawn", width: 64, height: 64, paint: ({ size: [width, height], color }) => { for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) color.setPixel(x, y, (x * 3 + y * 5) % 7 ? 0x4a7d3a : 0x77a345); },});
// Four tufts side by side, each 16 texels: a card a metre square.export const tufts = definePixelTexture({ name: "tufts", width: 64, height: 16, variation: TextureVariation.None, paint: ({ size: [width, height], color, alpha }) => { for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { // A blade every other texel, taller toward each frame's // middle, lit at its tip. const fromMiddle = Math.abs((x % 16) - 7.5); const tall = x % 2 === 0 ? 12 - fromMiddle : 0; const up = height - 1 - y; alpha.setPixel(x, y, up < tall ? 1 : 0); color.setPixel(x, y, up >= tall - 1 ? 0xb5cc52 : 0x3f7030); } },});
const grassCover: readonly BlockCover[] = [ { texture: tufts, columns: 4, perCell: [3, 5] },];
export const green = defineBlockSet("green", { blocks: { grass: { top: lawn, side: "#6e5a46", lip: "#4f7a36", fringe: 2, cover: grassCover, }, path: { top: "#a08a6a", side: "#6e5a46", cover: [{ texture: tufts, columns: 4, perCell: 0.12 }], }, },});
// At module scope, so the map spawns once.const legend = { g: "grass", p: "path" } as const;const cells = ["gggg", "pppp", "gggg"];const heights = [ [2, 2, 2, 2], [1, 1, 1, 1], [1, 1, 1, 1],];
export function VillageGreen() { return ( <BlockMap name="green" blockSet={green} legend={legend} cells={cells} heights={heights} /> );}Each BlockCover takes the following:
texture: a strip ofcolumnsframes side by side. A card’s size is its frame’s at the texture’spixelsPerMetre, 16 unless you set it, times itsscale, so a frame of 16 texels at 16 a metre is a card a metre square. Paint each texel’s alpha 0 or 1: the card cuts at half.perCell: a mean, where 0.05 is a 5% chance of one and 2.5 is two, and three half the time, or a range of whole numbers drawn evenly, such as[3, 5].castShadow: casts into the sun’s shadow map, each card turned to the sun. Leave it off for cover a player walks through, or the shadow pass pays for thousands of cards.windWeight: its share of the sway, from 0, which holds it still; the reference sways shrubs 0.5, tufts 1, flowers 1.1 and reeds 1.4.besideWater: grows only on a cell whose neighbour on one of its four sides holds water with its surface within 0.4 m of the cell’s top, above or below, as reeds do.frames: the frames a card is drawn from, each evenly, so a frame listed twice draws twice as often, such as a tinted tuft one time in four. Every frame once unless you set it.scale: each card’s scale, drawn evenly between two numbers above 0;[0.7, 1.05]unless you set it. One scale keeps the painted frame’s shape.rootDarkening: how dark a card’s root is, from 0 to 1, rising to its own colour over its lower 60%; 0.35 unless you set it. The reference darkens its grass 0.35 and its shrubs 0.25.patches: where the map’s patch field passesabove, from 0 to 1, a cell grows the patch’sperCellfrom itsframesin place of the cover’s own. Either left out takes the cover’s.zones: splits the map into zones aboutsizemetres across, 12 unless you set it, and each zone’s cells draw from one of itsframeslists, two or more. A meadow’s flowers then stand in drifts of one colour. Its lists replaceframesand a patch’sframes, sodefineBlockSetrefuses a cover that sets either beside them;patchesstill set the count.
Each cell’s cards come from a hash of the game’s texture seed, the cell and the strip’s name, so an edit to one cell moves no card outside it and its four neighbours, and every page grows the same meadow.
The patch field is one value noise a map, read at each cell’s middle, from the texture seed and the map’s name, so every cover’s patches gather in the same meadows: thick tufts, tall grass and flowers together. Its lattice is the set’s patchSize apart, 4.35 m unless you set it, and one value retunes every cover’s patches on the set’s maps. A cell outside every patch grows exactly what it would with no patches. A low above with a cover’s own perCell at 0 leaves clearings where the field falls under it. Two covers cannot clump apart on one map: a field of their own would lift that.
A zone is the square of a jittered grid whose point lies nearest a cell’s middle, and the point’s hash picks its list, so every list comes up about as often. The zones come from the texture seed, the map’s name and the strip’s name. They are the same whichever chunk reads them, and continue across every block whose cover draws that strip, such as from grass onto a meadow. Unity’s terrain details tint by a noise spread and Unreal’s grass types follow painted layers; neither zones a frame. A card keeps half its width in from a drop, so none hangs over a cliff, and a card too wide for its ledge is left out. A stair and a water bed grow none, and defineBlockSet refuses cover on either. The cards of one strip in one chunk draw as one mesh, and a room draws none. Shoot the cover from the game’s camera with spawnite play screenshot to judge its density.
Cover costs about 0.6 ms a frame on the diorama’s meadow, measured 2026-10-18 on an RTX 3070 from the game’s camera at the golden hour, cover on and off three times in turn. The frame took 7.18 to 7.40 ms without it and 7.76 to 7.99 ms with it. It added 25 draw calls and 11,376 triangles to 130 calls and 46,704 triangles. A second run the same day, under the look’s depth of field, measured no difference: 6.94 ms either way once the frame settled. No quality level turns it off.
An edit rebuilds its chunk and the chunks around it within the occlusion’s reach, on the main thread. On a terraced 96 by 96 map of 1 m cells, one edit at a chunk’s corner rebuilds nine chunks in about 0.6 s in Node, measured the same day: a hitch a game that digs will see.
Walk and collide
Section titled “Walk and collide”Each chunk is one Rapier voxel collider, a column of boxes for each cell, so a sliding body meets no seam between two cells. The boxes are a cell across and a step tall, the block set’s cellSize, [1, 0.5, 1] unless you set it.
A step of 0.5 m stays under the character’s default 0.6 m step height. So a character walks up a one-step ledge with no stair, and stops at a ledge of two steps. The navmesh bakes the same columns and routes the same way. Each stair is a convex wedge over its column.
A block with blocked stands a wall of colliderHeight over its top, 4 m unless you set it, rounded up to whole steps. Water is never solid, and a block is never both water and blocked. To stop a character at a river, line its banks with blocked cells.
Read a map file
Section titled “Read a map file”A map file holds a block map in its blockSet, the set’s name, and three fields of one string a row:
"blockSet": "village","cells": ["ggggT", "ccscc", "wwwww"],"heights": ["22222", "11111", "-1 -1 -1 -1 -1"],"legend": { "g": "grass", "c": "cobblestone", "w": "riverbed", "s": "stairs", "T": "tree" }A heights row is one lowercase base-36 digit a cell, 0 to z for 0 to 35 steps. A row with a height below 0 or past 35 is whole numbers between single spaces instead. The map file is refused before a room opens when its rows differ in width, or when a cell holds a character the legend does not name, with the row in the message.
<Landscape map="emberfall"> stands a block map file whole, centred on the origin, with its props and its navmesh, as Landscape says. To draw the rows alone, readBlockRows decodes them into BlockMap’s props: <BlockMap blockSet={village} {...readBlockRows(readMap("emberfall"))} />.
A map names greybox without declaring it: the engine’s own set of plain colours, as Roblox’s Baseplate is, to lay a level out before its blocks are painted. Its blocks are grass, dirt, stone, water, stairs and blocked, and findBlockSet(name) returns it or any set a module declared.
Find and change a cell
Section titled “Find and change a cell”findBlockMap finds a map by its name, and two maps of one name in a scene are refused. findBlockAt reads the cell under a point: its block, its legend character, its height in steps, the walkable top in metres and its water. setBlockCell changes a cell where outcomes are decided. The room streams the change to every page, and the collider and the navmesh rebuild only the column it touched. This dig lowers the cell under a point one step:
import { findBlockAt, findBlockMap, setBlockCell, type Authority, type World,} from "@spawnite/engine";import type { Vector3Like } from "three";
/** Digs the cell of the map `terraces` under `point` one step down. */export function digUnder( authority: Authority, world: World, point: Vector3Like,) { const terraces = findBlockMap(world, "terraces"); const cell = terraces && findBlockAt(terraces, point); if (!terraces || !cell) return; setBlockCell(authority, terraces, { column: cell.column, row: cell.row, height: cell.height - 1, });}setBlockCell takes a new symbol, a new height, or both, and a dot makes the cell void. A new column that meets a character’s capsule lifts the character onto its top in the same step, onto a stair’s tread under it. A character on a ground below the map stays. It writes nothing and returns a BlockCellRefusal for any of the following:
- A cell off the map, or a character the legend lacks.
- A height that is not whole, past the range, or at or under the base.
- A stair with no neighbour a step higher, or an edit that takes the step from a stair beside the cell.
- A pond whose water level would stand under its bed.
A change to BlockMap’s rows writes only the cells that changed, so a cell setBlockCell changed keeps its value. The base stays where the map spawned, and rows that sink a cell to it or under it throw, naming the cell.
Edit a map file
Section titled “Edit a map file”editBlockMapFile applies the map builder’s edits to a map file’s values, as edit_tiles and spawnite map tiles do, for a script or a tool that writes the file. Each edit is one gesture of a tool. Paint, Fill and Rectangle lay a block. Raise, Lower, Set, Flatten and Smooth change heights in whole steps. Stairs lays a stair or plans a flight up a cliff. The first edit refused refuses the whole list, naming its index and the fix, and the file stays as it was. This one raises a paved plateau with a stair up its side:
import { BlockTool, editBlockMapFile, type EditBlockMapFileOptions,} from "@spawnite/engine";
/** Raises a 3 by 3 plateau two steps in the middle of `map`, paves it in * stone, and lays a stair up its south side, as one write or none. */export function raisePlateau(map: EditBlockMapFileOptions["map"]) { const result = editBlockMapFile({ map, edits: [ { tool: BlockTool.Raise, cells: [[3, 3]], brush: { size: 3, shape: "square" }, steps: 2, }, { tool: BlockTool.Rectangle, from: [2, 2], to: [4, 4], block: "stone", }, // Its foot on the grass a step under the plateau's edge. { tool: BlockTool.Lower, cells: [[3, 4]] }, { tool: BlockTool.Stairs, cell: [3, 4], facing: "north" }, ], }); if (result.refusal !== null) throw new Error(result.refusal); return result.rows;}In the devtools, the Map section’s map builder runs the same edits by hand, as Editing while playing says.
A game’s own editor, such as a build mode in play, edits rows it holds in memory. editBlockRows applies one edit to an EditableBlockRows, and beginBlockStroke runs a gesture a drag at a time: extend joins a brush’s cells, or moves a rectangle’s or a flight’s end, until end keeps it. Each result’s change holds the rectangle before and after, which restoreBlockChange puts back for an undo. resizeBlockRows and resizeBlockMapFile resize about an anchor, and requireBlockMapRows throws, naming the cell, for rows the set refuses.
Read what changed
Section titled “Read what changed”A system that keeps its own copy of the ground, such as a placement of grass on the tops, reads what changed rather than the whole map. createBlockCursor marks the map as it stands, and forEachBlockChange calls you with each rectangle of cells written since, once each. It returns false when the record of changes no longer reaches back, and you read the whole map again. readBlockTriangles gives the walkable tops and treads of a rectangle as world triangles.
The values and the other engines’
Section titled “The values and the other engines’”| Value | Spawnite | Luminati | Others |
|---|---|---|---|
| Cell | 1 by 1 m | 1 by 1 m | Unity’s Grid 1 m, Godot’s GridMap 2 m, Roblox 4 studs |
| Step | 0.5 m | 0.5 m | Minecraft 1 m |
| Chunk | 32 by 32, drawn per chunk | 32 by 32 over 64 cells, one chunk under; drawn in batches of up to 48,000 triangles over 48 m | Godot’s octants 8, Minecraft 16, the tile map 16 |
| Stair | 4 treads | 4 treads | none built in |
| Water over a high bed | 0.35 m | 0.35 m | none built in |
| The edge’s cut | 2 m | 2 m | none built in |
| A blocked cell’s wall | 4 m | none | the tile map’s colliderHeight 4 m |
| Top occlusion | 4 by 4 lattice, 1.75 m | 4 by 4 lattice, 1.75 m | Unity and Unreal bake lightmaps; Minecraft shades corners |
| Wall foot | 0.58 over 1.15 m | 0.58 over 1.15 m | none built in |
| Fringe and brim | 0.375 m; 3/16 m out | 6 texels; 3 texels out, up to 7 down | none built in |
| Wall bounce | the look’s, 0.44 diorama | 0.44 | global illumination |
| Cover on a grass cell | the diorama game’s lists | 3 to 5 tufts, up to 3 flowers | Unity detail density, Unreal grass varieties |