A permanent, field-level engineering reference for the LDtk project JSON format, written to ground TileMapPlus's importer/exporter/data-model work in verified ground truth.
Sources cross-checked throughout: the official JSON Schema (
docs/JSON_SCHEMA.json, Draft-7, auto-generated from Haxe source),docs/JSON_DOC.md, the ldtk.io docs site, the LDtkCHANGELOG.md, theldtkimportC++ rules engine (clearest source for auto-rule match semantics), and real sample files:ldtk-haxe-apisample.ldtk(multi-world, Free,externalLevels:true,jsonVersion 1.5.3), its external levelsample/0000-West.ldtkl, andWorldMap_GridVania_layout.ldtk(single-world, GridVania, inline levels). Field descriptions are quoted verbatim from the schema/doc unless marked "(observed)".Format version context: captured against LDtk 1.5.x. The tileset/entity/enum models have been stable since ~1.0. Version-gated specifics are flagged inline.
- Format conventions (read first)
- The two identifier systems:
uidvsiid - Project root fields
- Worlds, levels, external levels, neighbours, backgrounds
- Layer instances and tile data
- IntGrid and AutoLayer rules
- Tilesets, tile coordinates, and custom data
- Entities, field instances, and enums
- Shared structs
- Versioning and migration semantics
- Consolidated round-trip checklist: preserve vs recompute
- Gotchas
These conventions apply to every section below.
__-prefixed fields are derived/denormalized. The doc preamble: "Important data from definitions is often duplicated in fields prefixed with a double underscore (eg.__identifieror__type)" — "to make the JSON parsing much easier." They are convenience copies, recomputable fromdefs+ the instance. On import: read them, but the authoritative source is the definition + non-__fields. On export: re-emit them (loose loaders read them) and keep them internally consistent (e.g.intGridCsv.length == __cWid * __cHei, or LDtk rejects the file).- "Optional"/nullable fields are typed
T | null(oneOf [T, null]in the schema). The key is always present; the value may benull. Distinguishnullfrom[]— they mean different things (see §5, external levels). iid/*Iidare stable UUID-style strings (e.g."60018d46-66b0-11ec-a36b-a9ab7da03bee").uidis an integer. See §2.- Empty-array sentinels:
gridTiles,autoLayerTiles,intGridCsv,optionalRules,entityInstances,fieldInstances,tags,customData,enumTags,savedSelections,nineSliceBorders,realEditorValuesare always present arrays (possibly[]), nevernull. - Value sentinels:
intGridCsvuses0= empty cell (real values start at1); tilef=0= no flip; tileadefaults to1;maxCount/maxPerLevelof0= unlimited; Linear-layoutworldX/worldY=-1; IntGrid valuegroupUidof0= "no group".
Two distinct identifier systems coexist; conflating them breaks references.
uid— integer Unique IDentifier. On definitions (layer defs, entity defs, tileset defs, enum defs, field defs) and on levels (level.uid). User-meaningful, can be reorganized; new ones are drawn from the project-rootnextUid. References within defs use uids:defUid,layerDefUid,tilesetUid/tilesetDefUid,levelId.iid— string Instance IDentifier (UUID-style). Auto-generated, immutable. Present on project, world, level, layer instance, entity instance. Purpose: a permanent anchor that survives definition reorganization. Cross-references between instances always use iids.
Reference plumbing (all iid-based):
- EntityRef field value /
EntityReferenceInfos= a 4-part address:entityIid,layerIid,levelIid,worldIid— together uniquely locate an entity anywhere in the project (worldIidis essential in multi-world projects). tocentries carry the same quadruple per instance.level.__neighbours[]references neighbours bylevelIid(waslevelUidpre-1.0).layerInstance.levelIdpoints to the owning level uid (integer); the layer's owniidis a separate string id.
Round-trip rule: any iid you read must be written back byte-identical. Generating new iids on round-trip silently breaks every EntityRef, every toc entry, and every neighbour link that points at it. uids may be remapped only if you remap every reference consistently — generally not worth it; preserve them too, and keep nextUid >= max(uid) + 1.
Scope = per-project unless noted. Types per the Draft-7 schema.
| Field | Type | Null? | Meaning | Round-trip |
|---|---|---|---|---|
__header__ |
object | no | {fileType:"LDtk Project JSON", app:"LDtk", appVersion, url} — informational |
Cosmetic; recompute or preserve |
iid |
string | no | Unique project instance id (UUID-style) | PRESERVE verbatim |
jsonVersion |
string | no | File format version, e.g. "1.5.3" |
Authoritative; set to your exporter's target, or preserve when passing through |
appBuildId |
number | no | LDtk build that wrote the file | PRESERVE verbatim (editor compat heuristics) |
nextUid |
integer | no | Next integer UID available for new defs/levels | Preserve / advance correctly; never reuse below it |
| Field | Type | Null? | Meaning |
|---|---|---|---|
defaultGridSize |
integer | no | Default grid size for new layers (sample: 8) |
defaultPivotX / defaultPivotY |
number (0..1) | no | Default entity pivot (sample: 0.5 / 1) |
defaultEntityWidth / defaultEntityHeight |
integer | no | Default new-entity dims |
defaultLevelBgColor |
string #RRGGBB |
no | Used when a level's bgColor is null |
defaultLevelWidth / defaultLevelHeight |
integer | yes | DEPRECATED at root → moved into each worlds[]. null in multi-world / 1.5.x sample |
bgColor |
string #RRGGBB |
no | Project canvas/background color (sample: "#18131B") |
See §4 for full semantics. Note: worldLayout, worldGridWidth, worldGridHeight are deprecated at root (moved to worlds[]) and go null when MultiWorlds is enabled.
| Field | Type | Null? | Meaning |
|---|---|---|---|
worlds |
array | no | Multi-world container. Empty [] unless MultiWorlds — but the 1.5.3 sample populates it (single world). Read both shapes |
dummyWorldIid |
string | no | IID of the internal single "dummy" world used when NOT in multi-worlds mode. Preserve verbatim |
worldLayout |
enum | null | yes | Free | GridVania | LinearHorizontal | LinearVertical. Deprecated at root |
worldGridWidth / worldGridHeight |
integer | null | yes | GridVania cell size in px. Deprecated at root |
levels |
array | no | Levels (root-level, legacy single-world view). In multi-worlds, levels live under worlds[].levels. Order significant only for Linear layouts |
| Field | Type | Null? | Meaning |
|---|---|---|---|
defs |
object (Definitions) | no | Holds layers, entities, tilesets, enums, externalEnums, levelFields. The schema/template; levels hold instances of it |
toc |
array | no | Pre-computed index of all entity instances whose EntityDef has exportToToc. Derived/regenerable. See §10.6 for the instances vs instancesData version split |
| Field | Type | Null? | Meaning |
|---|---|---|---|
externalLevels |
boolean | no | If TRUE: project file holds defs + level headers; each level body is a separate .ldtkl file (level.externalRelPath); layerInstances becomes null in the project file |
simplifiedExport |
boolean | no | Also emit a "Super Simple" export (PNGs + CSV + minimal data) |
exportTiled |
boolean | no | Also emit a Tiled-compatible .tmx |
imageExportMode |
enum | no | None | OneImagePerLayer | OneImagePerLevel | LayersAndLevels |
exportLevelBg |
boolean | no | Include level background in exported PNGs |
pngFilePattern |
string | null | yes | Filename pattern for exported PNGs |
| Field | Type | Null? | Meaning |
|---|---|---|---|
identifierStyle |
enum | no | Capitalize | Uppercase | Lowercase | Free — identifier normalization |
levelNamePattern |
string | no | Pattern for generated level identifiers (e.g. Level_%idx) |
backupOnSave |
boolean | no | Write a backup on each save |
backupLimit |
integer | no | Backups to retain |
backupRelPath |
string | null | yes | Relative path for backups |
minifyJson |
boolean | no | If TRUE, exported JSON is partially minified (formatting only; semantics identical) |
| Field | Type | Null? | Meaning |
|---|---|---|---|
customCommands |
array | no | {command:String, when:"Manual"|"AfterLoad"|"BeforeSave"|"AfterSave"} — shell commands the editor runs. Preserve verbatim |
flags |
array | no | Advanced toggles: DiscardPreCsvIntGrid, ExportOldTableOfContentData, ExportPreCsvIntGridFormat, IgnoreBackupSuggest, PrependIndexToLevelFileNames, MultiWorlds, UseMultilinesType. Preserve verbatim — they change serialization shape |
Verbatim — project root (v1.5.3 sample):
"__header__": { "fileType":"LDtk Project JSON", "app":"LDtk", "appVersion":"1.5.3", "url":"https://ldtk.io" },
"iid":"a8ea85a0-7820-11ed-b6fd-4507f1bd5fbf",
"jsonVersion":"1.5.3", "appBuildId":473702,
"defaultGridSize":8, "defaultPivotX":0.5, "defaultPivotY":1,
"defaultLevelWidth":null, "defaultLevelHeight":null,
"bgColor":"#18131B", "worldLayout":null, "externalLevels":trueLDtk has a hard split an importer/exporter MUST branch on. There is no multiWorlds boolean — detect mode by whether worlds[] is non-empty.
Single-world (legacy / default) — root carries the world:
{
"worldLayout": "GridVania", "worldGridWidth": 256, "worldGridHeight": 256,
"externalLevels": false,
"levels": [ /* all Level objects here */ ],
"worlds": [], "dummyWorldIid": "..."
}(verbatim root values from WorldMap_GridVania_layout.ldtk.)
Multi-worlds — root world fields are null, worlds[] populated:
{
"worldLayout": null, "worldGridWidth": null, "worldGridHeight": null,
"externalLevels": true, "levels": [],
"worlds": [
{ "identifier": "SampleWorld", "iid": "6d9adae0-c640-11ed-b633-01b6d7e2af59",
"worldGridWidth": 256, "worldGridHeight": 256, "worldLayout": "Free",
"levels": [ /* Level objects here */ ] }
]
}(verbatim from sample.ldtk.)
Schema semantics (verbatim):
- Root
worlds: "This array will be empty, unless you enable the Multi-Worlds in the project advanced settings." - Root
dummyWorldIid: "If the project isn't in MultiWorlds mode, this is the IID of the internal "dummy" World." In single-world mode LDtk still internally wraps everything in one dummy World;dummyWorldIidis that World's iid. - Root
worldGridWidth/worldGridHeight/worldLayout/defaultLevelWidth/defaultLevelHeightare documented as "Moves to worlds array in multi-worlds update".
Importer rule: if worlds[] is non-empty, read layout from each World (root layout fields are null, root levels[] may be empty). Else read from root; dummyWorldIid is the implicit world's id. Exporter rule: write back whichever shape you read — don't silently convert single→multi. Multi-world projects cannot be loaded by pre-1.3 importers.
Enum: Free, GridVania, LinearHorizontal, LinearVertical, null (null only at root in multi-worlds mode).
| Layout | Position source | worldX/worldY |
Array order | Grid snap |
|---|---|---|---|---|
| Free | per-level worldX,worldY (px) |
authoritative, arbitrary | irrelevant | none |
| GridVania | per-level worldX,worldY (px) |
authoritative, snapped to worldGridWidth/Height |
irrelevant | yes |
| LinearHorizontal | array order in levels[] |
always -1 |
authoritative | n/a |
| LinearVertical | array order in levels[] |
always -1 |
authoritative | n/a |
Verbatim from schema — Level worldX: "World X coordinate in pixels. Only relevant for world layouts where level spatial positioning is manual (ie. GridVania, Free). For Horizontal and Vertical layouts, the value is always -1 here." (same for worldY.)
Critical importer rule: In Linear layouts you CANNOT use worldX/worldY (they are -1). Lay levels out yourself by iterating levels[] in order and stacking by pxWid (Horizontal) or pxHei (Vertical). Real check: a GridVania sample's first level "Entrance" has worldX:0, worldY:0; a Free sample level "West" has worldX:0, worldY:40 — both live coordinates. In multi-worlds, GridVania snapping uses the owning World's worldGridWidth/Height, not root (which is null).
A Level is per-world (lives in root levels[] or worlds[].levels[]). __-prefixed fields are derived/recomputable; all others authoritative.
| Field | Type | Null? | Authoritative vs derived | Meaning |
|---|---|---|---|---|
identifier |
string | no | authoritative | User-defined unique id (per world) |
uid |
integer | no | authoritative | Project-unique Int id. Preserve verbatim (referenced by deprecated levelUid) |
iid |
string (UUID) | no | authoritative | Unique instance id. ROUND-TRIP CRITICAL — neighbours/cross-refs use it |
worldX |
integer | no | authoritative (Free/GridVania) / sentinel -1 (Linear) |
World X in px. See §4.2 |
worldY |
integer | no | authoritative / sentinel -1 |
World Y in px |
worldDepth |
integer | no | authoritative | "Index representing level depth in world. Default 0, greater=above, lower=below." Drives </>/o neighbour codes |
pxWid |
integer | no | authoritative | Level width in px |
pxHei |
integer | no | authoritative | Level height in px |
useAutoIdentifier |
boolean | no | authoritative | If true, identifier auto-follows the naming pattern; false once user renamed. Preserve |
__bgColor |
string #hex |
no | derived | Effective bg color = bgColor ?? project.defaultLevelBgColor |
bgColor |
string | null | yes | authoritative | Per-level override; null ⇒ inherit project default |
__smartColor |
string #hex |
no | derived | Auto-contrasting UI tint; cosmetic |
bgRelPath |
string | null | yes | authoritative | Relative path to a level background IMAGE (distinct from bgColor) |
bgPos |
enum | null | yes | authoritative | Bg image fit: Unscaled, Contain, Cover, CoverDirty, Repeat, null |
bgPivotX / bgPivotY |
number (0–1) | no | authoritative | Bg image pivot |
__bgPos |
object | null | yes | derived | Computed crop/scale geometry for the bg image (see §4.5). null when no bg image |
externalRelPath |
string | null | yes | authoritative (path) | Relative path to the .ldtkl when externalLevels=true; null when inline OR when reading the .ldtkl itself |
fieldInstances |
array | no | authoritative | Level custom field values (often []). Opaque payload — preserve verbatim |
layerInstances |
array | null | YES | authoritative | All layer instances in display order. null when externalLevels=true (data lives in the .ldtkl); [] means a genuinely layer-less level — don't conflate |
__neighbours |
array | no | derived | Adjacency list (see §4.4). Recomputable; empty for Linear layouts |
Verbatim — Level (multi-world Free, externalized — layerInstances:null, externalRelPath set):
{
"identifier": "West", "iid": "60018d40-66b0-11ec-a36b-3b0761cf900c", "uid": 0,
"worldX": 0, "worldY": 40, "worldDepth": 0, "pxWid": 272, "pxHei": 280,
"__bgColor": "#121331", "bgColor": null, "useAutoIdentifier": false,
"bgRelPath": "N2D - SpaceWallpaper1280x448.png", "bgPos": "Cover",
"bgPivotX": 1, "bgPivotY": 1, "__smartColor": "#7D7D8E",
"__bgPos": { "topLeftPx": [0,0], "scale": [1.25,1.25], "cropRect": [422.4,0,217.6,224] },
"externalRelPath": "sample/0000-West.ldtkl",
"fieldInstances": [], "layerInstances": null,
"__neighbours": [
{ "levelIid": "60038910-66b0-11ec-a36b-1ff7a8a0ffd4", "dir": "e" },
{ "levelIid": "60047370-66b0-11ec-a36b-2b89739d30ed", "dir": "e" }
]
}Verbatim — Level (single-world GridVania, inline — layerInstances present, no bg image so __bgPos:null):
{
"identifier": "Entrance", "iid": "a367c3b0-66b0-11ec-9cd7-91690c910c97", "uid": 0,
"worldX": 0, "worldY": 0, "worldDepth": 0, "pxWid": 512, "pxHei": 256,
"__bgColor": "#37494E", "bgColor": null, "bgRelPath": null, "bgPos": null,
"bgPivotX": 0.5, "bgPivotY": 0.5, "__bgPos": null,
"externalRelPath": null, "fieldInstances": [ /* ... */ ], "layerInstances": [ /* ... */ ]
}__neighbours is an array of NeighbourLevel objects. It is derived (LDtk computes adjacency from world positions): an importer can trust it; an exporter can regenerate it. Empty in Linear layouts.
Verbatim: "An array listing all other levels touching this one on the world map. Since 1.4.0, this includes levels that overlap in the same world layer, or in nearby world layers. Only relevant for world layouts where level spatial positioning is manual (ie. GridVania, Free). For Horizontal and Vertical layouts, this array is always empty."
| Field | Type | Null? | Notes |
|---|---|---|---|
levelIid |
string | no | iid of the neighbour. Canonical reference (since 1.2.0) |
levelUid |
integer | null | yes | DEPRECATED since 1.2.0, replaced by levelIid. May be absent in newer files |
dir |
string | no | Direction code (below) |
dir code domain (verbatim): "A lowercase string tipping on the level location (north, south, west, east). Since 1.4.0, this value can also be < (neighbour depth is lower), > (neighbour depth is greater) or o (levels overlap and share the same world depth). Since 1.5.3, this value can also be nw,ne,sw or se for levels only touching corners."
So: n s e w (edges); nw ne sw se (corners, 1.5.0/1.5.3+); < > (depth-differing, 1.4.0+); o (overlap at same depth, 1.4.0+). A single neighbour can appear multiple times with different dir values. Reference by levelIid, not array index.
Two independent background concepts per level:
- Background color —
bgColor(authoritative override, may be null) → effective__bgColor(derived:bgColor ?? project.defaultLevelBgColor). - Background image —
bgRelPath(path),bgPos(fit mode),bgPivotX/Y(0–1). When an image is set, LDtk computes__bgPos(a LevelBgPosInfos object); when no image,__bgPosisnull.
LevelBgPosInfos (all derived geometry — recomputable from image + bgPos + pivots + level size):
| Field | Type | Verbatim meaning |
|---|---|---|
topLeftPx |
[x,y] ints |
"the [x,y] pixel coordinates of the top-left corner of the cropped background image, depending on bgPos option." |
scale |
[scaleX,scaleY] floats |
"the [scaleX,scaleY] values of the cropped background image, depending on bgPos option." |
cropRect |
[cropX,cropY,cropW,cropH] floats |
"the cropped sub-rectangle of the displayed background image. This cropping happens when original is larger than the level bounds." |
bgPos enum: Unscaled (1:1, pivot-placed), Contain (fit inside, no crop), Cover (fill, crops overflow), CoverDirty (legacy/non-recomputed cover variant), Repeat (tile), null. For an importer, prefer rendering from bgRelPath + __bgPos directly (crop+scale+offset already baked) rather than re-deriving from bgPos.
Project flag externalLevels (boolean, root only): "If TRUE, one file will be saved for the project (incl. all its definitions) and one file in a sub-folder for each level."
Mechanics:
- When
externalLevels=true: the main.ldtkkeeps every Level's metadata (identifier, iid, worldX/Y, pxWid/Hei, bg fields, fieldInstances,__neighbours) but setslayerInstances: nulland fillsexternalRelPathwith the relative path to the level file. - A
.ldtklfile is a single complete Level object (same schema as an inline Level) withlayerInstancespopulated and its ownexternalRelPath: null. Confirmed from0000-West.ldtkl:identifier:"West",iid,worldX:0/worldY:40,pxWid:272/pxHei:280,externalRelPath:null, and a 4-layerlayerInstancesarray (Entities/Custom_tiles/Collisions/Cavern_background). - Path resolution: resolve
externalRelPathrelative to the main.ldtkfile's directory (e.g."sample/0000-West.ldtkl"→<projectDir>/sample/0000-West.ldtkl). Forward slashes; project-relative. - Importer rule: if
externalLevels=trueandlevel.layerInstances==null, you MUST open the.ldtklto get layer data. On metadata mismatch, the.ldtklis authoritative for layer content; positional/world data is mirrored. - Exporter rule: keep
iid/uid/identifier/worldX-Y/pxWid-Heiidentical between the stub in the main file and the.ldtkl. Filename pattern isNNNN-Identifier.ldtkl(4-digit ordinal prefix; controlled by thePrependIndexToLevelFileNamesflag), but match byiid/externalRelPath, not filename.
Layer instances appear under levels[].layerInstances[] (embedded) OR are null when external levels are enabled (then they live in the level's .ldtkl).
"Each level always contains a layer instance for each layer definition found in
defs." — exactly one instance per definition per level; you cannot have a level missing a layer or having two of the same.
| Field | Type | Null? | Meaning (verbatim where quoted) |
|---|---|---|---|
__identifier |
string | no | "Layer definition identifier" (copy of the def's identifier) |
__type |
string | no | "Layer type (possible values: IntGrid, Entities, Tiles or AutoLayer)". Copy of the layer definition's base type — see §5.3 |
__cWid |
int | no | "Grid-based width" (cells across) = level width / gridSize |
__cHei |
int | no | "Grid-based height" (cells down) |
__gridSize |
int | no | "Grid size" in px (cell size for this layer) |
__opacity |
number (0–1) | no | "Layer opacity as Float [0-1]" (copy of def opacity) |
__pxTotalOffsetX |
int | no | "Total layer X pixel offset, including both instance and definition offsets" = def.pxOffsetX + inst.pxOffsetX. Use this for rendering, not pxOffsetX |
__pxTotalOffsetY |
int | no | Total Y offset (def + instance) |
__tilesetDefUid |
int | yes | "The definition UID of corresponding Tileset, if any". Null on plain Entities layers (observed) |
__tilesetRelPath |
string | yes | "The relative path to corresponding Tileset, if any" |
iid |
string (UUID) | no | "Unique layer instance identifier". Round-trip critical |
levelId |
int | no | "Reference to the UID of the level containing this layer instance" |
layerDefUid |
int | no | "Reference the Layer definition UID". Join key back to defs.layers[] |
pxOffsetX |
int | no | "X offset in pixels to render this layer, usually 0". Instance-only; def offset is separate (sum is __pxTotalOffsetX) |
pxOffsetY |
int | no | "Y offset in pixels..." |
visible |
boolean | no | "Layer instance visibility" |
seed |
int | no | "Random seed used for Auto-Layers rendering". Round-trip critical — changing it re-rolls rules so tiles shift |
optionalRules |
int[] | no | "An Array containing the UIDs of optional rules that were enabled in this specific layer instance." Empty [] common |
overrideTilesetUid |
int | yes | "This layer can use another tileset by overriding the tileset UID here." Null = use def's tileset |
intGridCsv |
int[] | no | IntGrid values (see §6.2). Empty [] on non-IntGrid layers |
intGrid |
object[] | yes (deprecated) | Old [{coordId,v}] form; removed in 1.0.0, replaced by intGridCsv. May appear in ancient files only |
autoLayerTiles |
Tile[] | no | "Tiles generated by Auto-layer rules, sorted in display order". See §5.2 |
gridTiles |
Tile[] | no | Hand-painted tiles (Tiles layers). See §5.2 |
entityInstances |
EntityInstance[] | no | Entities (see §8) |
Authoritative vs derived: all __-prefixed fields are convenience duplicates (derivable from defs + the instance). The authoritative instance-owned values are: iid, levelId, layerDefUid, pxOffsetX/Y, seed, visible, optionalRules, overrideTilesetUid, intGridCsv, and the three array payloads.
Schema title "Tile instance"; required = ["a","f","px","src","t","d"] — all six are mandatory on every tile.
| Field | Type | Meaning (verbatim) | Notes |
|---|---|---|---|
px |
int[2] [x,y] |
"Pixel coordinates of the tile in the layer ([x,y] format). Don't forget optional layer offsets, if they exist!" |
Top-left in layer space, before __pxTotalOffsetX/Y. Derivable from coordId (in d) and __gridSize |
src |
int[2] [x,y] |
"Pixel coordinates of the tile in the tileset ([x,y] format)" |
Top-left of the source cell in the tileset image. Fully derivable from t + tileset def (§7.3) |
f |
int (0–3) | "Flip bits, a 2-bits integer... Bit 0 = X flip, Bit 1 = Y flip" | f=0 none, 1 X, 2 Y, 3 both. See §5.4 |
t |
int | "The Tile ID in the corresponding tileset." | Grid index into the tileset (§7.3). The authoritative "which tile" |
d |
int[] | "Internal data used by the editor. For auto-layer tiles: [ruleId, coordId]. For tile-layer tiles: [coordId]." |
Editor-private. Length differs by array (2 vs 1). coordId = cellX + cellY*__cWid. Round-trip critical — preserve verbatim |
a |
number (0–1) | "Alpha/opacity of the tile (0-1, defaults to 1)" | Added in a later version; defaults 1. Multiplies with __opacity |
__type ∈ IntGrid, Entities, Tiles, AutoLayer, copied from the layer DEFINITION's type, NOT inferred from whether rules exist.
Tiles— hand-painted. PopulatesgridTiles;autoLayerTiles=[]. Has a tileset.AutoLayer— pure auto-layer (rules paint onto a tileset; reads another layer's grid; no own editable IntGrid values). PopulatesautoLayerTiles;gridTiles=[].IntGrid— integer grid. PopulatesintGridCsv. If the IntGrid layer ALSO has auto-layer rules + a tileset, it ALSO populatesautoLayerTileswhile__typestays"IntGrid". This is the trap: you cannot decide which tile array to read from__typealone.Entities— entity placement. All tile/grid arrays[],__tilesetDefUidusually null.
Empirically verified in 0000-West.ldtkl: layers "Collisions" and "Cavern_background" report "__type":"IntGrid" yet carry non-empty autoLayerTiles and empty gridTiles. (autoTilesetDefUid on the def was merged into tilesetDefUid in v1.2.0; before that an IntGrid def with autoTilesetDefUid is the rule-bearing case.)
Which array each type populates:
__type |
gridTiles |
autoLayerTiles |
intGridCsv |
|---|---|---|---|
Tiles |
populated | [] |
[] |
AutoLayer |
[] |
populated | [] |
IntGrid (no rules) |
[] |
[] |
populated |
IntGrid (+rules+tileset) |
[] |
populated | populated |
Entities |
[] |
[] |
[] |
Import rule of thumb: render gridTiles then autoLayerTiles regardless of __type. To detect "has auto rules", check autoLayerTiles.length > 0 (or the def's tilesetDefUid + rule groups), not __type.
Ordering: gridTiles are "all the tiles in display order (1st one is beneath 2nd one, etc.)" — array order IS z-order: earlier elements drawn first (underneath), later on top. autoLayerTiles likewise. The same px cell can hold multiple stacked tiles (base + overlay). Preserve array order exactly on round-trip; reordering changes the result. Across layers, the layer-instance array order (top→bottom in the editor) is the inter-layer z-order.
Flip bits (f): 2-bit mask — Bit 0 (value 1) = X flip (horizontal mirror), Bit 1 (value 2) = Y flip (vertical mirror). f=3 (both) == 180° rotation. LDtk has no arbitrary rotation field — only the two mirror axes; you cannot represent 90°/270° natively (for UE import: map to mirrored UVs, not rotation). Flips are about the tile's own center within its __gridSize box; flip X negates the U axis, flip Y the V axis. The flip does not change px.
Verbatim evidence (0000-West.ldtkl): IntGrid layer that nonetheless fills autoLayerTiles (the §5.3 trap; d is [ruleId=36, coordId]):
{ "__identifier":"Collisions", "__type":"IntGrid", "__cWid":34, "__cHei":35,
"__gridSize":8, "__tilesetDefUid":1,
"__tilesetRelPath":"Cavernas_by_Adam_Saltsman-Extended.png",
"iid":"6001b450-66b0-11ec-a36b-51f8aff2e196", "levelId":0, "layerDefUid":2,
"seed":4084837, "visible":true, "optionalRules":[], "overrideTilesetUid":null,
"intGridCsv":[1,1,1,1,1,1,1,1,1,1,1,1, ...],
"gridTiles":[],
"autoLayerTiles":[
{"px":[16,0],"src":[0,0],"f":0,"t":0,"d":[36,2],"a":1},
{"px":[24,0],"src":[0,0],"f":0,"t":0,"d":[36,3],"a":1}
] }Hand-painted Tiles layer (d is [coordId], length 1; second tile shows f:2 Y-flip):
{ "__identifier":"Custom_tiles", "__type":"Tiles",
"iid":"60018d4a-66b0-11ec-a36b-6969d93d9836", "layerDefUid":34, "seed":1927915,
"gridTiles":[
{"px":[224,24],"src":[88,32],"f":0,"t":59,"d":[130],"a":1},
{"px":[208,32],"src":[0,160],"f":2,"t":240,"d":[162],"a":1}
],
"autoLayerTiles":[] }Entities layer (all tile/grid arrays empty, no tileset):
{ "__identifier":"Entities", "__type":"Entities", "__tilesetDefUid":null,
"__tilesetRelPath":null, "iid":"60018d45-66b0-11ec-a36b-2fc9526ec805",
"layerDefUid":28, "seed":3895744, "visible":true,
"gridTiles":[], "autoLayerTiles":[] }| Concept | Lives in | Scope |
|---|---|---|
intGridValues, intGridValuesGroups, autoRuleGroups→rules, tilesetDefUid, autoSourceLayerDefUid, layer type |
defs.layers[] (LayerDef) |
per-project / per-layer-DEF (shared by all levels) |
intGridCsv, autoLayerTiles, gridTiles, seed, optionalRules, overrideTilesetUid |
layerInstances[] |
per-layer-INSTANCE (one per level per layer) |
A single tile (px/src/f/t/d/a) |
inside the tile arrays | per-tile |
| A rule | autoRuleGroups[].rules[] |
per-def (NOT per level) |
Mental model: rules are project-level templates; intGridCsv is per-level input; autoLayerTiles is the baked per-level OUTPUT of running the rules over the CSV. Editing a rule changes output for every level using that layer.
intGridCsv (layer instance, IntGrid/AutoLayer): "A list of all values in the IntGrid layer, stored in CSV format... Order is from left to right, and top to bottom... 0 means "empty cell" and IntGrid values start at 1. The array size is __cWid x __cHei cells."
- It is a JSON array of ints (despite the name "CSV" — observed as
[1,1,1,...], not a string). - Cell
(cx,cy)index =cy * __cWid + cx; inverselycx = i % __cWid,cy = floor(i / __cWid). 0is the empty sentinel; real values are1..Nand map tointGridValues[].value.- An empty IntGrid layer still serializes the full array of zeros. A pure
AutoLayerthat sources another layer's grid carries an emptyintGridCsv:[]. - Legacy
intGrid(object array) is the deprecated pre-1.0 form; do not emit.
intGridValues (LayerDef) — IntGridValueDefinition. WARNING (verbatim): "the array order is not related to actual IntGrid values!" — key by .value, never by array index.
| Field | Type | Null? | Meaning |
|---|---|---|---|
value |
int | no | The IntGrid integer this row describes (>=1) |
identifier |
string | yes | User name e.g. "walls" |
color |
string #rrggbb |
no | Display/editor color |
groupUid |
int | no | Parent intGridValuesGroups uid; 0 = no group |
tile |
TilesetRect | yes | Optional preview tile |
"intGridValues": [
{"value":1,"identifier":"walls","color":"#2A2B61","tile":null,"groupUid":0},
{"value":2,"identifier":"ladders","color":"#A36433","tile":null,"groupUid":0},
{"value":3,"identifier":"lava","color":"#FF0000","tile":null,"groupUid":0}
]intGridValuesGroups (LayerDef): {uid, identifier|null, color|null}. Purely organizational (folders), referenced by IntGridValueDefinition.groupUid. Editor-organizational; round-trip-preserve to keep authoring UX.
| Field | Type | Meaning |
|---|---|---|
type |
enum | An IntGrid def WITH a tilesetDefUid + rules behaves as an auto-layer (its instance gets autoLayerTiles). A pure AutoLayer def has no own IntGrid data and reads another layer's grid |
tilesetDefUid |
int | null | Default tileset the rules paint from. Null on plain IntGrid w/o tiles |
autoSourceLayerDefUid |
int | null | The key distinguisher. Null → rules run on THIS layer's own intGridCsv. Set → reads the referenced IntGrid layer's CSV as input (this layer's own intGridCsv is empty) |
autoRuleGroups |
AutoLayerRuleGroup[] | All rule groups (§6.4) |
autoTilesKilledByOtherLayerUid |
int | null | (advanced) lets one layer mask another's auto-tiles. Preserve verbatim |
Two ways to get autoLayerTiles: (a) IntGrid layer + tileset + rules (self-source), or (b) dedicated AutoLayer with autoSourceLayerDefUid pointing at an IntGrid layer.
{"uid":3,"name":"Walls","color":null,"icon":null,"active":true,
"isOptional":false,"usesWizard":false,
"requiredBiomeValues":[],"biomeRequirementMode":0,"rules":[ ... ]}| Field | Type | Null? | Meaning | Class |
|---|---|---|---|---|
uid |
int | no | Group id | functional |
name |
string | no | Display name | functional |
active |
bool | no | If false, whole group is skipped | functional |
isOptional |
bool | no | If true, group's rules apply only when its uid is in the instance's optionalRules |
functional |
rules |
AutoRuleDef[] | no | Ordered list (order = priority, §6.6) | functional |
color |
string | null | yes | Editor folder color | editor-only |
icon |
TilesetRect | null | yes | Editor folder icon | editor-only |
usesWizard |
bool | no | Created via corner/edge wizard | editor-only |
requiredBiomeValues |
string[] | no | Biome gating (v0.9+); group applies only in matching biomes | functional |
biomeRequirementMode |
int | no | 0/1 = how requiredBiomeValues combine (any vs all) |
functional |
collapsed |
bool | (older) | Editor UI state | editor-only |
{"uid":15,"active":true,"size":5,
"tileRectsIds":[[92],[93]],"alpha":1,"chance":1,"breakOnMatch":false,
"pattern":[0,0,-1,0,0, 0,0,1,0,0, 0,0,-1,0,0, 0,0,0,0,0, 0,0,0,0,0],
"flipX":false,"flipY":false,
"xModulo":1,"yModulo":1,"xOffset":0,"yOffset":0,
"tileXOffset":0,"tileYOffset":0,
"tileRandomXMin":0,"tileRandomXMax":0,"tileRandomYMin":0,"tileRandomYMax":0,
"checker":"None","tileMode":"Single","pivotX":0,"pivotY":0,
"outOfBoundsValue":null,"invalidated":false,
"perlinActive":false,"perlinSeed":8199321,"perlinScale":0.2,"perlinOctaves":2}| Field | Type | Null? | Meaning | Class |
|---|---|---|---|---|
uid |
int | no | Rule id. PRNG salt; stored in each baked tile's d[0] |
essential |
active |
bool | no | If false, produces no tiles | essential |
size |
int | no | Pattern is size*size; typically 1,3,5,7 (engine may use up to 9 internally) |
essential |
pattern |
int[] | no | size*size row-major match grid. Encoding in §6.5.1 |
essential |
tileRectsIds |
int[][] | no | Candidate tile selections; each inner array is one "stamp" (1 id for Single, many for Stamp). One inner array picked at random per match | essential |
chance |
number 0–1 | no | Probability rule applies in a matched cell (deterministic PRNG). 1 = always | essential |
breakOnMatch |
bool | no | If true and rule matches, no later rule paints that cell | essential |
flipX / flipY |
bool | no | Also try the mirrored pattern; a mirrored match sets the tile's flip bit | essential |
checker |
enum None/Horizontal/Vertical |
no | Brick-style offset filter combined with modulo | essential |
xModulo / yModulo |
int (>=1) | no | Rule only runs where (cell - offset) % modulo == 0 (spacing/gaps) |
essential |
xOffset / yOffset |
int | no | Phase offset for the modulo filter | essential |
tileMode |
enum Single/Stamp |
no | Single = 1 tile; Stamp = a block of tiles anchored by pivot | essential |
pivotX / pivotY |
number 0–1 | no | Stamp anchor within the matched cell (Stamp only) | essential (Stamp) |
tileXOffset / tileYOffset |
int | no | Pixel nudge applied to placed tile(s) | essential |
tileRandomXMin/Max, tileRandomYMin/Max |
int | no | Seeded random pixel jitter range | essential (if used) |
alpha |
number 0–1 | no | Opacity multiplier baked into each tile's a |
essential |
outOfBoundsValue |
int | null | yes | IntGrid value assumed for out-of-level cells the pattern samples. null = treat OOB as wildcard pass (engine uses -1 → skip constraint); a number = pretend that value (e.g. 1 to auto-close walls at edges) | essential |
perlinActive |
bool | no | Gate rule by Perlin noise (organic patches) | essential (if true) |
perlinScale / perlinOctaves |
number | no | Noise frequency/scale, octaves | essential (if perlin) |
perlinSeed |
number | no | Noise seed — always present even when perlin off. Round-trip-preserve | round-trip |
invalidated |
bool | no | Editor flag: rule needs re-evaluation. Editor-private | editor-only |
Confirmed by the ldtkimport engine doc comment AND the sample's real distinct values [-1000001,-2,-1,0,1,2,3]:
- Positive N → cell at that position must equal IntGrid value
N. - Negative -N → cell must NOT equal IntGrid value
N(any other value, including empty, passes). - 0 → "don't care".
1000001(RULE_PATTERN_ANYTHING) → cell must contain any nonzero IntGrid value ("anything but empty").-1000001(RULE_PATTERN_NOTHING) → cell must be empty (==0).
Engine source (verbatim, ldtkimport Rule.h): "A value of 1,000,001 (one million one) is special, it means 'Anything'... Likewise with the negative, -1000001 means 'Nothing'... A value of 0 means we don't care about the cell at that position."
Match test (engine matchesCell):
if (patternValue == ANYTHING && intGridValue == 0) -> fail
if (patternValue == NOTHING && intGridValue != 0) -> fail
if (patternValue > 0 && patternValue != ANYTHING && intGridValue != patternValue) -> fail
if (patternValue < 0 && patternValue != NOTHING && intGridValue == -patternValue) -> fail
// 0 always passes
Pattern center = index (size*size)/2 (the cell the tile is painted on). E.g. 3×3 [0,1,0, 1,-1,1, 0,1,0]: N/E/S/W must be 1, center must NOT be 1, corners ignored.
Engine-confirmed order:
- Iterate rule groups top→bottom, within each group rules top→bottom.
breakOnMatchfrom an earlier rule blocks later rules on that cell. - Skip group if
!active(or optional-and-not-enabled). Skip rule if!activeorchance<=0. - Modulo/checker filter per cell: e.g.
(cellY - yOffset) % yModulo == 0(and X analog); checker adds a brick offset. Fail → cell skipped for this rule. - Chance (deterministic):
getRandomIndex(randomSeed + uid, cellX, cellY, 100) >= chance*100→ skip. So per-instanceseed+ ruleuid+ cell coords fully determine randomness (reproducible). - Perlin gate (if
perlinActive): noise(scale, octaves, perlinSeed) thresholded → skip. - Pattern match (§6.5.1) using
outOfBoundsValuefor OOB cells; ifflipX/flipY, also try mirrored sampling (a mirrored match sets the tile's flip bits). - On match: pick a random entry from
tileRectsIds, place 1 tile (Single) or a block (Stamp, anchored by pivot, withsize), applyingtileXOffset/tileYOffset+ jitter,alpha, flip bits.breakOnMatchmarks the cell Final.
Importer decision: consume baked vs re-run
- Consume
autoLayerTiles(recommended): simplest, pixel-exact, no need to implement matching/perlin/PRNG. Each entry already has finalpx,src,f,t,a. This is what LDtk's own game-side loaders do. Use this unless you need runtime/procedural regeneration. - Re-run rules (only for runtime procedural maps / runtime CSV edits): requires faithfully reproducing the PRNG (
randomSeed+uid, cell coords,getRandomIndex), modulo/checker, perlin, flips, breakOnMatch, stamps. Higher fidelity risk.
autoLayerTiles is reproducible from defs + CSV + seed — it is computed-then-cached by the editor. Preserve seed verbatim (sample: Collisions seed:4084837) to reproduce the exact bake.
All tileset definitions are per-project, in defs.tilesets[]. The docs call this "the most important part among project definitions... If you only had to parse one definition section, that would be the one." Each entry describes ONE imported atlas image (or the internal icon atlas); layers/entities/enums/fields reference it by uid.
| Field | Type | Null? | Derived? | Meaning |
|---|---|---|---|---|
uid |
integer | no | authoritative | "Unique Int identifier". Round-trip critical |
identifier |
string | no | authoritative | "User defined unique identifier" |
relPath |
string | null | yes | authoritative | "Path to the source file, relative to the current project JSON file. It can be null if no image was provided, or when using an embed atlas." Forward-slash, project-relative |
embedAtlas |
enum ["LdtkIcons", null] |
yes | authoritative | "If this value is set, then it means that this atlas uses an internal LDtk atlas image instead of a loaded one." Only non-null value is "LdtkIcons" |
pxWid |
integer | no | authoritative* | "Image width in pixels" (sourced from image, stored) |
pxHei |
integer | no | authoritative* | "Image height in pixels" |
tileGridSize |
integer | no | authoritative | Tile size in px (square). Basis for all src math |
__cWid |
integer | no | DERIVED | "Grid-based width" = columns = floor((pxWid - padding*2 + spacing) / (tileGridSize + spacing)) |
__cHei |
integer | no | DERIVED | "Grid-based height" = rows, analogous from pxHei |
padding |
integer | no | authoritative | "Distance in pixels from image borders" — margin before first tile |
spacing |
integer | no | authoritative | "Space in pixels between all tiles" — gutter between cells |
tags |
string[] | no | authoritative | "An array of user-defined tags to organize the Tilesets" (NOT per-tile) |
tagsSourceEnumUid |
integer | null | yes | authoritative | "Optional Enum definition UID used for this tileset meta-data". Supplies values for enumTags. Null = none |
customData |
TileCustomMetadata[] | no | authoritative | "An array of custom tile metadata". See §7.4. Empty [] when unused |
enumTags |
EnumTagValue[] | no | authoritative | "Tileset tags using Enum values specified by tagsSourceEnumId". See §7.4. Empty [] when unused |
savedSelections |
object[] | no | editor-private | "Array of group of tiles selections, only meant to be used in the editor". Preserve verbatim. See §7.5 |
cachedPixelData |
object | null | yes | DERIVED/cache | "used internally for various optimizations. It's always synced with source image changes." See §7.5 |
Doc typo: the
enumTagsdescription spells the binding fieldtagsSourceEnumIdbut the actual JSON key istagsSourceEnumUid— trusttagsSourceEnumUid.
Verbatim — real tileset def (sample.ldtk):
{
"__cWid": 12, "__cHei": 32,
"identifier": "Cavernas_by_Adam_Saltsman_Extended",
"uid": 1, "relPath": "Cavernas_by_Adam_Saltsman-Extended.png", "embedAtlas": null,
"pxWid": 96, "pxHei": 256, "tileGridSize": 8, "spacing": 0, "padding": 0,
"tags": [], "tagsSourceEnumUid": null, "enumTags": [], "customData": [],
"savedSelections": [ {"ids":[20,21,22],"mode":"Random"}, {"ids":[9,10],"mode":"Random"} ],
"cachedPixelData": { "opaqueTiles":"111111111111...", "averageColors":"f123f224f224..." }
}Sanity: pxWid 96 / tileGridSize 8 = 12 = __cWid; pxHei 256 / 8 = 32 = __cHei (exact because padding=spacing=0).
- LDtk ships a built-in tileset Internal_Icons:
embedAtlas:"LdtkIcons",relPath:null. Its image is bundled inside LDtk, not on disk. - If
embedAtlas == "LdtkIcons", source the image from LDtk's internal icon atlas (or ship a copy); do NOT attempt to resolverelPath. - Docs-vs-reality: the schema/quicktype historically typed
relPathas non-nullableString, but real files (any project with Internal_Icons) emitrelPath:null(corrected to["string","null"]; see deepnight/ldtk issue #664). TreatrelPathas nullable always; treat non-nullembedAtlasas the signal that there is no external file.
A tile is identified by a single integer Tile ID (t, also called coordId). The atlas is row-major. __cWid in these formulas is the TILESET def's column count, not the layer instance's same-named field — easy bug.
(a) Tile ID → grid coordinates:
gridTileX = t % __cWid // tileset's __cWid (columns)
gridTileY = floor(t / __cWid)
(b) Grid coordinates → pixel src (top-left in atlas):
src.x = padding + gridTileX * (tileGridSize + spacing)
src.y = padding + gridTileY * (tileGridSize + spacing)
The tile occupies tileGridSize × tileGridSize px. spacing is added per step (not after the last tile).
(c) Inverse (pixel src → Tile ID), for export:
gridTileX = (src.x - padding) / (tileGridSize + spacing)
gridTileY = (src.y - padding) / (tileGridSize + spacing)
t = gridTileX + gridTileY * __cWid
src is fully derivable from t + the tileset def, and vice versa. t is the more authoritative/compact identity; src is precomputed pixels. On export, keep them consistent — recompute src from t if you ever mutate t. px (tile position in the LAYER) is independent and lives in the layer instance.
Both stored on the tileset def but indexed by Tile ID.
(a) customData — free-form per-tile strings. TileCustomMetadata:
| Field | Type | Null? | Meaning |
|---|---|---|---|
tileId |
integer | no | The Tile ID this metadata attaches to |
data |
string | no | Opaque user string. Editor treats it as plain text; convention is JSON (e.g. collision polygons). LDtk does NOT parse it — parse defensively |
Example: { "tileId": 42, "data": "{\"collider\":[[0,0],[8,0],[8,8]]}" }. Only tiles WITH data appear.
(b) enumTags — enum-value tagging of tiles. EnumTagValue:
| Field | Type | Null? | Meaning |
|---|---|---|---|
enumValueId |
string | no | The enum value id (matches a value id in the enum referenced by tagsSourceEnumUid) |
tileIds |
integer[] | no | Every Tile ID tagged with that enum value |
One array element per used enum value, each holding all tile IDs carrying that tag. Resolve by joining tagsSourceEnumUid → defs.enums[].values[].id. A non-null tagsSourceEnumUid with empty enumTags is valid (enum bound, nothing tagged yet).
savedSelections — array of { "ids":[int...], "mode":<enum> }. ids = tile IDs in a saved brush group; mode = how it paints (observed "Random"; "Stamp" also exists). Purely an editor convenience; preserve verbatim (treat mode as an open-ish enum string).
cachedPixelData — object { opaqueTiles, averageColors } (whole field nullable):
| Field | Type | Null? | Meaning |
|---|---|---|---|
opaqueTiles |
string | no | "An array of 0/1 bytes, encoded in Base64, that tells if a specific TileID is fully opaque (1) or not (0)". One byte per tile, tile-ID order |
averageColors |
string | yes | "Average color codes for each tileset tile (ARGB format)" — concatenated hex, 4 chars per tile |
"Always synced with source image changes" → fully regenerable from the image. Safe to recompute; preserve verbatim if you can't re-derive identically (keep encoding: base64 for opaqueTiles, packed ARGB hex for averageColors; both length-coupled to __cWid * __cHei).
Scope: defs.entities[] (EntityDef), EntityDef.fieldDefs[] (FieldDef), entityInstances[] (EntityInstance), fieldInstances[] (FieldInstance), defs.enums[] + defs.externalEnums[] (EnumDef / EnumDefValues).
| Field | Type | Null? | Meaning |
|---|---|---|---|
identifier |
string | no | Unique entity name |
uid |
integer | no | Project-unique; instances reference via defUid. Authoritative |
width / height |
integer | no | Default pixel dims |
color |
string #rrggbb |
no | Base entity color (UI) |
pivotX / pivotY |
number 0..1 | no | Pivot as fraction of size |
renderMode |
enum | no | Rectangle, Ellipse, Tile, Cross |
tileRenderMode |
enum | no | Cover, FitInside, Repeat, Stretch, FullSizeCropped, FullSizeUncropped, NineSlice |
nineSliceBorders |
int[] | no | 4 ints [up,right,down,left]. Empty [] unused |
tilesetId |
integer | yes | Tileset UID for optional tile display |
tileRect |
TilesetRect | yes | Rectangle in tileset to display. Replaces deprecated tileId (removed v1.2.0) |
tileId |
integer | yes | DEPRECATED (removed 1.2.0) → tileRect |
uiTileRect |
TilesetRect | yes | Overrides tileRect for UI/panels only |
fillOpacity / lineOpacity / tileOpacity |
number 0..1 | no | Opacities |
hollow |
boolean | no | Draw body outline only |
keepAspectRatio |
boolean | no | Lock aspect when resizable both axes |
resizableX / resizableY |
boolean | no | Per-axis resizing |
minWidth / maxWidth / minHeight / maxHeight |
integer | yes | Resize constraints (null = unconstrained) |
maxCount |
integer | no | Max instances (0 = unlimited) |
limitBehavior |
enum | no | DiscardOldOnes, PreventAdding, MoveLastOne |
limitScope |
enum | no | PerLayer, PerLevel, PerWorld |
exportToToc |
boolean | no | List all instances in project TOC |
allowOutOfBounds |
boolean | no | Instances may sit outside level bounds |
showName |
boolean | no | Editor displays the name label |
doc |
string | yes | Designer documentation |
tags |
string[] | no | Classifying tags (copied to instance __tags) |
fieldDefs |
FieldDef[] | no | Custom field defs (§8.2). Empty [] when none |
Legacy: pre-1.0 files use
maxPerLevelinstead ofmaxCount+limitScope, andeditorAlwaysShowon fieldDefs. Tolerate both.
{ "identifier":"Player", "uid":27, "width":8, "height":10, "color":"#00BFFF", "fieldDefs":[] }The pairing of type (machine) and __type (human) is central.
| Field | Type | Null? | Meaning |
|---|---|---|---|
identifier |
string | no | Field name (= FieldInstance.__identifier) |
uid |
integer | no | Instances link via defUid. Authoritative |
type |
string | no | Internal token: F_Int, F_Float, F_String, F_Text, F_Bool, F_Color, F_Enum(<enumUid>), F_Point, F_Path, F_EntityRef, F_Tile |
__type |
string | no | Human type: Int,Float,Bool,String,Multilines,Color,LocalEnum.<Name>/ExternEnum.<Name>,Point,FilePath,Tile,EntityRef, optionally Array<...> |
isArray |
boolean | no | If true, __type becomes Array<X> and __value is a JSON array |
canBeNull |
boolean | no | Whether value (or array elements) may be null |
arrayMinLength / arrayMaxLength |
integer | yes | Array length bounds (null = unbounded) |
min / max |
number | yes | Numeric value bounds (Int/Float) |
defaultOverride |
tagged value | yes | "Default value if selected value is null or invalid." Editor-tagged form (like realEditorValues entries), not the raw scalar |
regex |
string | yes | Validation pattern |
textLanguageMode |
enum | yes | Text syntax highlight (LangC, LangJson, LangMarkdown, ...) |
editorDisplayMode |
enum | no | Hidden,ValueOnly,NameAndValue,EntityTile,LevelTile,Points,PointStar,PointPath,PointPathLoop,RadiusPx,RadiusGrid,ArrayCountWithLabel,ArrayCountNoLabel,RefLinkBetweenPivots,RefLinkBetweenCenters |
editorDisplayPos |
enum | no | Above,Center,Beneath |
editorDisplayColor |
string | yes | Override display color |
editorTextPrefix / editorTextSuffix |
string | yes | Text wrapping around displayed value |
useForSmartColor |
boolean | no | This field's color feeds the entity __smartColor |
tilesetUid |
integer | yes | Tileset used for a Tile field |
acceptFileTypes |
string[] | yes | Allowed extensions for FilePath fields |
allowedRefs |
enum | no | EntityRef constraint: Any,OnlySame,OnlyTags,OnlySpecificEntity |
allowedRefsEntityUid |
integer | yes | Target entity UID when OnlySpecificEntity |
allowedRefTags |
string[] | no | Allowed target tags when OnlyTags |
symmetricalRef |
boolean | no | EntityRef link is bidirectional |
allowOutOfLevelRef |
boolean | no | EntityRef may target entities in other levels |
exportToToc / searchable |
boolean | no | TOC export / searchable in editor |
doc |
string | yes | Designer help |
{ "identifier":"Integer","__type":"Int","uid":16,"type":"F_Int","isArray":false,
"canBeNull":false,"editorDisplayMode":"NameAndValue","editorDisplayPos":"Center",
"min":null,"max":null }| Field | Type | Null? | Authoritative? | Meaning |
|---|---|---|---|---|
iid |
string (UUID) | no | YES (round-trip) | Unique instance id, target of EntityRef links. Never regenerate |
defUid |
integer | no | YES | Links to EntityDef.uid |
__identifier |
string | no | derived | EntityDef.identifier |
__grid |
int[] [x,y] |
no | derived | Grid-cell coords (px/cellSize) |
__pivot |
number[] [x,y] |
no | derived | Pivot 0..1 from def |
__tags |
string[] | no | derived | Copy of EntityDef.tags |
__tile |
TilesetRect | yes | derived | Tile to display ("can be the field own Tile, or some other Tile guessed from the value, like an Enum") |
__smartColor |
string #rrggbb |
no | derived | "smart color, guessed from either Entity definition, or one of its field instances" |
__worldX / __worldY |
integer | yes | derived | World-space coords. Only present (non-null) in GridVania or Free layouts |
px |
int[] [x,y] |
no | YES | Pixel coords relative to the level (NOT world). True placement source |
width / height |
integer | no | YES (if resized) | Instance pixel dims (may differ from def if resizable) |
fieldInstances |
FieldInstance[] | no | YES | All custom field values (§8.4). Empty [] if none |
{
"__identifier": "Player", "__grid": [1,3], "__pivot": [0,0], "__tags": [],
"__tile": null, "__smartColor": "#94D9B3",
"iid": "a9e6f630-dbe0-11ec-9df7-0dc6c63805d5",
"width": 16, "height": 16, "defUid": 17, "px": [8,24],
"fieldInstances": [
{ "__identifier":"NodeType","__value":"KinematicBody2D","__type":"String","__tile":null,"defUid":20,"realEditorValues":[] },
{ "__identifier":"Health","__value":10,"__type":"Int","__tile":null,"defUid":21,"realEditorValues":[] }
]
}px is level-relative; world coords come from __worldX/__worldY (only in GridVania/Free). (The TOC representation uses distinct keys worldX/worldY/widPx/heiPx/fields.)
| Field | Type | Null? | Meaning |
|---|---|---|---|
__identifier |
string | no | = FieldDef.identifier |
__type |
string | no | Human type; same vocabulary as FieldDef.__type (incl. Array<...>, LocalEnum.X) |
__value |
varies | yes | Parsed value (shape per __type — table below). Game code reads THIS |
__tile |
TilesetRect | yes | Tile for display (own Tile field, or guessed from value) |
defUid |
integer | no | Links to FieldDef.uid |
realEditorValues |
array | no | Editor-private raw values — round-trip critical. Array of { "id":"V_String"|"V_Int"|"V_Float"|"V_Bool"|..., "params":[...] }. "Only used by editor"; read __value instead, preserve this verbatim. Empty [] when unset/default |
__value shape by __type (authoritative — JSON_DOC.md):
__type |
__value JSON shape |
Notes |
|---|---|---|
Int |
number (integer) | Defaults to min when unset, to comply with def |
Float |
number | |
Bool |
boolean | |
String |
string | |
Multilines |
string (with \n) |
__type is Multilines only with the "Use Multilines type" advanced option; otherwise multiline text stays String |
FilePath |
string (rel path) | Plain string, e.g. "README.md" |
Color |
string #rrggbb |
Hex string. NB: differs from EnumDefValues.color (integer) |
LocalEnum.X / ExternEnum.X |
string | Selected enum value id (e.g. "A"). null if unset |
Point |
object GridPoint {cx,cy} |
Grid coords, not pixels |
Tile |
object TilesetRect {tilesetUid,x,y,w,h} |
|
EntityRef |
object EntityReferenceInfos {entityIid,layerIid,levelIid,worldIid} |
Resolve via the four IIDs |
Array<X> |
JSON array of the X shape | Empty [] allowed |
Verbatim field instances (note populated realEditorValues and the Color string-vs-int divergence):
{"__identifier":"Point","__value":{"cx":3,"cy":18},"__type":"Point","defUid":24,"realEditorValues":[{"id":"V_String","params":["3,18"]}]}
{"__identifier":"Enum","__value":"A","__type":"LocalEnum.SomeEnum","defUid":22,"realEditorValues":[{"id":"V_String","params":["A"]}]}
{"__identifier":"Color","__value":"#9B2ABC","__type":"Color","defUid":23,"realEditorValues":[{"id":"V_Int","params":[10169020]}]}
{"__identifier":"Array_Integer","__value":[1,2,3],"__type":"Array<Int>","defUid":25,"realEditorValues":[{"id":"V_Int","params":[1]},{"id":"V_Int","params":[2]},{"id":"V_Int","params":[3]}]}
{"__identifier":"Array_points","__value":[{"cx":11,"cy":21},{"cx":11,"cy":23},{"cx":13,"cy":23}],"__type":"Array<Point>"}Color.__value is "#9B2ABC" while realEditorValues carries the integer form 10169020 (= 0x9B2ABC).
| Field | Type | Null? | Meaning |
|---|---|---|---|
uid |
integer | no | Referenced inside F_Enum(<uid>). Authoritative |
identifier |
string | no | Enum name (the X in LocalEnum.X) |
values |
EnumDefValues[] | no | Ordered possible values (§8.6) |
iconTilesetUid |
integer | yes | Tileset providing value icons (when values use tileRect) |
externalRelPath |
string | yes | If set, enum imported from an external file; local enums = null |
externalFileChecksum |
string | yes | Checksum of external source for change detection |
tags |
string[] | no | Organizational tags |
Local vs external: Local enums are authored in the editor (defs.enums[]). External enums are synced from source files (.hx/.cs/.json/text) and live in defs.externalEnums[]. Abstract Haxe enums are not supported. A round-trip tool must keep each enum in its correct array and preserve externalRelPath/externalFileChecksum.
| Field | Type | Null? | Meaning |
|---|---|---|---|
id |
string | no | The enum value (what __value equals on an Enum field instance) |
color |
integer | no | Optional color as INTEGER (0xRRGGBB), e.g. 6710903. (Contrast: Color field __value is a hex string) |
tileRect |
TilesetRect | yes | Icon rectangle in iconTilesetUid. Replaces deprecated tileId/__tileSrcRect (removed 1.4.0) |
tileId |
integer | yes | DEPRECATED → tileRect |
__tileSrcRect |
int[] [x,y,w,h] |
yes | DEPRECATED → tileRect |
Modern (1.5.x): { "id":"Pickaxe", "tileRect":{"tilesetUid":31,"x":0,"y":0,"w":16,"h":16}, "color":6710903 }. Legacy (still loaded): {"id":"A","tileId":60,"__tileSrcRect":[192,48,16,16]}. An importer must read either; map __tileSrcRect [x,y,w,h] + iconTilesetUid → tileRect {x,y,w,h}.
- GridPoint —
{ "cx": int, "cy": int }. Grid-cell coords (multiply by layer cellSize for pixels). Used as Point__valueand Array elements. - TilesetRect —
{ "tilesetUid": int, "x": int, "y": int, "w": int, "h": int }. Pixel rect (top-left x,y + w,h) within a tileset. Can span MULTIPLE tiles (w/h need not equaltileGridSize), unlike a tile instance which is always one grid cell. Used by__tile, EntityDef.tileRect/uiTileRect, EnumDefValues.tileRect, IntGridValueDefinition.tile, and Tile__value. Example:{ "tilesetUid":31, "x":0, "y":0, "w":16, "h":16 }. - EntityReferenceInfos —
{ "entityIid", "layerIid", "levelIid", "worldIid" }(all strings). Full path to a referenced entity; EntityRef__value. Resolve by matching the four IIDs (worldIidneeded for multi-world projects).
The JSON Schema is Draft-7, auto-generated from Haxe source (the file notes it must not be hand-edited). Schema, docs, and the 1.5.3 sample agree closely. Key dated changes:
- 1.0.0:
iids introduced across project/world/level/layer/entity;intGridCsvreplaced object-arrayintGrid; neighbourlevelIid(replacinglevelUid);__smartColoradded; embed atlas (LdtkIcons) added. - 1.2.0: entity
tileIdremoved →tileRect. NeighbourlevelUiddeprecated →levelIid. IntGrid defautoTilesetDefUidmerged intotilesetDefUid. - 1.2.4: TOC
instancesData[]introduced (see §10.6). - 1.3.0+: Multi-worlds
worlds[](preview); when MultiWorlds is enabled, rootworldLayout/worldGridWidth/Height/defaultLevelWidth/Heightgonull. - 1.4.0: enum-value
tileId/__tileSrcRectremoved →tileRect. Neighbourdirgains<,>,o(depth/overlap).__neighboursincludes overlapping levels. - 1.5.0 / 1.5.3: corner neighbour dirs
nw/ne/sw/se.
Version-aware handling:
- Check
jsonVersion/appBuildIdat project root to branch parsing. - Read both root-level and
worlds[]world params (MultiWorlds nulls the root copies). - Follow
externalRelPathwhenexternalLevels:true; keeplayerInstances:nullin the project file. - Map on read: enum/entity
tileId→tileRect; enum__tileSrcRect→tileRect; neighbourlevelUid→levelIid;intGrid→intGridCsv. Emit current forms. - Handle corner neighbour dirs only when
jsonVersion ≥ 1.5.0. - Legacy
maxPerLevel/editorAlwaysShowvs modernmaxCount+limitScope+editorDisplayMode.
A toc[] entry historically had instances[] (array of EntityReferenceInfos). Newer format uses instancesData[] = { iids:{...the 4 iids...}, worldX, worldY, widPx, heiPx, fields }, where fields holds values of entity fields flagged exportToToc. The legacy instances array is still emitted only if the ExportOldTableOfContentData flag is set. A faithful round-trip should preserve whichever form(s) the source emitted (honor the flag), or regenerate both consistently.
Identity & version
- Every
iid(project, world, level, layer instance, entity instance) - All
uid/defUid/layerDefUid/tilesetUid/tagsSourceEnumUid/level.uid; keepnextUid >= max(uid)+1 -
dummyWorldIid(the implicit single world's identity in non-MultiWorlds mode) -
appBuildId,jsonVersion - EntityRef quadruple (
entityIid/layerIid/levelIid/worldIid) — all four or the ref dangles
Tiles & layers
- Tile
darray ([ruleId,coordId]auto vs[coordId]tile-layer) — editor-private, not reconstructable; array length is itself a signal of origin - Full
gridTiles/autoLayerTilesarray order (= z-order; stacked tiles depend on it) - Tile
t,f,px,a(andsrcconsistent witht) - Layer
seed(re-rolls auto-rule RNG if changed),optionalRules,intGridCsv(authoritative IntGrid; 0=empty, values start at 1),pxOffsetX/Y
Rules
- Rule
patternsentinels1000001(Anything) /-1000001(Nothing) — undocumented; pass through and match with the special-case logic -
perlinSeed(always present even when perlin off),invalidated(editor dirty flag),outOfBoundsValue(null vs number changes edge behavior) -
intGridValueskeyed by.value, never array index (order is explicitly unrelated to values)
Entities, fields, enums
-
FieldInstance.realEditorValues({id,params}raw editor form; not synthesizable from__value— Color stores int here vs hex in__value, Point stores"x,y") -
FieldDef.defaultOverride(tagged form, not a plain scalar) -
__valuesemantics +defUid+__typeon field instances - Entity
px(level-relative, the true placement source),width,height,fieldInstances -
EnumDefValues.coloris an INTEGER; Color field__valueis a#rrggbbSTRING — do not conflate -
EnumDef.externalRelPath/externalFileChecksum; place external enums indefs.externalEnums[]
Tilesets
-
relPath(nullable; round-trip null faithfully),embedAtlas(signals Internal_Icons) -
savedSelections(editor tile-group selections; treatmodeas open enum),customData.data(opaque string + itstileId),enumTags(per-tile enum tagging)
Levels & worlds
-
worldDepth,useAutoIdentifier,bgColor(nullable — preserve null-vs-explicit),bgRelPath,bgPos,bgPivotX/Y -
externalRelPathand thelayerInstancesnull-vs-[]-vs-populated tri-state -
worldX/worldY-1sentinel in Linear layouts (don't treat as a real coordinate; don't reorderlevels[]) - Level/world
fieldInstancespayloads
Project-level behavior
-
flags(e.g.MultiWorlds,ExportOldTableOfContentData,PrependIndexToLevelFileNames,UseMultilinesType),customCommands,identifierStyle, all export options - Whichever
tocform the source used (honorExportOldTableOfContentData)
- All
__-prefixed fields:__identifier,__type,__cWid/__cHei,__gridSize,__opacity,__pxTotalOffsetX/Y,__tilesetDefUid/__tilesetRelPath,__grid,__pivot,__tags,__worldX/Y,__smartColor,__tile,__bgColor,__bgPos,__neighbours - Tile
src(fromt+ tileset def — but recompute if you mutatet) - Tile
px(fromcoordIdind+__gridSize) -
toc/instancesData(from entity instances) -
cachedPixelData(opaqueTilesbase64,averageColorspacked ARGB — from the source image) -
__header__(cosmetic) -
autoLayerTilesin principle (from defs +intGridCsv+seed+ exact ruleset) — but only re-run rules if you must regenerate geometry; otherwise preserve the bake verbatim
If you don't fully implement LDtk's crop/adjacency/PRNG math, prefer to pass derived fields through unchanged rather than emit subtly wrong recomputed values that loose loaders will read.
__type:"IntGrid"can still populateautoLayerTiles. You cannot pick the tile array from__typealone — checkautoLayerTiles.length > 0. (§5.3)- External levels:
layerInstancesisnullin the.ldtk. You MUST followexternalRelPathto the.ldtkl.null(externalized) ≠[](genuinely layer-less). (§4.6, §5.1) - Two different
__cWids. Layer-instance__cWid(cells in the level) vs tileset-def__cWid(columns in the atlas). Thet→srcformula uses the tileset's. (§7.3) - Pattern sentinels
1000001/-1000001are undocumented in the official docs — only discoverable in real files + engine source. (§6.5.1) intGridValuesarray order ≠ IntGrid values. Key by.value. (§6.2)padding/spacingnon-zero breaks naivepxWid/tileGridSizecolumn counting — use the full formula. (§7.1, §7.3)- Color encodings diverge:
EnumDefValues.coloris an INTEGER; Color field__valueand entitycolor/__smartColorare#rrggbbSTRINGS. (§8.4, §8.6) - Linear layouts set
worldX/worldYto-1and rely onlevels[]array order;__neighboursis empty there. (§4.2, §4.4) relPathis nullable (schema once mistyped it non-null; issue #664).embedAtlas:"LdtkIcons"⇒ image is internal,relPathis null. (§7.2)- Stacked tiles at the same
pxare legal and intentional — array order is z-order; never dedupe or reorder. (§5.4) - No arbitrary tile rotation — only X/Y mirror via
f. 90°/270° cannot be represented; map to mirrored UVs for UE. (§5.4) - Deprecated tile refs coexist with replacements: entity
tileId(gone 1.2.0), enumtileId/__tileSrcRect(gone 1.4.0),intGrid(gone 1.0.0), neighbourlevelUid(deprecated 1.2.0). Read both; emit current. (§10) - Changing
seedsilently re-rolls auto-layer output — it is not cosmetic. (§5.1, §6.6) customData.datais opaque — LDtk never parses it. Parse defensively; never assume JSON. (§7.4)- Multi-worlds vs single-world is detected by
worlds[]non-empty, not a boolean. Root layout fields gonullin multi-worlds. (§4.1) __worldX/__worldYare null outside GridVania/Free — fall back to level +px. (§8.3)- Don't define which array a Tiles vs IntGrid+rules layer uses by reading
gridTiles/autoLayerTilesemptiness alone if you also need the IntGrid — they can both be non-empty on a rule-bearing IntGrid layer. (§5.3)