Skip to content

Tiled JSON Maps

Snowpulse provides a native Tiled JSON loader, an orientation-aware coordinate model, and a TiledRenderer component. The production boundary is deliberately neutral: the engine draws tile and image layers and exposes object data, while the application decides what an object means for gameplay.

Public headers:

#include <runtime/components/tiled_renderer.h>
#include <runtime/tiled/tiled_document.h>
#include <runtime/tiled/tiled_geometry.h>
#include <runtime/tiled/tiled_loader.h>
#include <runtime/tiled/tiled_projection.h>

They are also available through snowpulse.h.

Supported Contract

Snowpulse guarantees JSON exported by Tiled 1.12.x. Older JSON is accepted on a best-effort basis. A future schema version produces a warning in compatible validation mode and an error in strict mode, including every directly or transitively referenced .tj template. Strict mode also requires every .tmj, .tsj, and .tj document to declare its schema version.

Supported source files and data include:

  • .tmj maps, .tsj tilesets, and .tj object templates;
  • legacy .json map/tileset/template exports, with a deprecation diagnostic;
  • orthogonal and isometric maps;
  • finite maps and infinite maps with signed, negative chunk coordinates;
  • native unsigned GID arrays and base64 GID streams using no compression, zlib, gzip, or zstd;
  • atlas and image-collection tilesets, tile animations, tile offsets, object alignment, complete collision object-group layer metadata, terrain metadata, and raw GID flip bits;
  • tile, image, object, and nested group layers, including inherited visibility, opacity, tint, offset, and parallax state;
  • rectangle, ellipse, capsule, point, polygon, polyline, text, and tile objects;
  • templates with presence-aware overrides and properties merged by name; and
  • Tiled property values including bool, int, float, string, color, file, object, class, and recursively typed lists.

Not supported:

  • XML .tmx, .tsx, or .tx sources;
  • staggered, hexagonal, or oblique orientations;
  • Tiled world files; or
  • saving an edited document back to a Tiled source file.

Unsupported input is never returned as a partial document. Loader diagnostics carry the source path, JSON pointer, and external-reference chain.

Engine and Application Responsibilities

TiledRenderer renders tile layers, image layers, and tile objects in authored object-layer order. Other object-layer content remains data; it never auto-creates Nodes, scripts, physics bodies, colliders, navigation, or entities. Text and geometric objects remain queryable, and the optional object debug overlay is visualization only.

Application code owns:

  • entity/prefab creation;
  • navigation occupancy, movement costs, and cell conventions;
  • trigger and event behavior; and
  • the decision to turn object or tileset collision shapes into physics data.

This boundary keeps authored content portable between games and avoids hidden runtime side effects when a map loads.

Loading and Diagnostics

Use the component when a scene needs rendering:

auto* mapNode = scene()->createNode("map");
scene()->rootNode()->addChild(mapNode);

auto* tiled = mapNode->addComponent<snowpulse::TiledRenderer>();
if (!tiled->load("maps/world.tmj")) {
    return;
}
tiled->setDebugObjectsEnabled(false);

Use TiledDocumentLoader directly when a tool or validation path needs structured diagnostics or stricter limits:

snowpulse::TiledLoadOptions options;
options.validation = snowpulse::TiledValidationMode::Strict;
options.maxSourceBytes = 16u * 1024u * 1024u;
options.maxDecodedBytes = 64u * 1024u * 1024u;

auto result = snowpulse::TiledDocumentLoader::loadMapFromAsset(
    assetManager(), "maps/world.tmj", options);
if (!result) {
    for (const auto& diagnostic : result.diagnostics) {
        logTiledDiagnostic(diagnostic);
    }
    return;
}

const snowpulse::TiledDocument& map = *result.value;

TiledLoadResult<T>::value is a shared immutable snapshot (std::shared_ptr<const T>). TiledRenderer copies a successfully loaded map into private storage before exposing its typed, revisioned mutation methods, so callers cannot bypass revision tracking by mutating loader output.

The default limits are 64 MiB per source, 256 MiB of decoded layer bytes, 1,024 external documents, and 32 reference levels. Base64 tile streams are decoded as exact little-endian uint32_t values; decoded byte count, declared cell count, finite-map pixel dimensions, signed half-open layer/chunk bounds, atlas capacity, tile/image dimensions, integer overflow, and chunk overlap are validated before the document is returned. Atlas columns and tilecount are inferred from declared image geometry when omitted. Image-collection IDs may be sparse: their GID span follows the greatest declared tile ID, not tilecount.

References and Asset Layout

Every reference is resolved relative to the file that declares it. For example, an image in tilesets/environment.tsj is relative to tilesets/, not to the map that imports the tileset. Snowpulse retains both the authored and resolved paths in TiledAssetReference.

