DEMIENGINEDOCUMENTATION Everything is free
2D & isometric · Game systems

Tile data, layers, collision, and objects.

Tilemaps

A tilemap is a level built from a grid of small repeated pieces. This page explains what a tilemap is for, how to author a *.tilemap.json asset field by field, and how to read and edit tiles from Lua.

What a tilemap is

Hand-placing every floor piece of a Skyforge station would take forever and produce heavy scenes. A tilemap stores the level as a grid of tile IDs. Each ID selects a small image from a tileset — one floor plate, one wall corner, one hazard grate — and the renderer draws the grid in one pass.

Tilemaps exist for three reasons:

  • Speed of authoring — paint rooms instead of instancing hundreds of objects.
  • Cheap rendering — many tiles from few textures.
  • Built-in gameplay layers — the same grid can carry collision, walkability, and spawn markers.

The authored document is a *.tilemap.json asset. A scene then places it with a Tilemap2D component.

Tile size and map size

{
  "format_version": 1,
  "tile_size": [32, 32],
  "map_size": [40, 24]
}
  • format_version identifies the document format. Keep it on every authored file.
  • tile_size is [w, h] of one tile in texture pixels. Square tiles [32, 32] are the common case.
  • map_size is [columns, rows] of the grid. A 40×24 map holds 960 cells.

World size comes later from the scene component’s pixels_per_unit, so one tilemap asset can appear at different scales.

Tilesets and tile IDs

A tileset is a texture plus the ID range it owns.

"tilesets": [
  {
    "texture": "asset://tiles/station",
    "first_tile": 1,
    "animations": {
      "17": { "frames": [17, 18, 19], "fps": 6 },
      "23": { "frames": [23, 24], "fps": 4, "loop": true }
    }
  }
]
  • texture is the image the tiles sample.
  • first_tile is the first global ID covered by this set. Later sets continue from the previous range.
  • animations optionally cycles frames for a tile ID (blinking hazard lights, scrolling vents). Each entry lists frames, fps, and optional loop.

Tile ID rules, once and for all:

RuleDetail
0 is emptyA cell holding 0 draws nothing.
Positive IDs are globalIDs count from 1 upward across all tilesets, one-based.
Row 0 is the topMap rows are stored top to bottom; row map_size[1] - 1 is the floor row.
Storage is row-majorEach layer’s tiles array lists row 0 left to right, then row 1, and so on.

Layers

Layers stack back to front and each one can carry gameplay flags.

"layers": [
  {
    "name": "floor",
    "parallax": 1.0,
    "opacity": 1.0,
    "collision": false,
    "tiles": [0, 1, 1, 0, /* ... row-major ... */]
  },
  {
    "name": "walls",
    "parallax": 1.0,
    "opacity": 1.0,
    "collision": true,
    "collision_layer": "world",
    "navigation_blocked": true,
    "navigation_cost": 0,
    "tiles": [0, 0, 17, 0]
  }
]
FieldMeaning
nameLayer label used for debugging and object association.
parallaxScroll factor relative to the camera. 1.0 moves with the world; less drifts slower for backgrounds.
opacityLayer alpha, 0 to 1. Dim a background layer under a dialog.
collisionWhen true, solid tiles on this layer generate collision shapes.
collision_layerPhysics layer name the generated shapes use (see Physics layers). Only meaningful with collision: true.
navigation_blockedWhen true, solid tiles on this layer mark navigation cells unwalkable.
navigation_costExtra cost applied to cells covered by this layer. Higher values make paths avoid the area. 0 leaves cost unchanged.
tilesFlat row-major array of tile IDs, map_size[0] * map_size[1] entries.

Collision and navigation flags let one painted grid drive both physics and pathfinding. A Skyforge maintenance floor paints a navigation_cost grating layer so bots prefer the dry route without blocking walking entirely.

Object layers

Not everything is a repeated tile. Spawns, triggers, and lights are single markers with positions and extra data — those live in object_layers.

"object_layers": [
  {
    "name": "spawns",
    "objects": [
      {
        "id": "spawn_a",
        "type": "player_start",
        "x": 4,
        "y": 10,
        "properties": { "facing": "east", "team": "crew" }
      }
    ]
  }
]
  • name groups the markers so Lua can request one set at a time.
  • id is the stable object id.
  • type is your gameplay category — anything you read in script.
  • x and y are positions in tile units on the map grid.
  • properties is an open string-to-value table for facing, team, patrol path, and similar data.

Placing a tilemap in a scene

{
  "Tilemap2D": {
    "asset": "asset://maps/station_deck",
    "pixels_per_unit": 32
  },
  "Transform2D": { "position": [0, 0] }
}
  • asset is the *.tilemap.json URI.
  • pixels_per_unit converts texture pixels to world units. With 32 pixel tiles and 32 pixels per unit, one tile is 1×1 world unit.
  • The map origin is at Transform2D.position, and that point is the lower-left corner of the grid. The map extends right and up from there.

Reading and editing tiles from Lua

local Tilemap2D = require("demi.tilemap2d")

local id = Tilemap2D.get_tile(map, x, y)
Tilemap2D.set_tile(map, x, y, 17)
Tilemap2D.clear_overrides(map)

local objects = Tilemap2D.objects(map, "spawns")
Tilemap2D.bake_navigation(map)
CallArgumentsReturns
get_tilemap entity map, cell x, yTile ID number at that cell. 0 is empty.
set_tilemap, x, y, new tile IDNothing. Writes a runtime override on top of the authored tiles.
clear_overridesmapNothing. Restores every cell to the authored asset data.
objectsmap, object layer nameArray of { id, type, x, y, properties } from that layer. Empty array when the layer has no objects.
bake_navigationmapNothing. Rebuilds walkability after tiles change so paths follow the edit.

A Skyforge script that paints blast doors shut and reopens them:

local Tilemap2D = require("demi.tilemap2d")

function close_door(map, cx, cy)
    Tilemap2D.set_tile(map, cx, cy, 17)      -- solid door tile
    Tilemap2D.bake_navigation(map)           -- paths now avoid the door
end

function open_door(map, cx, cy)
    Tilemap2D.set_tile(map, cx, cy, 1)       -- floor tile
    Tilemap2D.bake_navigation(map)
end
  • set_tile changes one cell for the running game only; the asset on disk is untouched.
  • bake_navigation is called after each edit so bot paths respect the new layout. It returns nothing.
  • clear_overrides is the escape hatch when a level reset should restore the authored map.

Reading spawn markers on level start:

local Tilemap2D = require("demi.tilemap2d")

local spawns = Tilemap2D.objects(map, "spawns")
for _, obj in ipairs(spawns) do
    -- obj.id         "spawn_a"
    -- obj.type       "player_start"
    -- obj.x, obj.y   tile coordinates
    -- obj.properties { facing = "east", team = "crew" }
end
  • objects returns an array of object tables; ipairs walks every marker.
  • x and y are in tile units. Convert through the grid helpers on an isometric map (see Isometric grid).
  • Missing layers return an empty array, so level-start code needs no existence checks.

Tips

  • Keep decorative layers (collision: false) separate from gameplay layers so art changes never break collision.
  • Name collision_layer after physics layer names the project already declares.
  • Store one-way platforms and hazards on their own layer with distinct collision_layer values for easy filtering.
  • Use object layers for anything you want named and searched; tiles are for repetition.

Tile art is ordinary sprite texture work. Placement on an isometric diamond grid is covered in Isometric grid.