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_versionidentifies the document format. Keep it on every authored file.tile_sizeis[w, h]of one tile in texture pixels. Square tiles[32, 32]are the common case.map_sizeis[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 }
}
}
]
textureis the image the tiles sample.first_tileis the first global ID covered by this set. Later sets continue from the previous range.animationsoptionally cycles frames for a tile ID (blinking hazard lights, scrolling vents). Each entry listsframes,fps, and optionalloop.
Tile ID rules, once and for all:
| Rule | Detail |
|---|---|
| 0 is empty | A cell holding 0 draws nothing. |
| Positive IDs are global | IDs count from 1 upward across all tilesets, one-based. |
| Row 0 is the top | Map rows are stored top to bottom; row map_size[1] - 1 is the floor row. |
| Storage is row-major | Each 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]
}
]
| Field | Meaning |
|---|---|
name | Layer label used for debugging and object association. |
parallax | Scroll factor relative to the camera. 1.0 moves with the world; less drifts slower for backgrounds. |
opacity | Layer alpha, 0 to 1. Dim a background layer under a dialog. |
collision | When true, solid tiles on this layer generate collision shapes. |
collision_layer | Physics layer name the generated shapes use (see Physics layers). Only meaningful with collision: true. |
navigation_blocked | When true, solid tiles on this layer mark navigation cells unwalkable. |
navigation_cost | Extra cost applied to cells covered by this layer. Higher values make paths avoid the area. 0 leaves cost unchanged. |
tiles | Flat 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" }
}
]
}
]
namegroups the markers so Lua can request one set at a time.idis the stable object id.typeis your gameplay category — anything you read in script.xandyare positions in tile units on the map grid.propertiesis 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] }
}
assetis the*.tilemap.jsonURI.pixels_per_unitconverts 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)
| Call | Arguments | Returns |
|---|---|---|
get_tile | map entity map, cell x, y | Tile ID number at that cell. 0 is empty. |
set_tile | map, x, y, new tile ID | Nothing. Writes a runtime override on top of the authored tiles. |
clear_overrides | map | Nothing. Restores every cell to the authored asset data. |
objects | map, object layer name | Array of { id, type, x, y, properties } from that layer. Empty array when the layer has no objects. |
bake_navigation | map | Nothing. 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_tilechanges one cell for the running game only; the asset on disk is untouched.bake_navigationis called after each edit so bot paths respect the new layout. It returns nothing.clear_overridesis 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
objectsreturns an array of object tables;ipairswalks every marker.xandyare 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_layerafter physics layer names the project already declares. - Store one-way platforms and hazards on their own layer with distinct
collision_layervalues 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.