Keep the complete directory structure under the application's asset root:

assets/
  maps/region/world.tmj
  tilesets/environment.tsj
  templates/spawn.tj
  images/terrain/environment.png

Map entry paths and authored references must use /, preserve exact case, remain below the asset root, and must not be absolute paths or URIs. Snowpulse does not flatten references to a basename. That rule is identical on desktop, web, iOS, and Android, even when the host filesystem itself is case-insensitive.

When optional web asset packs are enabled, the manifest compiler follows the automatic Tiled graph: map tilesets, object templates, tileset images, per-tile images, image-layer images, and their transitive sources. Each linked file must be in the same deferred pack as its owner or in boot. File-valued custom properties are gameplay data rather than automatic loader references; assign those manually with the application content that opens them.

Every snowpulse_add_game() target whose asset tree contains Tiled JSON also depends on a platform-neutral graph-validation target. Consequently desktop, web, iOS, and Android builds fail before linking when an automatic dependency is missing, escapes the asset root, uses non-portable separators, has incorrect case, or contains a template cycle. The same check can be run directly:

python3 snowpulse/tools/tiled_asset_graph.py validate --assets-dir path/to/assets

Queries and Raw GIDs

The parsed document supports stable ID/name lookup without creating runtime objects:

const snowpulse::TiledDocument* map = tiled->document();
if (!map) {
    return;
}

const auto* ground = map->findLayerByName("ground");
const auto* spawn = map->findObjectById(300);
const auto* terrain = map->findTilesetByName("terrain");

for (const snowpulse::TiledObject* enemy : tiled->objectsByClass("enemy")) {
    bindEnemy(*enemy);
}
for (const snowpulse::TiledObject* trigger : tiled->objectsInLayer(12)) {
    bindTrigger(*trigger);
}

if (ground && ground->isTileLayer()) {
    const uint32_t raw = ground->rawGidAt(4, 3);
    const uint32_t gid = snowpulse::tiledRawGid(raw);
    const snowpulse::TiledFlipFlags flags = snowpulse::tiledFlipFlags(raw);
    (void)gid;
    (void)flags;
}

Raw GIDs preserve all Tiled transform bits. Use the masked GID for tileset lookup and retain the flags when resolving tile-object or collision geometry.

Templates are resolved during loading. The resolved object retains its origin, ordered templateChain, per-field effectiveFieldOrigins, templateReference, template-merged fields, declaredProperties, and merged properties, so an application can distinguish authored overrides from inherited defaults and trace either one back to its source path, JSON Pointer, and reference chain. Recursive list-property nodes are exposed through shared_ptr<const TiledPropertyList>; callers cannot mutate loaded nested values around the renderer's revisioned mutation API.

Lookup pointers remain valid until the renderer loads or unloads a map. A failed transactional load keeps the current document, pointers, and render cache active; inspect diagnostics() or lastError() for that failed attempt.

Neutral Object and Collision Geometry

objectGeometry() resolves an authored object through its group/layer state, map orientation, offsets, parallax, rotation, and the renderer owner's world matrix. It returns local and world paths and bounds without creating runtime objects. The default query uses the exact RenderView2D snapshot captured by the renderer's latest draw, so picking and rendering share camera rotation, zoom, shake, and parallax. Before the first draw it is camera-independent. Use the ForView variants with an explicit frame snapshot when querying before submission; the caller still controls curve tessellation:

snowpulse::TiledGeometryOptions geometryOptions;
geometryOptions.curveSegments = 24;

if (auto geometry = tiled->objectGeometryForView(
        300, renderQueue.frameView(), geometryOptions)) {
    for (const auto& path : geometry->paths) {
        consumePath(path.localPoints, path.worldPoints, path.closed);
    }
}

for (const auto& collision :
     tiled->tileCollisionGeometryForView(
         450, renderQueue.frameView(), geometryOptions)) {
    consumeCollision(collision.resolvedRawGid, collision.geometry);
}

// Collision objects authored on the tile at cell (4, 3) in layer 12.
for (const auto& collision :
     tiled->tileCollisionGeometryForView(
         12, {4, 3}, renderQueue.frameView(), geometryOptions)) {
    consumeCollision(collision.resolvedRawGid, collision.geometry);
}

The two overloads resolve a tile object by object ID or an ordinary tile-layer cell by layer ID and Tiled cell coordinate. Tile collision resolution applies the current animated GID, raw flip flags, tileset tile offset, tile-object alignment, size/scale, and rotation. The result is data only: choosing a Box2D shape, collision category, sensor flag, or component lifetime remains application code. A tile definition also retains its complete immutable collision object group—including name/class, draw order, blend mode, visibility, opacity, lock, tint/color, offsets, parallax, properties, and object declaration provenance—rather than flattening it to a shape vector.

