Guide

GuideAssetsTiled maps

Tiled maps

Load Tiled (.tmj) tilemaps as assets through the official @codexo/exojs-tiled extension.

Intermediate~10 min read

What you'll learn

  • activate the official Tiled extension via ApplicationOptions.extensions
  • load a .tmj map through the loader and read its layers and tilesets
  • understand tileset texture ownership and the supported format scope

Before you start

Tiled maps

Tiled is a popular open-source map editor. The official @codexo/exojs-tiled extension adds a TiledMap asset type that loads Tiled’s JSON map format (.tmj) through the normal Loader pipeline — fetching the map, resolving its tileset image references, and returning a TiledMap you can read for layer and tile data.

Note: TiledMap ships as an official ExoJS extension package, separate from the core. It is built on @codexo/exojs-tilemap, and both that package and @codexo/exojs are peer dependencies — install all three explicitly, on the same version:

npm install @codexo/exojs @codexo/exojs-tilemap @codexo/exojs-tiled

Like all extensions, the core ships nothing Tiled-specific — an Application only understands .tmj files once you activate the extension.

Activation

Pass tiledExtension to ApplicationOptions.extensions. The extension is bound to exactly this Application:

import { Application } from '@codexo/exojs';
import { tiledExtension } from '@codexo/exojs-tiled';

const app = new Application({ extensions: [tiledExtension] });

The package root (@codexo/exojs-tiled) is side-effect-free: importing it registers nothing globally. You decide which Application gets the extension.

Core-only

An Application constructed with an explicit empty list takes no extensions — not even globally registered ones — and will not recognise .tmj files:

import { Application } from '@codexo/exojs';

const app = new Application({ extensions: [] }); // core only; no TiledMap

Loading a .tmj here throws No renderer/handler style errors directing you to import the extension. This is the same add-only model the Particles extension uses.

Loading a map

Once the extension is active, load a .tmj map with an explicit descriptor or by path. Both resolve to the same handler.

import { Asset } from '@codexo/exojs';

// Explicit type
const map = await loader.load(Asset.type('tiledSource', 'levels/forest.tmj'));

// By path — the `.tmj` extension is registered, so the type is inferred
const sameMap = await loader.load('levels/forest.tmj');

The handler fetches the JSON (cache-routed), validates it, then loads every tileset image it references as a Texture through the same loader, returning a fully-resolved TiledMap.

Reading the map

TiledMap exposes the parsed metadata, layers, and the resolved tileset textures:

map.width;            // map width in tiles
map.height;           // map height in tiles
map.tileWidth;        // tile width in pixels
map.tileHeight;       // tile height in pixels
map.layers;           // readonly tile/object layers
map.tilesets;         // readonly tileset definitions
map.tilesets[0].texture; // the Texture resolved for an image tileset (undefined for a collection)
map.data;             // the raw parsed TiledMapData

TiledMap is a plain data asset, not a Drawable. It gives you the layer grids and tileset textures; you decide how to render them (for example, by building a Mesh or batching Sprites per visible tile). A built-in tilemap renderer is out of this extension’s initial scope.

Custom properties

Tiled’s custom properties (on maps, layers, tiles, and objects) survive the trip through map.toTileMap(). Every runtime ObjectLayer, TileMapObject, and TileDefinition carries a properties bag — a flat, frozen Readonly<Record<string, TilePropertyValue>> built from the map’s Tiled property list. Plain-scalar Tiled types (string, int, float, bool, color, file) come through as their equivalent JS value:

const tileMap = map.toTileMap();
const [spawnLayer] = tileMap.objectLayers;
const spawn = spawnLayer.objects.find(object => object.name === 'PlayerSpawn');

spawn.properties.maxHealth; // number, e.g. 100
spawn.properties.label;     // string
spawn.properties.locked;    // boolean

