Skip to content

Tilemaps

The @yagejs/tilemap package loads and renders maps created with the Tiled map editor. It supports tile layer rendering, object layer extraction, and physics collision shape generation.

Worth knowing up front so you don’t reach for something that isn’t there.

FeatureStatus
Orthogonal Tiled JSON mapsSupported
Multiple tile layers per mapSupported
Object layers (spawns, triggers, collision shapes)Supported
Custom properties on the map, layers, tilesets and objectsSupported
Object-reference resolution (Tiled object refs)Supported
Collision-shape extraction (rect / ellipse / capsule / polygon / polyline)Supported
toPhysicsColliders() adapter for RapierSupported
Tileset-image and collection-of-images tilesetsSupported
Embedded and external tilesetsSupported
Flipped and rotated tilesSupported
Tile images that don’t match the map gridSupported — anchored bottom-left, as Tiled draws them
Tile objects (an object that draws a tile)Data only — gid, position, size and properties are parsed; the image is not drawn
Tileset objectalignment overrideSupported — a tile object’s position is normalised to its top-left corner
Tileset tilerendersize / fillmodeNot read — a tile draws at its image’s own size
Collision shapes authored on a tile in the tilesetNot read
Layer draw offsets and tileset tileoffsetSupported
Per-layer visibility and opacitySupported
Animated tilesSupported for evenly-spaced, equal-duration frames
Infinite / chunked mapsNot yet
Base64-encoded layer dataNot yet — export layer data as CSV
Group layers and image layersNot yet
Isometric / hexagonal / staggered orientationsNot planned
Dynamic tile editing at runtimeNot yet
Parallax / background scroll layersNot built-in (use a regular sprite layer)

A map that uses an unsupported map, layer or tileset setting does not fail silently: validateTiledMap() reports it against the raw JSON, and TilemapComponent logs what it finds when the map loads — see Checking a Map. Three limits have no diagnostic, because the map itself parses fine: runtime tile editing, per-tile collision shapes, and a tileset’s tilerendersize / fillmode.

For a worked example of loading a Tiled map and extracting collision shapes, see the tilemap example — it parses a Tiled JSON, renders the layers, calls getCollisionShapes("walls"), and visualizes the resulting rectangles through the debug overlay. Two distinct steps make up the Tiled-collision-to-Rapier workflow:

  1. Extractiontilemap.getCollisionShapes("walls") returns raw shape data in Tiled’s coordinate system (top-left-origin rect / circle / capsule / polygon / polyline configs).
  2. Physics conversiontoPhysicsColliders(shapes) adapts those raw shapes into Rapier ColliderConfigs with the offsets baked in. Bounding-box shapes (rect, circle, capsule) become center-origin configs (offset = x + width/2, y + height/2); polygons and polylines keep their original vertices with a top-left-based offset (offset = { x, y }), since vertex-defined shapes don’t need centering.

The wiring (extracted shapes → toPhysicsColliders() → static RigidBodyComponent + per-shape ColliderComponent) is shown inline in the Collision Extraction section below.

Tiled’s “Export As JSON” dumps the tileset wherever you point it, but the JSON itself still references its source PNG by path-relative-to- the-JSON. The loader resolves it as dirname(tilesetSrc) + tileset.image. Tiled writes that field exactly as it was authored — typically a path that walks out of the project ("../../Downloads/tiled-projects/dungeon/spr_tileset.png"). The moment you copy the JSON into public/, that path no longer resolves in the browser and you get a silent 404: the tileset object loads, the texture doesn’t, and tiles render as blank rectangles.

The fix is mechanical — rewrite image to a sibling-relative path whenever you stage a tileset:

public/assets/maps/dungeon.tsj
{
"tilewidth": 16,
"tileheight": 16,
"tilecount": 256,
"image": "../../Downloads/tiled-projects/dungeon/spr_tileset.png",
"image": "spr_tileset.png",
...
}

…and drop spr_tileset.png next to dungeon.tsj in public/assets/maps/. The same rule applies to embedded tilesets inside a map JSON: the image field is resolved relative to the map file’s directory, so the PNG needs to sit next to the map (or at any sibling path you reference). Watching for this saves a frustrating “why is my map blank?” debugging cycle.

