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:
.tmjmaps,.tsjtilesets, and.tjobject templates;- legacy
.jsonmap/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.txsources; - 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:
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.