Two Tiled property types aren’t plain scalars and convert to structured values instead:

  • object-typed properties — a reference to another Tiled object by numeric id — convert to a TilePropertyObjectRef: { kind: 'objectRef', id }. Resolve it by matching id against another object’s id in the same map:

    import { TilePropertyKind } from '@codexo/exojs-tilemap';
    
    const door = spawnLayer.objects.find(object => object.name === 'Door');
    const target = door.properties.linkedSwitch;
    
    if (target?.kind === TilePropertyKind.ObjectRef) {
      const linkedSwitch = spawnLayer.objects.find(object => object.id === target.id);
    }
  • class-typed properties — Tiled’s nested custom-property groups — recursively convert into their own properties-shaped bag instead of being dropped. Nesting can be arbitrarily deep, and each nested bag reads the same way as the top-level one:

    const stats = spawn.properties.stats; // a nested TileProperties bag
    stats.health; // number
    stats.resistances.fire; // further nesting reads the same way

Before this conversion existed, object- and class-typed properties were indistinguishable from a plain number or silently dropped; both are now tagged (TilePropertyObjectRef) or preserved (nested TileProperties) so you can tell them apart from real scalars at runtime.

Tile objects and the anchor convention

An object placed with Tiled’s insert tile tool converts to a TileObject — kind is 'tile' and tile carries the resolved ResolvedTile (tileset, local tile id, flip flags):

import { ObjectKind } from '@codexo/exojs-tilemap';

const tileMap = map.toTileMap();
const [propLayer] = tileMap.objectLayers;
const [chest] = propLayer.byKind(ObjectKind.Tile);

chest.tile.tileset;     // the runtime TileSet the tile came from
chest.tile.localTileId; // index within that tileset
chest.tile.transform;   // { flipX, flipY, diagonal }

Tiled does not store a tile object’s position the way it stores every other object’s. A rectangle, ellipse, point, polygon, or text object records the top-left corner of its bounding box; a tile object records its alignment anchor instead — by default the bottom-left corner in an orthogonal map and the bottom-centre in an isometric one. A tileset can override that with its objectalignment property (topleft, top, topright, left, center, right, bottomleft, bottom, bottomright).

toTileMap() resolves that alignment and converts the anchor to the top-left corner, so x/y mean the same thing on every TileMapObject kind:

// A 16×24 tile object stored at (32, 48) in a default orthogonal map
// (bottom-left anchor) converts to a corner at (32, 24).
chest.x; // 32
chest.y; // 24

The two helpers behind this are exported for when you work with the parsed source model directly: resolveTiledObjectAlignment(tileset.objectAlignment, map.orientation) applies Tiled’s orientation-dependent default, and getTiledObjectAnchorOffset(alignment, width, height) returns the anchor’s offset from the corner.

Text objects

An object drawn with Tiled’s text tool converts to a TextObject — a TileMapObject whose kind is 'text' and whose text field carries the styled content:

import { ObjectKind } from '@codexo/exojs-tilemap';

const tileMap = map.toTileMap();
const [captionLayer] = tileMap.objectLayers;
const [caption] = captionLayer.byKind(ObjectKind.Text);

caption.text.text;       // the text content
caption.text.color;      // 0xRRGGBB, or null for the default colour
caption.text.fontFamily; // e.g. 'sans-serif'
caption.text.pixelSize;  // font size in px
caption.text.bold;
caption.text.italic;
caption.text.wrap;       // wrap within the object's [width, height] bounds
caption.text.halign;     // 'left' | 'center' | 'right' | 'justify'
caption.text.valign;     // 'top' | 'center' | 'bottom'

caption.x, caption.y, caption.width, and caption.height (inherited from the common TileMapObject fields) give the text box’s placement and bounds; ExoJS does not lay out or draw the text for you — pair caption.text with a Text node or your own renderer.

Image layers

A Tiled image layer converts to an ImageLayer — a data-only layer holding a single resolved image, alongside the same offset/parallax/tint/opacity/properties fields tile and object layers carry:

const tileMap = map.toTileMap();
const [background] = tileMap.imageLayers;

