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, and both that package and@codexo/exojsare peer dependencies — install all three explicitly, on the same version:npm install @codexo/exojs @codexo/exojs-tilemap @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.
Activation
Pass ldtkExtension to ApplicationOptions.extensions. 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.
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 with an explicit descriptor or by path (the .ldtk extension is registered):
import { Asset } from '@codexo/exojs';
// Explicit type
const world = await loader.load(Asset.type('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');
Load .ldtk by an absolute URL, or tileset images fail to resolve
The loader resolves each tileset image against the map’s own URL with new URL(...), which needs an origin-qualified address. Pass an absolute https://… path, or set loader.basePath to an absolute origin — a bare relative or root-relative path fails tileset resolution.
External .ldtkl level files load automatically
With “Save levels to separate files” enabled, each level lives in a sibling <identifier>.ldtkl file — the loader detects and fetches these during load, so there’s nothing extra to do. This only matters if you call ldtkToTileMap on a raw document yourself, where unresolved external levels convert with empty layers.
ldtkMap loads the whole project at once
Every level — and every external .ldtkl file — is fetched and converted up front. That is the right trade for a game whose levels all fit in memory. To load levels individually instead, each with its own asset scope, use the ldtkProject type: see Worlds and level streaming.
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.getTileLayer('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 LDTK_FLIP_NONE, LDTK_FLIP_X, LDTK_FLIP_Y, and LDTK_FLIP_XY 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. - Streaming and spawning: Worlds and level streaming covers per-level loading with
ldtkProject, the world/neighbour model, and turning authored objects into game objects. - Sibling extension: the Tiled maps chapter covers the same activation model for the other map-editor adapter.
- Loading model: Loading & resources covers the asset pipeline the loader plugs into.