import { TilemapPlugin } from "@yagejs/tilemap";
engine.use(new TilemapPlugin());

The plugin depends on @yagejs/renderer.

Use the tiledMap() asset factory and pair it with a spritesheet texture:

import { tiledMap } from "@yagejs/tilemap";
import { renderAsset } from "@yagejs/renderer";
const MapData = tiledMap("assets/level.json");
const Tileset = renderAsset("assets/tileset.png");

Add both to your scene’s preload array. The tileset texture must load before the map so tile rendering can resolve texture frames:

class LevelScene extends Scene {
readonly preload = [Tileset, MapData];
}

Add a TilemapComponent to an entity to render the map:

import { Scene, Transform } from "@yagejs/core";
import { TilemapComponent } from "@yagejs/tilemap";
import type { LayerDef } from "@yagejs/renderer";
class LevelScene extends Scene {
readonly name = "level";
readonly preload = [Tileset, MapData];
// Declare the scene's layers — the renderer plugin materializes them
// automatically when the scene is pushed.
readonly layers: readonly LayerDef[] = [
{ name: "map", order: -10 },
];
onEnter(): void {
const map = this.spawn("map");
map.add(new Transform());
map.add(new TilemapComponent({
source: MapData, // asset handle — preferred form
layers: ["ground", "walls"], // which tile layers to render (omit for all)
layer: "map", // render layer name
}));
}
}

source takes the same AssetHandle you passed to preload. Internally the component captures both the parsed map data and the asset path, so the same constructor argument enables save/load and the Tiled-derived auto-keys covered below. If you don’t have a handle in scope, you can still pass mapKey as a plain string.

Query map dimensions from the component:

const tilemap = map.get(TilemapComponent);
tilemap.widthPx; // total width in pixels
tilemap.heightPx; // total height in pixels
tilemap.tileWidth; // single tile width
tilemap.tileHeight; // single tile height

TilemapComponent is a visual component like SpriteComponent, so it accepts the same visual options and exposes the same vocabulary — tint, alpha, blend mode, visibility, masks, and a component-scope effects host:

map.add(new TilemapComponent({
source: MapData,
layer: "map",
tint: 0x5566aa, // wash the whole map cold
alpha: 0.85,
}));
const tilemap = map.get(TilemapComponent);
tilemap.tint = 0xffffff; // back to untinted
tilemap.fx.addEffect(bloom({ strength: 1.5 })); // effects on the map alone

Tint and alpha reach the tiles through a colour filter on the map’s container, because the tile renderer has no colour input of its own. A map at full white and full opacity has no filter attached, so an untinted map costs nothing.

A Tiled map can use features this package does not render. Rather than dropping them quietly, the loader collects them:

const tilemap = map.get(TilemapComponent);
for (const d of tilemap.data.diagnostics) {
console.log(`${d.severity}: ${d.message}`);
}
// error: Group layer "decor" is not rendered. Its dropped children are: vines, banners.
// warning: Layer "clouds" sets parallax, which the renderer does not apply.

TilemapComponent already logs the same list as a single warning when it loads a map with anything in it, so a broken map announces itself in the console without you writing this loop.

To check a map before building anything from it — in a content pipeline, an asset test, or a level editor — run the validator over the raw JSON:

import { validateTiledMap } from "@yagejs/tilemap";
const problems = validateTiledMap(rawTiledJson)
.filter((d) => d.severity === "error");
if (problems.length > 0) {
throw new Error(problems.map((d) => d.message).join("\n"));
}

A diagnostic is an error when authored content is dropped or will render wrong, and a warning when a setting is simply not applied. Each one carries a code you can match on (group-layer, infinite-map, unsupported-orientation, unsupported-tile-animation, and so on), plus the layer or tileset name it came from. A gate filtered to errors lets every warning through, so match on the codes your game cannot live with. tile-object is an error — it names the tile objects on a layer whose images the map does not draw — so a game that spawns its own sprites from them will want to let that one through.

An external tileset that has not been loaded yet is not reported — it resolves during preload.