background.texture;   // the resolved Texture (Loader-owned), or null if the image failed to load
background.repeatX;   // tiles the image horizontally when true
background.repeatY;   // tiles the image vertically when true
background.parallaxX; // horizontal parallax factor
background.parallaxY; // vertical parallax factor
background.properties.fogDensity; // custom properties — same TileProperties bag as other layers

Like ObjectLayer, ImageLayer is not a Drawable — it exposes the resolved image and its placement so a renderer or scene node can draw it; there is no built-in image-layer renderer.

Per-tile collision shapes

Shapes drawn in Tiled’s tile collision editor (the objectgroup on an individual tile within a tileset) survive the trip through toTileMap() as TileDefinition.collision — a list of TileMapObjects in tile-local pixel space (origin at the top-left of the cell):

const tileMap = map.toTileMap();
const [ground] = tileMap.layers;
const tile = ground.getTileAt(3, 5);
const definition = tile?.tileset.getTileDefinition(tile.localTileId);

for (const shape of definition?.collision ?? []) {
  shape.kind;   // 'rectangle' | 'ellipse' | 'polygon' | 'polyline' | 'point'
  shape.x;      // tile-local pixel space
  shape.y;
  shape.width;
  shape.height;
}

collision is only present on definitions for tiles that actually declare shapes in Tiled; getTileDefinition returns undefined for a tile with no per-tile metadata at all. Combine the shape’s local coordinates with the cell’s world position (tx * tileWidth, ty * tileHeight) to place it in map space.

buildTileCollisionGeometry(layer) does that placement for a whole layer, applying each tile’s flip/rotation transform plus the tileset and layer offsets and merging adjacent whole-cell boxes into as few rectangles as it can. It returns plain geometry — this package has no physics dependency.

To go straight to colliders, @codexo/exojs-tilemap-physics builds and streams static bodies from that geometry:

import { PhysicsWorld } from '@codexo/exojs-physics';
import type { TileLayer } from '@codexo/exojs-tilemap';
import { TileColliderStreamer } from '@codexo/exojs-tilemap-physics';

declare const ground: TileLayer;

const world = new PhysicsWorld({ gravity: { x: 0, y: 1600 } });
const colliders = new TileColliderStreamer(world, ground);

// Tick this from your update loop.
colliders.sync();

It owns one static body per resident chunk and keeps them in step with the layer, so a streamed map gains and loses collision along with its chunks. See Physics basics for the mapping table and the two region modes.

Base64 and compressed tile data

Tiled can write a tile layer’s data as a plain CSV array, or as a base64 string that’s optionally gzip- or zlib-compressed. @codexo/exojs-tiled decodes both transparently during the async load phase — by the time you read map.layers, every layer’s data is a plain GID array regardless of which encoding the map was exported with. There’s no user-facing API for this step; it just works.

The one exception is zstd: Tiled’s zstd tile-layer compression has no native decoder in this extension and throws TiledFormatError. Re-export the map with gzip, zlib, or uncompressed CSV data instead.

Wang and autotile sets

Wang sets — Tiled’s terrain/auto-tile definitions — parse onto TiledTileset.wangSets as raw TiledWangSetData. Convert one with tiledWangSetToWangSet into a runtime WangSet, then apply it to a layer with autoTile (a full-layer pass) or refreshCell (an incremental, single-cell update) from @codexo/exojs-tilemap:

examples/guides/tiled-maps/wang-sets.ts
const tileMap = map.toTileMap();
const [ground] = tileMap.layers;

// wangSets live on the parsed TiledTileset, keyed by the runtime tileset's
// index within `layer.tilesets` (0 here - the layer's only tileset).
const [wangSetData] = map.tilesets[0].wangSets;
const wangSet = tiledWangSetToWangSet(wangSetData, 0);

