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.

TypeScript
// 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] },
  },
})
TypeScript
// 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 — each index or [index, weight], bare indices weigh 1) plus transition sets under over, keyed by the neighboring material's name ('*' = anything else, empty included): edges [n, e, s, w], outer corners and inner (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. With size the line is that many cells ACROSS and every piece is a size² block — a 2-lane road is size: 2, its cross being 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:

TypeScript
// 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 take fill[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:

TypeScript
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.
  • axis is the run the art is drawn for ('v' / 'h'), and it picks the tile: an element varies ACROSS the line and repeats along it, so tiles is size long — 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:

TypeScript
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.

TypeScript
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 wrong
  • rot90 / rot180 / rot270 turn clockwise, flipX mirrors horizontally; they compose (group math — rot90(rot90(t)) is rot180(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 R to rotate / F to mirror (or the toolbar buttons) — the file gains the canonical call, e.g. rot90(flipX(187)). In the scene editor: R/F reorient a single-tile custom brush.

The same value, built and read field by field:

TypeScript
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 all

packTile(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:

TypeScript
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).