TilemapComponent is @serializable, but the live parsed map it wraps is not — the parsed TiledMapData carries PixiJS textures, which don’t survive JSON.stringify. The constructor therefore accepts three alternative inputs: a source: AssetHandle<TiledMapData> (preferred — captures both the parsed data and the asset path in one argument, which doubles as the prefix for object auto-keys), an in-memory map object for the fast-path case where you already have the parsed map, and a mapKey asset path that can be re-resolved from the AssetManager after a reload.

interface TilemapComponentOptions extends VisualComponentOptions {
/** Asset handle — preferred. Captures both data and path in one place. */
source?: AssetHandle<TiledMapData>;
/** Parsed Tiled map data — not serializable, no auto-keys. */
map?: TiledMapData;
/** Asset path string — equivalent to `source` when you don't have the handle. */
mapKey?: string;
/** Which tile layers to render. Omit to render all. */
layers?: string[];
/** Override prefix for object auto-keys. Defaults to mapKey. */
keyPrefix?: string;
// Plus the shared visual options: layer, visible, tint, alpha,
// blendMode, interactive.
}

For any game that uses the save/load system, pass source (or mapKey) rather than map. When a TilemapComponent constructed with an inline map gets serialized, serialize() emits a warning and the snapshot cannot round-trip through afterRestore(). The asset-path form lets the save system store just the reference and re-look-up the texture-backed map on restore:

// Recommended: save/load friendly + auto-keys for Tiled objects
map.add(new TilemapComponent({
source: MapData,
layers: ["ground", "walls"],
layer: "map",
}));
// One-shot prototypes only — will NOT survive a save/load cycle and
// auto-keys are unavailable.
map.add(new TilemapComponent({
map: parsedTiledJson,
layer: "map",
}));

The snapshot (TilemapComponentData) stores the asset key, the layer selection, any explicit keyPrefix override, and the shared visual fields every visual component saves — render layer, tint, alpha, visibility, blend mode, effects and mask. The live map is reconstructed from the asset key on load.

Sometimes you need to reach past the renderer and read raw tile data — for building a pathfinding grid, counting tile types, or driving custom gameplay off tile positions. TilemapComponent exposes a format-agnostic snapshot of the parsed map on its data property:

const tilemap = map.get(TilemapComponent);
const data = tilemap.data; // TilemapData
console.log(`${data.width} x ${data.height} tiles`);
console.log(`${data.tileLayers.length} tile layers`);

The TilemapData shape is deliberately decoupled from the Tiled JSON format — you can build your own parser for a different editor and reuse the same downstream consumers.

interface TilemapData {
width: number; // tiles wide
height: number; // tiles tall
tileWidth: number; // pixel width of one tile
tileHeight: number;
properties?: MapObjectProperty[]; // the map's own custom properties
tileLayers: TileLayerData[];
objectLayers: ObjectLayerData[];
tilesets: TilesetInfo[];
diagnostics: TilemapDiagnostic[];
}
interface TileLayerData {
name: string;
data: number[]; // flat, row-major tile GIDs (0 = empty)
width: number;
height: number;
visible: boolean;
offsetX: number; // the layer's Tiled draw offset, in pixels
offsetY: number;
properties?: MapObjectProperty[];
}
interface ObjectLayerData {
name: string;
objects: MapObject[]; // coordinates already include offsetX/offsetY
visible: boolean;
offsetX: number;
offsetY: number;
properties?: MapObjectProperty[];
}
interface TilesetInfo {
firstGid: number;
name?: string;
properties?: MapObjectProperty[];
}

Custom properties are available at every level Tiled offers them, so a map can carry its own identity — a biome, a music track, a level index — without a marker object standing in for it:

const biome = tilemap.getProperty<string>(tilemap.data, "biome");
const track = tilemap.getProperty<string>(tilemap.data, "music");

TileLayerData.data is a single flat array in row-major order. To read the tile at tile-coordinate (tx, ty), index with data[ty * width + tx]. A GID of 0 means “no tile at this position”. That flat layout makes it cheap to iterate for pathfinding or damage maps:

// Count all solid tiles in the "walls" layer
const walls = data.tileLayers.find((l) => l.name === "walls")!;
let solidCount = 0;
for (const gid of walls.data) {
if (gid !== 0) solidCount++;
}

MapObject (the element type in objectLayers[i].objects) carries id, name, optional class, position and size, rotation, an optional gid for objects that draw a tile, an optional point flag, an optional polygon, an optional polyline, and an optional properties: MapObjectProperty[] array of Tiled custom properties — covered in detail below.

Look up tile data at a world position:

const id = tilemap.getTileAt(worldX, worldY, "ground");
// Returns the tile id, or null if the position is empty

Each layer is read through its own draw offset, so the id you get back is the tile you see at that position.

A tileset’s tileoffset is the exception. It moves where a tile’s image is drawn without moving the cell the tile occupies. A tile from an offset tileset answers at its cell, which is where its collision and its neighbours are.

A tileset’s tiles need not be the size of the map’s grid — a three-cell-tall pillar on a 16px map, or an 8px coin. Tiled anchors every tile image to the bottom-left of the cell it was painted in, so a taller image overhangs upward and a wider one to the right, and a smaller one sits on the cell’s bottom edge. The tile still belongs to that one cell. Tiles render where Tiled draws them.

In a collection-of-images tileset each tile is measured on its own, which lets one tileset mix a 16px stump with a 64px pine and place both correctly.

A tile draws at its image’s own size. A tileset can ask Tiled to scale its tiles to the grid instead (tilerendersize: "grid"); that setting is not read, so those tiles keep their own size here and land where the rule above puts them.

Because the cell is unchanged, getTileAt answers at the cell a tile was painted in, not under the part of the image that overhangs its neighbours.

Tiled lets you flip a tile horizontally, flip it vertically, or reflect it across its diagonal, in any combination — eight orientations in all. They render the way the editor shows them, so a tileset needs only one copy of a wall corner or an arrow.

Tiled packs that orientation into the high bits of the tile’s GID. getTileAt strips them, so a comparison works whichever way a tile faces:

if (tilemap.getTileAt(x, y, "walls") === WALL_CORNER) { /* … */ }

Raw layer data keeps the bits, because the orientation is sometimes the point — a one-way conveyor tile, say, whose direction comes from how it was placed:

import { readTileGid } from "@yagejs/tilemap";
const layer = tilemap.data.tileLayers.find((l) => l.name === "machinery")!;
const { id, flippedHorizontally } = readTileGid(layer.data[row * layer.width + col]!);
if (id === CONVEYOR) {
direction = flippedHorizontally ? -1 : 1;
}

Water, torches, and machinery animated in Tiled play on their own. There is no component option to set and no per-frame game code — author the animation in the tileset and it runs. The clock comes from the scene, so a paused scene freezes its tilemaps and a slowed timeScale slows them with it.

An animation plays when three things hold, and Tiled’s animation editor gives you all three by default:

  • The tileset is a single image, not a collection of separate images.
  • Every frame lasts the same time.
  • The frames sit a constant distance apart in the tileset image — next to each other along a row, or down a column.

An animation outside that shape renders unanimated — as the tile the map places — and reports an unsupported-tile-animation warning saying which tile and why: durations that differ, frames scattered around the image, or a collection-of-images tileset. Splitting a two-second pause into ten identical 200ms frames is usually enough to bring an animation back into the supported shape.

The animation phase is not part of a save. A tilemap restored from a snapshot starts its cycle from the beginning.

Tiled object layers contain spawn points, triggers, and other game data. Extract objects grouped by their class or name:

const objects = tilemap.getObjects("spawns");
// Returns: Record<string, MapObject[]>
// e.g. { "player": [{ x, y, ... }], "enemy": [{ x, y, width, height, ... }] }
for (const obj of objects["enemy"] ?? []) {
this.spawn(EnemyBP, { x: obj.x, y: obj.y });
}

Each MapObject has:

interface MapObject {
id: number;
name: string;
class?: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
visible: boolean;
gid?: number; // set only on an object that draws a tile
point?: boolean;
polygon?: { x: number; y: number }[];
polyline?: { x: number; y: number }[];
properties?: MapObjectProperty[];
}

