Docs / Building a world · v0.117.0

Tilemaps

Tilemaps

Everything for building a 2D level: the ◫ Tiles tab, tilesets (what each tile is), autotiling, collision, and the Lua surface a game reads it all through.

New here? The three-minute version:

  1. Add a ▩ Tilemap node (◫ Tiles ⏵ Add layer, or the Hierarchy's Add menu).
  2. Drag your spritesheet from Assets onto ◫ Tiles ▸ TILESET, or pick it from the choose an image… list there. That makes the layer's tileset, cut into tiles for you: by the grid you drew it against in the 🖼 Image tab, a cell size in its name (tiles_16x16.png), or failing those its size. Check the cut on the SHEETS row and change cols / rows if it guessed wrong.
  3. Click a tile in PALETTE, press 9 for the tile tool, and paint in the ⌖ Scene view.
  4. For collision or autotiling, select tiles in the palette and set them under TILE and AUTOTILE.

1. A layer is a node

There is no layer list to learn. A tile layer is a Matter::Tilemap node, so it already has a transform, a material, a name, a visibility flag and a place in the Hierarchy — and everything that moves, hides, renames, parents, duplicates or deletes a layer is the ordinary node operation you already know.

The ◫ Tiles tab's layer list is a view of the scene's tilemap nodes. Which means the Hierarchy and the Tiles tab can never disagree about whether a layer is showing, because there is only one switch.

Parallax and depth come from the transform: Add layer puts each new layer 0.1 units in front of the last. (Not at the same Z — two layers on one plane make the depth test pick arbitrarily between them and the map shimmers as the camera moves.)

2. The sheet is the material

A tilemap draws from its node's ordinary Material: the texture, and sheetCols × sheetRows for how it is cut. So a tilemap is dressed exactly like every other surface — same texture picker, same filtering, same custom .flsl shader if you want one — and a project does not learn a second way to say "this texture, chopped this way".

A layer whose tileset names an image of its own draws that image instead, so a spritesheet dropped on the ◫ Tiles tab needs nothing set on the material. The material is what a layer draws when its tileset has no image, or it has no tileset.

Cell indices are row-major from the top-left of the sheet, starting at 0.

New layers are created unlit, because a 2D layer lit by the scene's sun is a 2D layer that goes dark at night.

One mesh, no seams

The whole grid is one mesh, one draw call. That is not only a performance choice. A tilemap built from one quad per tile has a hairline of background between tiles that opens and closes as the camera moves: each quad's edge is computed through its own transform, so two touching edges land either side of a pixel boundary independently. Here every tile's corners come from the same expression, so a shared edge is bit-identical on both sides and there is no gap to show through — at any zoom, from any camera position.

3. Painting

Press 9 (or pick ◫ in the viewport tool strip) and paint in the ⌖ Scene view. The ◫ Tiles tab opens with it.

Tool Key What it does
✏ Brush B Paint the current tile; drag for a stroke
✖ Erase E Clear squares; drag to erase a stroke
▬ Rectangle R Drag a filled rectangle
▭ Frame F Drag a rectangle outline — a room's walls in one gesture
╱ Line L Drag a straight line
◍ Fill G Flood-fill the connected region under the pointer
◉ Pick I Take the square under the pointer as the current tile
▢ Select S Drag out a rectangle to copy, move, turn or clear
✚ Move M Drag the selection somewhere else

Those letters are the tile tool's while it is held. Two of them (F, G) are the editor's frame-selection and grid toggle everywhere else; switching tools hands them straight back, and the Tiles tab has its own Grid checkbox.

Undo is ordinary undo. A tilemap's squares are scene state, so a stroke is one Ctrl-Z — the same Ctrl-Z as everything else, with no separate tile history to get out of step with the scene's.

The fill is four-connected, so it does not leak through a diagonal wall.

Room to paint

A layer grows as you paint past its edge. The brush, the rectangle, the frame and the line all make room for themselves, and nothing already placed moves. Untick GRID ▸ Grow as I paint for a level that is a set size.

To set the size yourself, change size under GRID and press Apply size. Every tile stays where it is in the world: new columns go on the right and new rows on the bottom, or on the left and top by the number in anchor. Fit to tiles shrinks the layer to the squares that hold a tile, again without moving any.

Multi-tile brushes

Drag a rectangle in the palette and the brush becomes that whole block. Painting places all of it at once — a whole doorway, a whole tree.

Select ▢ a region of the map, press Copy, then Paste: the clipboard becomes the brush, so the next click puts it exactly where you aimed. (Pasting straight into the map at a remembered position is the version that needs an undo to correct.)

Turning and mirroring

↺ ↻ ⇔ ⇕ turn the selection when there is one, and the brush when there is not. Same buttons, because "turn this" is one idea.

With the tile tool out, the keys do the same while you draw:

Key Does
, a quarter-turn anticlockwise
. a quarter-turn clockwise
[ mirror left to right
] mirror top to bottom

So one corner piece, drawn once, paints all four corners of a room.

A square tile has exactly eight placements: four rotations, each optionally mirrored. That is why the engine stores an orientation as (rot, flipX) rather than three independent booleans — a vertical flip is not independent, it is a horizontal flip composed with a half-turn. flipY is still accepted everywhere you might write it; it composes to the same eight states.

Rotating a multi-tile stamp turns both the layout and each tile in it. (Only turning the layout is the bug that makes a rotated pipe corner point the wrong way — invisible until the art is directional, which is exactly when you want to rotate it.)

The orientation rides in the tile value itself, in bits above the cell index. A map written before orientations existed is a list of bare indices and means exactly what it always did.

4. Tilesets — what a tile is

A tileset says what each cell of a spritesheet means: whether it collides, what it is tagged, which autotile shapes it draws, whether it animates. It lives at <project>/tilesets/<name>.tileset.ron and is shared by every layer cut from that sheet.

Drop an image on ◫ Tiles ▸ TILESET for a layer that has none, and its tileset is made from that image. If the project already has a tileset for that image, the layer uses it rather than making a copy. + Empty tileset makes one with no image yet, to give it one on its SHEETS row; on a layer whose material already has a spritesheet, + New tileset uses that.

More than one sheet on one layer

A tileset can carry several images. ◫ Tiles ▸ TILESET ▸ SHEETS ▸ + sheet adds one; give it an image and its cols/rows, and the palette grows a tab per sheet. Everything you place from any tab lands on the same layer.

That matters more than it sounds. Splitting a level across three tilemap nodes — one per sheet — is not a workaround, it changes what the level is: a wall on one node is not a neighbour of a wall on another, so nothing autotiles across the join, the collision merge stops at it and leaves a seam, and every grid tool (bucket, rectangle, stamp, retile) stops there too. One layer, several sheets, and all of that keeps working across the whole map.

Drawing costs one call per sheet the layer actually uses — bounded by how many images a level has, not by how many tiles.

Adding art never renumbers what you already placed. Each sheet owns a fixed block of the cell index, so growing sheet 0 from 4×4 to 16×16 leaves every square placed from sheet 1 meaning exactly what it did. Removing a sheet is offered only for the last one, for the same reason.

A project that has never added a sheet is unaffected in every particular: there is one implicit sheet, it is the layer's own material, and every index ever written is on it.

Why a tileset and not per-square data. Solidity is a property of the ART. A brick is solid everywhere a brick appears. Recording it per square means the answer is stored hundreds of times, a level built before you decided bricks were solid keeps the old answer, and marking one more tile solid becomes a job of repainting the level rather than ticking a box. With a tileset, tick "solid" on the brick and every brick in every scene collides — including the ones already placed.

The palette is the tileset editor

Click a tile in the palette and its properties are right underneath. The palette also draws what it knows, over the art:

  • a solid tile gets a collision overlay in the shape of its collider — so a half-tile collider looks like a half tile, not like a tick;
  • an autotiled tile gets a 3×3 neighbourhood diagram of a shape it draws, and +2 beside it if it draws more than one;
  • an animated tile gets a ▷.

Collision

A tileset made by dropping a spritesheet on the tab already has collision, worked out from each tile's pixels:

  • a tile that is almost all art is a solid square;
  • a tile whose art sits on its bottom edge with an uneven top is a slope that follows that top;
  • a tile with a little art somewhere else gets a box around it;
  • a tile that is nearly empty is walk-through.

Change any of them under TILE, or select tiles and press fit to the art to work them out again.

Five cases per tile: none, full, half (top / bottom / left / right), a hand-set rect in the unit tile, or a shape you draw yourself.

The half is named in the tile's own art, so it turns with the tile: the bottom half of a tile rotated a quarter-turn clockwise is its left half. (That is why the side is stored rather than a y = 0.5 — "the top half" survives a rotation and a number does not.)

Editing several tiles at once

Everything under TILE applies to every selected tile. Drag a box in the palette, or ctrl-click to pick out tiles that are not next to each other, and set the collision once. A selection whose tiles do not currently agree says so rather than showing you one of them.

Tiles being edited get a blue ring and a corner tick; the tile the brush will place keeps the orange one. They are the same after an ordinary click and stop being the same the moment you ctrl-click a second tile.

Slopes

Pick shape and draw the collider on the tile: click an edge to add a point, drag one to move it, right-click to remove it. The four 45° ramps are one button each. Points snap to the art's own pixel grid, which is the part that matters — a slope is built from several tiles whose diagonals have to meet, and a ramp ending a third of a pixel above where the next one begins is a character catching on every tile boundary for a reason nothing on screen shows.

It collides as the shape you drew, not as the box around it. Concave outlines are fine. A drawn shape turns and mirrors with its square like every other collider, so one ramp tile serves all four diagonals.

Colliders are merged

Solid squares become as few boxes as the shape allows. A 100×100 solid floor is one box, not ten thousand.

That is not an optimisation, it is what makes tile collision usable. Ten thousand static colliders is more than most whole 3D levels have, and the sim rebuilds its broadphase index over all of them. And the second reason matters more in play: a character sliding along a row of separate boxes catches on the seams between them — each box's face is its own plane, and at a shallow angle the depenetration pass ticks across each boundary. One merged box has no interior seams. This is the classic 2D-platformer bug and merging is the classic fix.

Tick Collision in the tab to draw the merged boxes over the map. They are the same boxes the sim gets, so what you see is what a character walks on.

A layer collides when its Collidable switch is on (Inspector ⏵ Collider), and it uses the node's own physics layer. Making a tile solid in the tab turns Collidable on for every layer that draws from that tileset, so you do not have to. A RigidBody on a tilemap means the same thing: its tiles are solid, and the map never moves. A tilemap with solid tiles that is not Collidable still warns in the Console when you press Play, naming the node.

One-way platforms

Tick one-way under TILE and the tile is solid only from above: a character jumps up through it from below and lands on its top. It works with any collision shape, so a thin plank ledge stays thin. Above is the layer's up, whichever way an individual tile is turned. One-way tiles never merge with solid ones, so a ledge beside a wall does not make the wall one-way.

To drop down through one, set node.dropThrough = true on the character's body (or a number of seconds; true is a quarter second). The Pixel Platformer does it when Jump is pressed while holding down:

lua
if node.grounded and my < -0.5 and input.justPressed("Jump") then
  node.dropThrough = true
end

Solid tiles ignore it. A body that has started through a one-way tile always finishes passing through it, so a jump that does not clear a ledge falls back down rather than snapping up onto it.

Tags

Free strings a script reads — "ice", "water", "damage", "ladder". This is how a tilemap carries gameplay without the game keeping a second table keyed by cell index, which goes stale the moment the artist reorders the sheet.

Animated tiles

Give a tile a list of extra frames and a rate and every square using it animates together — torches, water, conveyors. Frame 0 is the tile itself.

Animation is a view of the map: the frame is never written back to the grid, so a saved scene does not record whichever moment you happened to hit Ctrl-S on. It runs in the editor as well as in Play, so you can see a torch flicker while you are placing torches.

The mesh rebuilds only when the frame it shows changes. A map with nothing animated in it is never rebuilt at all.

5. Autotiling

An autotile group is a set of tiles that pick themselves by what is next to them: paint a shape and the corners, edges and ends appear.

  1. In ◫ Tiles ⏵ AUTOTILE, add a group — Edges (16 tiles) or Blob (47 tiles).
  2. In its RULES grid, click a shape, then click the tile that draws it. The next empty shape arms itself, so filling a preset is one run of alternating clicks.
  3. Click any of the group's tiles in the palette and paint.

If your sheet happens to be laid out in the preset's own order you can skip step 2: drag out the run of tiles and press Assign preset to N selected.

Edges uses the four orthogonal neighbours: a path, a wall run, a pipe network. Blob uses all eight with the corner rule, which is what you need for terrain that has inside corners as well as outside ones.

One tile, many shapes — one shape, many tiles

Both directions are ordinary, and neither costs you anything.

A tile can draw as many shapes as you like. One plain fill square is usually the answer to several neighbourhoods, and a sheet where the artist drew a single inside corner rather than four wants that one tile in four shapes. Click the shapes and click the same tile each time; nothing is moved off anything.

A shape can hold as many tiles as you like. They are variants — four grass squares that all mean "surrounded" — and which one a square gets is decided by where that square is, so a field varies without any two loads of the level disagreeing. A shape with alternates is marked ×N in the grid; arm it to see them all, and click one to take it back off.

Listing the same tile twice makes it twice as likely, which is how you get a rare flower without drawing nine plain squares.

The next shape after the first tile checkbox is what makes both work: it advances only when a shape goes from empty to having something, so a second click stays on the shape you are looking at. Turn it off to stay put entirely.

Assign preset understands variants too. Select a whole multiple of the preset's length — 32 tiles for the 16-shape preset — and it reads them as pass after pass, giving every shape two.

About the presets

Every tool has a different idea of what order a 16- or 47-tile sheet is laid out in, and none is more correct than the others. A preset that guesses your artist's order and gets it wrong produces plausible wrongness — it tiles, it just tiles with the wrong corners, and it reads as bad art rather than a wrong table.

So: the presets here are ascending mask order, stated rather than named after a tool, and every shape is drawn as a 3×3 diagram — in the RULES grid, and on the palette beside any tile that answers one. If a preset guessed wrong you can see which tiles disagree, and pointing a shape at a different tile is two clicks. That is what makes offering a guess safe.

Masks are one bit per neighbour, clockwise from north:

  NW  N  NE          128   1   2
   W  ·  E     =      64   ·   4
  SW  S  SE           32  16   8

North is up on screen, which for a tilemap means row − 1 (the grid is row-major from the top-left).

For a Blob group a corner bit only counts when both of its adjacent edges are also set. Without that rule there are 256 combinations and an artist would have to draw all of them; with it there are 47, and the ones that fall away are exactly the ones that look identical anyway.

Two rules worth knowing

Off the map counts as filled. A shape painted to the edge of the grid does not grow a coastline against the border — the map ends there, it is not a hole. (Get this wrong and every level's outer wall comes out edged.)

A half-drawn group leaves holes, never erases. If a group has no tile for a neighbourhood, that square is left exactly as painted. The tab shows "12 of 47 drawn" so an incomplete group is visible rather than something you discover as a gap in a level.

Groups that meet

Put two groups in each other's joins and their tiles count as "the same stuff" for masking — so grass and dirt can meet without either growing an edge against the other. Joins are one-way by default (a cliff can edge against sky without sky edging against the cliff); the tab sets both sides when you tick them.

Retiling

Retile as I paint is on by default. Retile all fixes a whole layer after you change the rules.

A retile always covers the painted region plus a one-square ring — painting a square changes what its neighbours should draw, so anything narrower leaves a seam of stale edge tiles exactly one square wide around every stroke.

A square you turned by hand keeps its orientation through a retile.

A square's variant is decided by where the square is, not by a random number, so retiling the same map twice gives the same result — and so does opening it tomorrow, or on another machine. A level that reshuffled its own grass on load would be unusable.

Isometric and hexagonal maps

A tileset's grid, in the Tiles tab, is the shape of its cells: square, isometric diamonds, hex (pointy top) with odd rows pushed half a cell right, or hex (flat top) with odd columns pushed half a cell down. It belongs to the tileset because it belongs to the art: a sheet drawn as diamonds makes every map painted with it lay its cells out as diamonds.

Everything that knows about cells follows the shape. The map draws its tiles in their cells, the cursor picks the cell it is over, and collision and 2D shadows use each solid cell's own outline: a diamond or a hexagon, never the square it was drawn on in the palette. A part-tile collider drawn on that square is placed on the cell the same way the art is.

cell height is a cell's height over its width: 0.5 for the usual 2:1 isometric diamond, and a regular hexagon's for hex. Art taller than its cell, such as an isometric block or wall, stands on the bottom of its cell and rises out of the top, and the map draws back to front so a nearer block covers the one behind it. Leave the cell height alone for tall art; it is the floor's shape.

Two things stay square: autotiling still looks at the four or eight neighbours of a square grid, and a turned or mirrored tile turns its art on its quad.

6. The orthographic camera

Under perspective, a tilemap two units further back is drawn slightly smaller — so a parallax layer changes scale as well as speed, and two layers cannot be lined up. An orthographic camera draws everything at the same scale at every distance, which is what 2D wants.

In the game: select a Camera node and set projection to orthographic, and height to how many world units the shot covers top to bottom. (With 1-unit tiles that is how many tiles tall it is; the width follows the viewport's aspect.) fovY is unused when it is orthographic, and the Inspector hides it rather than leaving a knob that does nothing.

From Lua:

lua
find('Cam'):setCamera{ projection = 'orthographic', orthoHeight = 12 }

It switches at runtime, either way, as often as a game likes: a builder's front, side and top views are one camera flipped to orthographic and back. The Camera component reads and writes the same names, so a script can ask which it is:

lua
local cam = find('Cam'):getComponent('Camera')
if cam.projection == 'perspective' then
  cam.projection = 'orthographic'   -- or 'ortho' / '2d'; reads back 'orthographic'
  cam.orthoHeight = 12
end
cam.active = true                   -- the view is this camera's; every other one turns off

In the editor: ⌖ Scene ⏵ ▦ View ⏵ Orthographic. Picking a plane lock (Front / Side / Top) turns it on as well, since squaring the view to a plane is almost always the 2D intent — and it is still a separate checkbox, because an orthographic free view is a real thing to want.

Under an orthographic view the wheel zooms the view height instead of moving the camera. It has to: moving forward changes nothing you can see when the view is the same size at every distance, so the wheel would simply appear dead. Panning scales with the zoom, so a drag tracks the pointer at any height.

A camera gizmo draws a box for an orthographic camera rather than a pyramid — the frame really is the same rectangle at every distance, and a gizmo that contradicts the projection is worse than no gizmo, because people frame shots by it.

Render targets have the projection too. A minimap is very often the one place a perspective game wants an orthographic shot:

lua
find('MapEye'):setCamera{
  target = 'minimap', width = 256, height = 256, hz = 10,
  projection = 'orthographic', orthoHeight = 80,
}

7. From Lua

lua
function start(node)
  node:setTilemap{ cols = 40, rows = 22, tile = 1.0,
                   tileset = 'tilesets/cave.tileset.ron' }
  local tm = node:tilemap()
  tm:fill(-1)                       -- -1, nil and EMPTY_TILE all mean "empty"
  tm:fillRect(0, 20, 39, 21, 3)     -- a floor
  tm:set(5, 19, 7, { rot = 90 })    -- one turned tile
  tm:autotile(0, 0, 39, 21)         -- let the group pick its own corners
end

The grid

Call
tm:set(x, y, cell [, xform]) One square, 0-based from the top-left. Outside the grid is a no-op, not a wrap. It changes the picture, the queries below and the collision: while playing, a tile set solid collides from the next frame, and one cleared stops colliding.
tm:get(x, y) The cell, orientation stripped — nil outside the grid and on an empty square.
tm:at(x, y) cell, rot, flipX — the whole answer, for art that faces a direction.
tm:fill(cell [, xform]) Every square, including the empty ones.
tm:fillRect(x0, y0, x1, y1, cell [, xform]) Corners in either order, clipped to the grid.
tm:size() cols, rows.
tm:tileSize() The world edge length of one square.
tm:resize{ cols =, rows =, offsetX =, offsetY = } Keeps whatever overlaps. offsetY = 1 grows a row on top.

xform is { rot = 0|90|180|270, flipX = bool, flipY = bool }. rot is degrees clockwise and must be a multiple of 90 — 45 is not one of the eight things a square tile can be, and rounding it to 0 would place the tile unturned with nothing said.

World space

lua
local x, y = tm:cellAt(player.worldPos)     -- which square is the player on?
if x and tm:hasTag(x, y + 1, 'ice') then ... end
local p = tm:worldAt(x, y)                  -- that square's centre, in the world

cellAt goes through the tilemap node's own transform, so a map that has been moved, turned or scaled still answers correctly. This is the part worth using rather than reimplementing: a Lua copy divides by a tile size and cannot see the node's transform, so it is right until the day the map moves.

worldAt gives the centre of the square, because what you do with it is put something on the tile.

Both follow the tileset's grid, so on an isometric or hexagonal map they answer in diamonds or hexagons. tm:path still steps between square neighbours.

What a tile is

Call
tm:solid(x, y) Does the tileset say that square collides? false with no tileset.
tm:oneWay(x, y) Is that square a one-way platform, solid from above only?
tm:tags(x, y) Its tags, as a list.
tm:hasTag(x, y, "ice") The common case, without a table per square — what a per-frame ground check should call.
tm:tileset() The .tileset.ron path, or nil.
tm:autotile(x0, y0, x1, y1) Recompute the region's autotiled squares (and the ring around it).

Call tm:autotile after a run of tm:set, not per square: retiling per write would be O(area) each time, and it would fight a stroke still being laid down.

Finding a way round

tm:path(from, to) is the route across the map for a top-down game: a list of square centres, and whether it reaches. Solid tiles are walls; empty squares and walk-through tiles are floor.

lua
local tm = level:tilemap()
local path, reached = tm:path(enemy, player, { avoid = { "water" }, cost = { mud = 3 } })
if path and #path > 1 then
  -- path[1] is the square the enemy is on; walk to the one after it.
  enemy:moveTowards(path[2], speed * dt)
end
  • diagonal (default true) allows diagonal steps, never past a wall's corner, so nothing following the path clips the wall beside it.
  • avoid lists tags to walk round as if solid.
  • cost makes tagged squares that many times dearer than plain floor, so a path takes a dry detour rather than wade through mud.

When the goal is walled off, the path ends on the reachable square nearest it, and reached is false. from and to can be nodes or world positions.

8. Levels from Tiled and LDtk

Put a Tiled map (.tmx) or an LDtk project (.ldtk) in the project, then drag it from Assets into the Scene view, or right-click it ▸ ▦ Import level.

Each level becomes one node named after it, and under it:

  • a tile layer for every tile layer, in the same order, with each tile's flip and turn kept;
  • an empty node for every object (Tiled) or entity (LDtk), at its centre, tagged with its layer, its class and its LDtk tags, so a script can findTagged("spawn");
  • one tileset for the whole level, with a sheet per image, whose collision is worked out from the art the way a dropped spritesheet's is. Per-tile classes (Tiled) and enum tags (LDtk) become tile tags.

One tile is one world unit, so a level of 8-pixel tiles is 8 pixels per unit, and sprites dropped beside it come out at the same pixel size.

Every visible layer is Collidable, and the Console lists them. Untick it on layers that are only decoration. Hidden layers come in hidden.

A sheet with a margin or spacing between its tiles is redrawn edge to edge as <name>.grid.png. An image outside the project is copied into textures/. Importing the same level again reuses its tileset, so collision and tags you changed are kept.

A Tiled map that is isometric or hexagonal comes in with its cells' shape set on the tileset it makes. Its hexagons are laid out regular, staggering the odd rows (or columns); a map whose stagger or side length differs is laid out that way and the Console says so. Its objects are placed by their raw coordinates.

Not imported: staggered isometric maps, image layers, Tiled tilesets made of separate images, and LDtk IntGrid layers that have no tiles (their values are the level's logic, not its picture). Each is named in the Console when a level has one.

9. What is not here yet

Written down because "it does not exist" beats "it exists and does nothing":

  • Chunked / infinite maps. A tilemap is cols × rows; a very large world is several tilemap nodes.

See also

  • scripting.md — the guided tour of the Lua surface
  • lua-api.md — every call, generated from the same table the editor's Docs tab and autocomplete read
  • physics.md — layers, the collision matrix, and the broadphase that merged colliders feed