Tilesets (.tiles.ts) and Autotile2D
A tileset file names the materials of a tile atlas — autotile rules that turn a map painted
with material ids (grass, dirt, road) into atlas tile indices, edges and corners picked for you.
A map of plain atlas indices needs none of this: that is a Tilemap on its own.
- auto-pick the edge and corner tiles where grass meets dirt or water
- draw roads, fences and walls whose pieces connect by themselves
- scatter random tile variants over a ground area
- build a two-lane road or a ground pattern that repeats every 2×2 tiles
- place a crossing in a road or a gate in a fence
- put a one-off atlas tile into an autotiled map
- reuse one tile turned or mirrored instead of drawing a new one
- generate a level in code with the same autotile rules
Platforms: iOS, Android, desktop, the web player and the le.codes viewer app; not on the web
(Web Lite), which has no Tilemap to draw the result — defineTileset, Tileset.derive and the
tile helpers are plain TS and run there too; headless tests derive the tiles but never draw them.
At a glance
One file per atlas, export default defineTileset({...}); the visual tileset editor
(lecodes-2d-editor) reads and writes these files, and the scene editor's Tiles tool paints with
the materials.
// world.tiles.ts
export default defineTileset({
image: asset('./TileSet.png'),
tile: 32, // atlas cell size in image pixels
materials: {
grass: { id: 1, kind: 'patch', color: '#5a9e58', fill: [130, [45, 0.05], [48, 0.006]] },
dirt: {
id: 2, kind: 'patch', color: '#a3824c', fill: 135,
over: { '*': { edges: [71, 136, 199, 134], outer: [72, 200, 198, 70], inner: [192, 64, 9, 196] } },
},
fence: { id: 3, kind: 'path', isolated: 377, ends: [187, 121, 251, 122], straights: [379, 378], corners: [249, 57, 58, 250] },
},
})// meadow.scene2d.ts — the tilemap block never changes shape; the ASPECT flips the meaning of
// the same cells('…') payload from raw atlas indices to material ids (here: grass around dirt)
import world from './world.tiles'
export default defineScene2d({
nodes: {
ground: {
tilemap: {
texture: asset('./TileSet.png'), tile: 32, atlas: [64, 64],
cells: cells('BgAAAAQAAAAHAAAAAQAAAAQAAAACAAAAAgAAAAEAAAAEAAAAAgAAAAcAAAABAAAA'),
},
aspects: [use(Autotile2D, { tileset: world, seed: 7 })],
},
},
})The loader derives display indices before the Tilemap is constructed — deterministic
(neighbor-mask autotile + per-cell seeded scatter), so the same map renders identically on every
platform, and derived indices are never written to a file.
Ground edges and connecting roads: patch and path materials
A material picks its own tiles from its neighbors: a patch (grass, dirt, water) draws its edges
and corners where it meets something else, a path (road, fence, wall) connects to the cells of
its kind around it. The two kinds differ only in their neighbor classifier:
kind: 'patch'— area materials (grass, dirt, water, platformer ground). Classifier = the 8-neighbor blob mask. Slots:fill(one index, or an array of scatter variants — eachindexor[index, weight], bare indices weigh 1) plus transition sets underover, keyed by the neighboring material's name ('*'= anything else, empty included):edges[n, e, s, w],outercorners andinner(concave) corners[ne, se, sw, nw].kind: 'path'— line materials (roads, fences, walls, rails). Classifier = the 4-neighbor mask:isolated,ends[n, e, s, w](the side the piece connects toward),straights[vertical, horizontal],corners[ne, se, sw, nw],tees[n, e, s, w](the tee MISSING that side),cross. Withsizethe line is that many cells ACROSS and every piece is asize² block — a 2-lane road issize: 2, itscrossbeing the four curb elbows of the junction.
Missing slots fall back to simpler ones (an unmatched corner case renders as an edge, then
as fill), so partial sets always render — -1 inside a slot array means "unassigned". Cells
outside the map count as the same material (patches continue off-map). Map values that match no
material id pass through as raw indices; -1 stays empty.
Two-lane roads and 2×2 patterns: size
Lots of art has a pattern PHASE — herringbone that only lines up as a 2×2, road tiles whose lane
dashes alternate every second tile. size: 2 (or 3) puts a material on a size×size block
lattice anchored at the map origin. For a patch it is the pattern block; for a path it is
the width of the line:
// a 2-lane road: every piece is a 2×2 block, so painting a one-block line gives curbs,
// lane divider and proper junctions
road: {
id: 1, kind: 'path', size: 2,
straights: [[10, 11, 26, 27], [72, 73, 88, 89]], // [vertical, horizontal]
tees: [[72, 73, 90, 91], [74, 11, 90, 27], [74, 75, 88, 89], [10, 75, 26, 91]],
cross: [74, 75, 90, 91],
}
// a patch on the same lattice: fill is size² tiles, each side `size` tiles, corners one cell
sidewalk: { id: 2, kind: 'patch', size: 2, fill: [14, 15, 30, 31] }- Classification runs block to block; inside a block each cell picks by its position. Patch:
corner cells take the corner tile, edge rows/columns take
edges[dir][along-index](n/s run left→right, w/e top→bottom), the rest takefill[ly * size + lx]. Path: the chosen piece's block is read the same way. - Any slot may stay a single index — it then covers every cell of its block/side.
- Scatter variants are whole blocks (
fill: [[a, b, c, d], [[e, f, g, h], 0.2]]) and roll once per block, so a pattern never tears. - Painting snaps to the lattice in the editor. For a patch, a one-block-wide strip carries both of its edges by construction.
size: 1(the default) is the ordinary one-tile-per-cell material.
Crossings and gates inside a road or fence: parts
A pedestrian crossing in a road, a gate in a fence: art that belongs to a material but is placed by hand, never picked by a rule. Each element carries its own id, so the map stores it like any material — but it connects as its parent, so the run continues straight through it:
road: {
id: 1, kind: 'path', size: 2,
straights: [[10, 11, 26, 27], [72, 73, 88, 89]],
parts: {
crossing: { id: 4, axis: 'h', tiles: [76, 92] }, // one tile per LANE
crossingNS: { id: 5, axis: 'v', tiles: [106, 107] },
},
}- Elements are painted one cell at a time — no block snapping, so a crossing can sit anywhere along the road and be as wide as you drag.
axisis the run the art is drawn for ('v'/'h'), and it picks the tile: an element varies ACROSS the line and repeats along it, sotilesissizelong — indexed by row for'h', by column for'v'. Without an axis it is a plain size² block instead.- Unassigned entries (
-1) fall back to the material's normal piece, so a half-authored element never blanks out. - Ids live in the same number space as materials: unique across the whole tileset.
- In the editor they appear as swatches under their material in the Tiles palette, and as the
Elements section of the material inspector (
+adds one).
A block counts as belonging to a material when any of its cells does, so a run painted or trimmed cell by cell still connects — the brush's block snapping is a convenience (a toggle in the palette), never a constraint on the data.
One-off atlas tiles in a material map: rawCell
A cell can also hold a plain atlas tile, for one-off art that deserves no rule. Because a material
map stores intent, raw tiles live below -1 so they can never be confused with an id:
rawCell(index: number): number // the cell value that draws atlas tile `index` verbatim
isRawCell(value: number): boolean // value <= -2
rawIndex(value: number): number // the atlas index behind a raw cell (guard with isRawCell)
data[i] = rawCell(137) // -139 — draws atlas tile 137 verbatim-1 is still empty, 0…n are material and element ids, and anything <= -2 is a raw tile. In
the editor this is the custom tile button: it opens the whole atlas on a pan/zoom surface, and
one click arms that tile as the brush. Raw cells take no part in autotiling — neighbouring
materials simply see "not me" and draw their edge.
Rotate or mirror a tile: rot90, flipX, packTile
Any tile value — a slot, a raw cell, a plain index in a raw map or a Tilemap's
data — can carry an orientation packed into the same int, so an incomplete atlas never forces
an image edit: one drawn edge serves all four sides.
fence: {
id: 3, kind: 'path',
straights: [379, rot90(379)], // one straight, turned for the other axis
ends: [187, rot90(187), rot180(187), rot270(187)], // one end cap, all four ways
corners: [57, rot90(57), rot180(57), rot270(57)],
}
edges: [652, flipX(rot270(652)), rot180(652), rot270(652)] // a mirror when turning looks wrongrot90 / rot180 / rot270turn clockwise,flipXmirrors horizontally; they compose (group math —rot90(rot90(t))isrot180(t), all 8 orientations of a tile are reachable).- Bits 0–27 hold the index, bit 28 the mirror, bits 29–30 the turns — the value stays a positive
int, so it flows through
cells('…'), the deriver and the engine unchanged (renderers permute the atlas cell's UVs; same quads, same batches, zero cost). - Rotation is only artistically right for symmetric art — a shaded edge rotates its highlight too. That is why orientation is per-slot and manual, never automatic.
- In the tileset editor: arm a slot, press
Rto rotate /Fto mirror (or the toolbar buttons) — the file gains the canonical call, e.g.rot90(flipX(187)). In the scene editor:R/Freorient a single-tile custom brush.
The same value, built and read field by field:
packTile(index: number, turns = 0, flip = false): number // mirrored first (flip), then `turns` quarter-turns clockwise
tileIndex(value: number): number // the atlas index (bits 0–27)
tileTurns(value: number): number // clockwise quarter-turns, 0–3
tileFlip(value: number): boolean // mirrored horizontally?
tileOriented(value: number): boolean // carries any orientation at allpackTile(i) with no orientation is i itself, and rot90(t) is
packTile(tileIndex(t), tileTurns(t) + 1, tileFlip(t)).
Autotile a map built in code: Tileset.derive
The Tileset handle (what defineTileset returns, the default import of a .tiles.ts file):
def, materialNames, material(name), id(name) — the value a map stores for a material —
and derive(cols, rows, data, seed?), the rules Autotile2D applies at load, for a procedural map
handed to a Tilemap directly:
import world from './world.tiles'
async function main() {
const cols = 8, rows = 6
const grass = world.id('grass'), dirt = world.id('dirt')
const ids = new Int32Array(cols * rows).fill(grass)
for (let x = 2; x < 6; x++) ids[3 * cols + x] = dirt // a dirt strip on row 3
const scene = new Scene2D()
const map = new Tilemap({
texture: await Texture2D.load(asset('./TileSet.png')),
cols, rows, tile: 32, atlas: [64, 64],
data: world.derive(cols, rows, ids, 7), // material ids → atlas tiles
})
scene.add(map)
scene.open()
}
main()Layering and seeds
A map is either materials (carries Autotile2D) or raw — never mixed; put transparent path
materials (fences) on their own tilemap node above the ground so the grass stays visible under the
rails; seed reshuffles scatter variants (same seed → same map).