getObjects() merges every layer when you don’t name one, and it keys a classless object by its name — fine for a lookup, wrong when two layers hold the same class or when a class and an object share a spelling. Use getObjectGroups() when the layer matters:

for (const group of tilemap.getObjectGroups()) {
console.log(group.layer, group.class, group.objects.length);
}
// "spawns" "EnemySpawn" 4
// "spawns" undefined 1 ← objects authored without a class
// "patrol" "EnemySpawn" 2 ← same class, different layer, kept apart

Each entry is { layer, class?, objects }, grouped by layer and class. Objects with no class collect into that layer’s single class: undefined entry.

Dragging a tile out of the tileset onto an object layer makes a tile object: an object that shows a tile. It has a gid, the global ID of that tile, alongside everything a normal object has — a class, a name, custom properties, a size you can stretch. The image is not drawn for you. An object layer is data, so a tile object is a placement you read and turn into an entity.

Its x and y are the top-left corner of the tile it draws, the same as every other object type. Tiled itself stores a tile object on a different corner — the bottom-left one, matching how tiles land in tile layers, or whichever corner the tileset’s objectalignment names — and the parser moves the position for you. A spawn reads it directly:

import { readTileGid } from "@yagejs/tilemap";
for (const obj of tilemap.getObjects("props")["Tree"] ?? []) {
if (obj.gid === undefined) continue; // authored as a plain rectangle
const { id, flippedHorizontally } = readTileGid(obj.gid);
this.spawn(TreeEntity, {
tileId: id,
x: obj.x,
y: obj.y,
flippedHorizontally,
});
}

obj.rotation turns the tile about that same corner, in degrees. Put the position and the rotation on the entity’s Transform and anchor the sprite top-left, and it lands where Tiled shows it at any angle. Position and rotation belong on the Transform — the renderer copies them onto the sprite every frame, so a rotation set on the sprite is overwritten.

import { MathUtils, Transform } from "@yagejs/core";
import { SpriteComponent } from "@yagejs/renderer";
// inside the spawned entity's setup(), for the tile object it came from
this.add(new Transform({
position: { x: obj.x, y: obj.y },
rotation: MathUtils.degToRad(obj.rotation), // Tiled stores degrees
}));
this.add(new SpriteComponent({ texture, anchor: { x: 0, y: 0 } }));

Collision extraction reads the same corner, so its rect and the sprite agree.

The gid keeps Tiled’s flip bits, exactly like the GIDs in raw layer data, so readTileGid splits it the same way.

Because the image is not drawn for you, validateTiledMap() reports a tile-object error for each object layer that holds tile objects, naming them. Collision shapes authored on the tile inside the tileset are not read either, so collision extraction treats a tile object as its full box.

The component exposes a few direct accessors so you don’t have to flatten getObjects() for one-off lookups:

tilemap.getAllObjects(); // flat list across every layer
tilemap.findObject(42); // by Tiled id
tilemap.findObjectByName("Player"); // first match across all layers

Spawning Entities from Tiled Objects (auto-keys)

Section titled “Spawning Entities from Tiled Objects (auto-keys)”

Tiled assigns every object a stable per-map id. Combine that id with the map’s asset path to derive a stable entity.key so persistent stores (createSet<string>, createMap<string, T>) can be keyed off authored content without your game inventing its own naming scheme. The component exposes the prefix wiring as objectKey() and forEachObject():

import { tiledObjectKey, TilemapComponent } from "@yagejs/tilemap";
// Standalone helper, format: "<prefix>#object:<id>"
tiledObjectKey("/assets/dungeon.json", 42);
// → "/assets/dungeon.json#object:42"
// On the component — prefix already wired up from `source` / `mapKey`:
tilemap.objectKey(obj);
tilemap.forEachObject("interactables", (obj, key) => {
if (obj.class === "EnemySpawn") {
this.spawn(EnemyEntity, { object: obj }, { key });
}
});

Pass keyPrefix: "level1" to the component constructor when multiple instances of the same map need distinct identity namespaces (instanced dungeons, per-floor layouts).

objectKey and forEachObject throw if the component was constructed from raw map: data without a mapKey, source, or explicit keyPrefix — auto-keys need a stable prefix.