// A 'mixed' wangset has no single-mask equivalent and converts to null.
if (wangSet) {
  // Re-derive every autotiled variant across the whole layer, e.g. after
  // generating terrain procedurally:
  autoTile(ground, wangSet);

  // Or, after painting a single cell, refresh just that cell and its eight
  // neighbours instead of re-running autoTile over the whole layer:
  refreshCell(ground, tx, ty, wangSet);
}

tiledWangSetToWangSet handles Tiled’s 'corner' (blob-style, 8-neighbour) and 'edge' (4-neighbour) wangset types; 'mixed' wangsets interleave both semantics in a way that can’t be faithfully represented by a single mask and convert to null. This conversion is a deliberate manual step (not run automatically by toTileMap()) — you decide which layers get autotiled and when.

Per-tile animation

Tiled’s tile editor lets you give a tile an animation: a sequence of frames, each showing a different local tile ID for a set duration. toTileMap() carries these frames into TileDefinition.animation; @codexo/exojs-tilemap ships TileAnimator to drive them at runtime, RPG-Maker style — advancing every animated cell across one or more layers on a shared clock:

import { TileAnimator } from '@codexo/exojs-tilemap';

const tileMap = map.toTileMap();
const animator = new TileAnimator(tileMap.layers);

// Somewhere in your update loop:
scene.systems.add({ update: (delta) => animator.update(delta) });

TileAnimator only rewrites a cell — and rebuilds the chunk that contains it — on the frame it actually changes; static tiles, and animated tiles between frame boundaries, are left untouched. Call animator.reset() to snap every animated cell back to its first frame (useful before serialising or pausing), and animator.rescan() after structural edits that add or remove animated tiles.

Tileset texture ownership

The tileset textures a TiledMap exposes are loaded through the Loader and owned by the loader cache — not by the TiledMap. This matters for teardown:

map.destroy(); // releases the TiledMap; does NOT destroy tileset textures

TiledMap.destroy() deliberately does not destroy its tileset textures, because the same texture may be shared with other maps or sprites through the cache. Release shared textures via the loader’s own unload path when nothing else needs them — never from the map.

Supported scope

The extension covers the common orthogonal workflow, including format details beyond the basics:

  • Orthogonal orientation
  • Tile layers, including base64 and gzip/zlib-compressed data
  • Infinite (chunked) maps — each chunked layer becomes an unbounded runtime layer whose data streams in on demand via getChunkSource() (see the Infinite maps chapter)
  • Image-based (atlas) tilesets, embedded in the map or referenced externally (.tsj)
  • Object layers — rectangle, ellipse, point, polygon, polyline, tile, and text objects
  • Image layers
  • Per-tile collision shapes, animation frames, and custom properties
  • Wang/autotile sets, via the tiledWangSetToWangSet conversion step (see Wang and autotile sets above)

Not supported

These raise a clear error or are intentionally absent from this release:

  • .tmx maps and .tsx tilesets (XML) — only the JSON format is parsed: .tmj for maps, .tsj for external tilesets. Export from Tiled as JSON.
  • Non-orthogonal orientations — isometric, staggered, and hexagonal throw.
  • Collection-of-images tilesets — a tileset built from one image per tile, rather than a single atlas, throws in toTileMap(). Use an atlas tileset.
  • zstd-compressed tile data — no native decoder is wired up; re-export with gzip, zlib, or uncompressed CSV data instead.
  • Object templates (.tx) — an object’s template path is preserved on TiledObject.template but not fetched or merged into the object. Avoid templates, or resolve them yourself.
  • Text kerning — Tiled’s per-text kerning flag is parsed (TiledTextData.kerning) but dropped during toTileMap(); it isn’t carried into TextStyle.

Where to look next

  • Package README: @codexo/exojs-tiled — install, entry points, and the supported-format matrix.
  • API reference: the Loader page documents load(...), explicit Asset.type(...) descriptors, and the .tmj extension lookup.
  • Spawning objects: Worlds and level streaming turns an object layer into game objects with MapObjectSpawner, and shows how to drive several Tiled maps as one streamed world.
  • Extension model: the Particles chapter walks through the same activation model for the sibling renderer extension.