Tile-Layer Picking

tileCellAtScreenBottomLeft() and tileCellAtScreenTopLeft() convert logical screen pixels to an authored cell in a specified tile layer. Pass the exact RenderView2D for the input event's frame so picking uses the same camera rotation, zoom, shake, owner world transform, map anchor, nested offsets, and parallax as rendering:

const snowpulse::RenderView2D& view = renderQueue.frameView();
const glm::vec2 pointerTopLeft = input->mousePosition();
if (auto cell = tiled->tileCellAtScreenTopLeft(
        12, pointerTopLeft, view)) {
    inspectCell(cell->x, cell->y);
}

After the component has drawn, the two-argument overloads use lastRenderView() as a convenience for input tied to the latest presented frame. Before the first draw that snapshot is invalid and the helpers return std::nullopt. Bottom-left and top-left refer only to the logical screen origin. Framebuffer scaling does not change logical input coordinates, and logicalViewportOffset is presentation metadata that must not be applied a second time.

Picking is not clipped to declared or content bounds. This makes negative and new cells addressable for infinite-map tools; query TiledLayer::tileBounds() or rawGidAt() when the application wants bounds or occupancy filtering. On isometric diamond edges, TiledProjection's documented rule applies: the cell on the positive side of a shared edge wins.

Projection and Map Placement

Tiled authored/projected space is x-right and y-down; renderer-local space is x-right and y-up. TiledProjection keeps those stages explicit.

The default renderer transform uses ProjectedOrigin. Authored projected (0, 0) stays at local (0, 0), so an infinite map does not jump when chunks are added at a new negative coordinate. To opt into the older finite-map bottom-left placement, set ContentBottomLeft explicitly:

snowpulse::TiledMapTransform transform {
    glm::dvec2(100.0, 40.0),
    snowpulse::TiledMapAnchorPolicy::ContentBottomLeft,
};
tiled->setMapTransform(transform);

const glm::dvec2 cellCenter =
    snowpulse::TiledProjection::cellToLocalCenter(*map, {3.0, 2.0}, transform);
const glm::ivec2 cell =
    snowpulse::TiledProjection::localToCell(*map, cellCenter, transform);

For object positions, first apply Tiled's orientation-specific object-pixel projection, then convert projected pixels into renderer-local coordinates:

const glm::dvec2 projected = snowpulse::TiledProjection::projectTiledPixels(
    *map, {object.x, object.y});
const glm::dvec2 local = snowpulse::TiledProjection::projectedToLocal(
    *map, projected, transform);

This extra projection matters for isometric maps because Tiled measures both object axes in tile-height units before projecting them onto the diamond grid. Do not apply orthogonal x / tileWidth, y / tileHeight object math to an isometric map.

Navigation coordinates are application policy and are not part of TiledProjection. A bottom-up square GridNavMap, for example, may map a finite orthogonal Tiled cell (x, y) to (x, map.height - 1 - y). An isometric or infinite navigation system should define its own bounds and origin rather than assuming that convention.

The pre-v2 helpers tileCellToWorldCenter, worldToTileCell, tiledPixelsToWorld, tiledToNavCell, and navToTiledCell were removed. Migrate to projectTiledPixels, projectedToLocal, cellToLocalCenter, and localToCell plus an explicit TiledMapTransform.

Mutation and Animation

Renderer mutations are in-memory only:

(void)tiled->setLayerVisible(2, false);
(void)tiled->setLayerOpacity(1, 0.75f);
(void)tiled->setTileRawGid(2, {5, 7}, 0u);
(void)tiled->setObjectVisible(300, true);

tiled->setAnimationsEnabled(true);
tiled->setAnimationTimeScale(1.0f);
tiled->restartAnimations();

Loading or unloading invalidates renderer-backed views. Mutations advance the document revision and dirty only the affected object, tile, chunk, or render cache; they do not rewrite .tmj, .tsj, or .tj files.

Platform Packaging

  • Desktop loads the preserved asset tree from the configured asset root.
  • Web eager builds preload the tree. Lazy builds validate and package the transitive Tiled dependency graph before producing boot/deferred packs.
  • iOS preserves subdirectories under Resources/assets/.
  • Android reads the preserved tree directly through AAssetManager; do not use filesystem-rooted asset methods for packaged maps.

All engine variants link the vendored zstd 1.5.7 decompressor, so zstd layer data behaves consistently across desktop, WebAssembly, iOS, and Android.