See Persistent World State for the canonical pattern that pairs these auto-keys with createSet / createMap to remember which chests are open, which triggers fired, etc., across save/load.

Read custom Tiled properties from an object, a layer, a tileset, or the map itself — all four helpers take anything carrying a properties array. The component-method variants are typed and discoverable; the standalone helpers exist for cases where you’ve already collected an object pool yourself.

// On the component (preferred)
tilemap.getProperty<number>(obj, "speed");
tilemap.getPropertyArray<number>(obj, "point");
tilemap.resolveRef(obj, "target"); // walks every layer
tilemap.resolveRefArray(obj, "spawns");
// The same helpers on the map, a layer, or a tileset
tilemap.getProperty<string>(tilemap.data, "biome");
tilemap.getProperty<number>(tilemap.data.tileLayers[0], "damage");
tilemap.getProperty<string>(tilemap.data.tilesets[0], "material");
// Standalone (caller supplies the pool)
import { getProperty, getPropertyArray, resolveObjectRef, resolveObjectRefArray } from "@yagejs/tilemap";
getProperty<number>(obj, "speed");
getPropertyArray<number>(obj, "point");
resolveObjectRef(obj, "target", allObjs);
resolveObjectRefArray(obj, "path", allObjs);

Tiled objects map to physics-agnostic TilemapColliderConfig variants:

Tiled objectEmitted shape
Rectangle{ type: "rect", x, y, width, height, rotation? }
Tile object (carries a gid){ type: "rect", x, y, width, height, rotation? } covering the tile’s whole box, exactly like a rectangle; shapes authored on the tile in the tileset are not read
Ellipse (width === height){ type: "circle", x, y, width, height, radius }
Ellipse (width !== height){ type: "polygon", x, y, vertices } — 24 vertices sampled on the ellipse outline (Rapier has no ellipse primitive; the ring is convex, so the physics-side convex hull matches it exactly)
Capsule{ type: "capsule", x, y, width, height, halfHeight, radius, axis, rotation? } (oriented along the longer axis)
Polygon{ type: "polyline", x, y, vertices } with the first vertex appended at the end, closing the loop (Tiled polygons are closed outlines; may be concave)
Polyline{ type: "polyline", x, y, vertices } verbatim (open chain)
PointSkipped

Object rotation is honored for every shape. Tiled stores it in degrees, pivoting on the object’s position: polygon, polyline, and sampled-ellipse vertices come out already rotated; a rotated circle has the position shift baked into its x/y; rectangles and capsules carry a rotation field in radians, which toPhysicsColliders() forwards to the physics collider. A rectangle rotated in the Tiled editor — the natural way to author a ramp — collides exactly as drawn.

Because Tiled polygons are emitted as polylines (chain of line segments), they support concave outlines such as a winding water edge or a U-shaped wall. The trade-off: polylines are static-only — they must be attached to a type: "static" rigid body (no mass/inertia is computed). For dynamic concave bodies, decompose the outline into convex pieces manually.

Wire extracted shapes through toPhysicsColliders() to spawn one static body with one ColliderComponent per shape:

import { toPhysicsColliders } from "@yagejs/tilemap";
import { RigidBodyComponent, ColliderComponent } from "@yagejs/physics";
const walls = this.spawn("walls");
walls.add(new Transform());
walls.add(new RigidBodyComponent({ type: "static" }));
for (const cfg of toPhysicsColliders(tilemap.getCollisionShapes("walls"))) {
walls.add(new ColliderComponent(cfg));
}

The component-method shortcut wraps two standalone functions for the same workflow:

import { extractCollisionShapes, toPhysicsColliders } from "@yagejs/tilemap";

Use the tilemap dimensions to constrain the camera. The camera is an entity you spawn in the scene:

import { CameraEntity } from "@yagejs/renderer";
const cam = this.spawn(CameraEntity, { follow: player.get(Transform) });
cam.bounds = {
minX: 0,
minY: 0,
maxX: tilemap.widthPx,
maxY: tilemap.heightPx,
};

This prevents the camera from scrolling past the map edges.