Guide

Guide Assets LDtk levels

LDtk levels

Load LDtk (.ldtk) level files and convert each level to a renderable TileMap through the official @codexo/exojs-ldtk extension.

Intermediate ~7 min read

What you'll learn

  • activate the LDtk extension explicitly or via /register
  • load a .ldtk world and render each level as a TileMap
  • understand tileset texture ownership and absolute-URL resolution

Before you start

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: LdtkMap ships 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-ldtk alongside @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

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 URL constructor, which requires that URL to be absolute (origin-qualified). Load .ldtk files by an absolute https://… URL, or set loader.basePath to 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>.ldtkl file instead of inline in the .ldtk document — 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 call ldtkToTileMap directly on a raw document whose external levels have not been resolved, those levels convert with empty layers instead (layerInstances is null until 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 — id is that entity’s entityIid, matched against iid-based lookups rather than the numeric TileMapObject.id ExoJS assigns during conversion. The optional layerIid / levelIid / worldIid are 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: LdtkMap documents levels, getLevelByName, and data; TileMap and TileMapNode document the runtime tilemap and its renderer.
  • Sibling extension: the Tiled maps chapter covers the same explicit-vs-/register activation for the other map-editor adapter.
  • Loading model: Loading & resources covers the asset pipeline the loader plugs into.