LDtk levels
Load LDtk (.ldtk) level files and convert each level to a renderable TileMap through the official @codexo/exojs-ldtk extension.
LDtk levels
LDtk is a modern, open-source level editor. The official @codexo/exojs-ldtk
extension adds an ldtkMap asset type that loads an LDtk project (.ldtk) through the normal
Loader pipeline — fetching the JSON, loading every referenced tileset
image, and converting each LDtk level into a runtime TileMap you
can drop straight into the scene graph.
Note:
LdtkMapships as an official ExoJS extension package, separate from the core. It is built on@codexo/exojs-tilemap(a regular dependency, installed transitively — you do not need to add it yourself). Install@codexo/exojs-ldtkalongside@codexo/exojs:npm install @codexo/exojs @codexo/exojs-ldtk
Because each LDtk level becomes a generic TileMap, you render LDtk worlds with the same tilemap
node used everywhere else in the engine — there is no LDtk-specific renderer.
Like all extensions, the core ships nothing LDtk-specific — an Application only understands
.ldtk files once you activate the extension. There are two ways to do that.
Activation
Explicit (recommended)
Pass ldtkExtension to ApplicationOptions.extensions. This is explicit, tree-shakeable, and
order-independent — the extension is bound to exactly this Application:
import { Application } from '@codexo/exojs';
import { ldtkExtension } from '@codexo/exojs-ldtk';
const app = new Application({ extensions: [ldtkExtension] });
ldtkExtension depends on tilemapExtension, so activating it enables both loading and tilemap
rendering — the tile-chunk renderer bindings are materialised automatically. The package root
(@codexo/exojs-ldtk) is side-effect-free: importing it registers nothing globally.
/register convenience
For an app that always wants LDtk support, import the /register entry once at startup. It
registers ldtkExtension (and its tilemapExtension dependency) in the global ExtensionRegistry
and re-exports the same public API:
import '@codexo/exojs-ldtk/register';
import { Application } from '@codexo/exojs';
const app = new Application(); // picks up ldtkExtension automatically
The /register import must run before you construct the Application whose extensions are read
from the global registry. The explicit extensions: [...] form works regardless of import order.
Core-only
An Application constructed with an explicit empty list takes no extensions — not even
globally registered ones — and will not recognise .ldtk files:
import { Application } from '@codexo/exojs';
const app = new Application({ extensions: [] }); // core only; no LdtkMap
This is the same add-only model the Tiled maps extension uses.
Loading a world
Once the extension is active, load a .ldtk file by type token, by path (the .ldtk extension is
registered), or by the 'ldtkMap' config-map type name:
import { LdtkMap } from '@codexo/exojs-ldtk';
// By type token (most explicit)
const world = await loader.load(LdtkMap, 'https://example.com/levels/world.ldtk');
// By path — the `.ldtk` extension is registered, so the type is inferred
const sameWorld = await loader.load('https://example.com/levels/world.ldtk');
// By config-map type name — the public `'ldtkMap'` lookup
const fromConfig = await loader.load({
world: { type: 'ldtkMap', source: 'https://example.com/levels/world.ldtk' },
});
Note: the LDtk loader resolves tileset image paths against the map’s own URL with the
URLconstructor, which requires that URL to be absolute (origin-qualified). Load.ldtkfiles by an absolutehttps://…URL, or setloader.basePathto an absolute origin so relative map paths resolve to absolute URLs. A bare relative or root-relative path will fail tileset resolution.
Note: LDtk’s “Save levels to separate files” project option stores each level’s layers and fields in a sibling
<identifier>.ldtklfile instead of inline in the.ldtkdocument —loadLdtkMap()detects and fetches these external level files automatically as part of the load, so there is nothing extra to do on your end. If you callldtkToTileMapdirectly on a raw document whose external levels have not been resolved, those levels convert with empty layers instead (layerInstancesisnulluntil resolved).
The LdtkMap result
A loaded LdtkMap exposes one runtime TileMap per LDtk level, plus the raw parsed document:
world.levels; // readonly TileMap[] — one per level, in document order
world.getLevelByName('Level_0'); // TileMap | undefined — lookup by LDtk identifier
world.data; // the raw parsed LdtkData document
Each level’s TileMap carries the LDtk tile layers (as TileLayers) and entity layers (as data-only
ObjectLayers, with each entity’s position, size, and fields preserved as object properties). Entity
positions are corrected for LDtk’s per-entity __pivot anchor, so an object’s x/y always give the
bounding box’s top-left corner, regardless of which pivot the entity definition uses in the editor.
See Entity field values below for how individual field values convert.
World-space placement is stored in TileMap.properties (worldX / worldY), alongside ldtkUid /
ldtkIid identifiers and any custom fields set on the level itself in the LDtk editor (the
level’s own “Custom fields” tab — distinct from fields on the entities placed inside it), merged in
under their own field identifier:
level.properties.worldX; // number — level's world-space X position in pixels
level.properties.musicTrack; // whatever value the level's own custom field carries
Multi-world projects
LDtk projects can enable “Multi-Worlds” (an advanced project setting) to hold several independent
worlds — each with its own set of levels — in one .ldtk file. When that setting is on, levels live
under data.worlds[].levels instead of the root data.levels, which LDtk’s format then keeps empty.
The overwhelming majority of projects use a single world and never touch this setting — for those,
nothing below applies and world.levels behaves exactly as shown above. When multi-world is in
use, ExoJS handles it transparently: LdtkMap.levels and getLevelByName() flatten every world’s
levels into the same single ordered list, so the rest of this guide’s API is unchanged either way.
The only difference is that each converted level’s TileMap.properties additionally carries the
owning world’s id under the reserved ldtkWorldIid key:
level.properties.ldtkWorldIid; // string | undefined — set only for multi-world projects
Rendering a level
Wrap a level’s TileMap in a TileMapNode and add it to the scene. TileMapNode is the generic
tilemap node from @codexo/exojs-tilemap (the package LDtk builds on):
import { TileMapNode } from '@codexo/exojs-tilemap';
const level = world.getLevelByName('Level_0') ?? world.levels[0];
scene.root.addChild(new TileMapNode(level));
To stream a multi-level world, add a node per level — each level is its own TileMap:
for (const level of world.levels) {
scene.root.addChild(new TileMapNode(level));
}
TileMapNode handles per-chunk culling and renders the level’s layers in document order. For
interleaving actors between tile layers, use TileMap.createView() to obtain independently
placeable layer nodes — see the TileMapView API.
Entity field values
Every custom field an entity carries in the LDtk editor ends up as a property on that entity’s
TileMapObject (found on the entity layer’s ObjectLayer). Scalar field types — Int, Float, Bool,
String, Multilines, Color, FilePath, Enum — convert directly to their JS scalar. Three structured
field types — Point, EntityRef, and Tile — convert into the tagged TilePropertyValue
variants from @codexo/exojs-tilemap, discriminated by a kind string:
import { TilePropertyKind } from '@codexo/exojs-tilemap';
const entities = level.getObjectLayer('Entities');
const chest = entities?.objects.find((object) => object.type === 'Chest');
const linkedSwitch = chest?.properties.linkedSwitch;
if (linkedSwitch && typeof linkedSwitch === 'object' && 'kind' in linkedSwitch) {
switch (linkedSwitch.kind) {
case TilePropertyKind.Point:
linkedSwitch.cx; // grid-cell X — a Point field
linkedSwitch.cy;
break;
case TilePropertyKind.ObjectRef:
linkedSwitch.id; // the referenced entity's entityIid — an EntityRef field
linkedSwitch.levelIid; // LDtk navigation context, when set
break;
case TilePropertyKind.TileRef:
linkedSwitch.tilesetUid; // a Tile field: region within a tileset, in pixels
linkedSwitch.x;
linkedSwitch.y;
break;
}
}
- Point fields (
{ kind: 'point', cx, cy }) give the field’s grid-cell coordinates. - EntityRef fields (
{ kind: 'objectRef', id, layerIid?, levelIid?, worldIid? }) reference another entity —idis that entity’sentityIid, matched againstiid-based lookups rather than the numericTileMapObject.idExoJS assigns during conversion. The optionallayerIid/levelIid/worldIidare LDtk’s own navigation context for locating the reference without searching the whole project. - Tile fields (
{ kind: 'tileRef', tilesetUid, x, y, w, h }) reference a pixel region within a tileset — useful for entities whose “icon” is picked from a tileset in the editor.
Array<T> fields (Array<Int>, Array<Point>, and so on) convert to a readonly array of the same
per-element conversion — an Array<Point> field becomes readonly TilePropertyPoint[], for example.
Unset (null) fields, and unset elements within an array field, are omitted entirely rather than
carried through as null properties.
IntGrid values
IntGrid layers classify each grid cell with a small integer you assign meaning to in the LDtk editor
(e.g. 1 = Wall, 2 = Water). Query a converted IntGrid layer’s classification with
getLdtkIntGridValueAt(layer, x, y):
import { getLdtkIntGridValueAt } from '@codexo/exojs-ldtk';
const collision = level.getLayerByName('Collision');
const cell = collision && getLdtkIntGridValueAt(collision, 3, 5);
if (cell) {
cell.value; // the raw integer assigned in the editor
cell.identifier; // the name given to that value in the editor, or null
cell.color; // the editor's swatch colour, as a CSS hex string
}
getLdtkIntGridValueAt returns undefined for an out-of-bounds coordinate, an empty cell (raw value
0), a raw value with no matching definition, or a TileLayer that was not converted from an IntGrid
layer in the first place — you don’t need to special-case any of those yourself.
Under the hood, the raw per-cell data is stored as JSON-encoded strings under two reserved
TileLayer.properties keys — ldtkIntGridCsv (the flat number[] CSV, index y * layer.width + x)
and ldtkIntGridValues (the layer’s raw-value → name/colour definitions). Both exist for advanced
consumers that want the unprocessed data; prefer getLdtkIntGridValueAt for everyday lookups.
Converting manually (advanced)
The loader does the fetch-and-convert for you, but the conversion step is also available directly.
ldtkToTileMap turns a raw LdtkData document into an LdtkMap of runtime TileMaps — useful for
custom pipelines or for converting data you already hold in memory:
import { ldtkToTileMap } from '@codexo/exojs-ldtk';
const converted = ldtkToTileMap(world.data, { source: 'https://example.com/levels/world.ldtk' });
Without an options.tilesets map, tile layers are created with correct dimensions but no tile data —
the asset-loading path supplies the runtime tilesets for you. Tile flip flags are exposed as the
constants ldtkFlipNone, ldtkFlipX, ldtkFlipY, and ldtkFlipXy for reading raw LdtkTileData.
Texture ownership
Tileset textures are loaded through the Loader and owned by the loader cache — not by the
LdtkMap. LdtkMap.destroy() destroys the owned runtime TileMaps but deliberately does not
unload tileset textures (they may be shared) or remove any scene nodes — the application owns those.
Where to look next
- Package README:
@codexo/exojs-ldtk— install, entry points, and the supported-feature matrix. - API reference:
LdtkMapdocumentslevels,getLevelByName, anddata;TileMapandTileMapNodedocument the runtime tilemap and its renderer. - Sibling extension: the Tiled maps chapter covers the
same explicit-vs-
/registeractivation for the other map-editor adapter. - Loading model: Loading & resources covers the asset pipeline the loader plugs into.