Tiles and engine exports
Structural roles, generic output, Tiled Wang sets, and Godot terrains.
PixelLab connectable output is not merely a directory of images. The response
contains ordered storage_urls plus tile_rules describing which adjacency
mask belongs to which image. PixelKiln preserves both pieces so downstream
tools never have to reconstruct semantics from filenames.
Stored state
A completed lock entry has role-qualified outputs in provider order and raw metadata under the provider id:
{
"outputs": [
{ "path": "terrain-tile-00.png", "sha256": "…", "role": "tile-00" },
{ "path": "terrain-tile-01.png", "sha256": "…", "role": "tile-01" }
],
"providerMetadata": {
"pixellab": {
"tileKind": "tileset",
"tileRules": {
"rule_type": "corner",
"arity": 4,
"connectivity": "other",
"terrains": ["feature", "background"],
"tiles": { "tile_0": 0, "tile_1": 1 }
}
}
}
}
Provider keys can contain gaps when a set includes stamp-only images. Output
roles therefore retain the original numeric key (tile-07) rather than being
renumbered by array position.
The terrain generator's shape
terrain (/create-tileset, or /create-tileset-sidescroller for a
sidescroller set; docs/GENERATORS.md) is a different
PixelLab endpoint from tiles' tileFeature: "tileset" above, and its
response carries no tile_rules bitmask at all. Instead each tile names
which terrain occupies its four corners directly:
{
"providerMetadata": {
"pixellab": {
"terrainTypes": ["lower", "upper"],
"terrainTiles": [
{ "role": "tile-00-none", "corners": { "NW": "upper", "NE": "upper", "SW": "upper", "SE": "upper" } },
{ "role": "tile-01-nw-ne-sw-se", "corners": { "NW": "lower", "NE": "lower", "SW": "lower", "SE": "lower" } }
],
// Each terrain's base tile id; a connected set sends one of these.
"terrainIds": { "lower": "<uuid>", "upper": "<uuid>" }
}
}
}
A sidescroller set names its terrains ["central", "background"] while its
corners still say lower (the platform) and upper (empty air; the
all-upper tile is blank). PixelKiln records the corner vocabulary,
["lower", "upper"], as terrainTypes so masks match, and keeps
PixelLab's names as providerTerrainTypes beside view: "side". In Tiled
and Godot the platform is terrain 0.
normalizeTerrainTiles() (src/pipeline/tileset-export.ts) turns this into
the same NormalizedTileRules shape tileRules would have produced directly:
ruleType: "corner", arity: 4, terrains from terrainTypes, and a mask
per tile built from its corners with the same NW/NE/SW/SE = 3/2/1/0 bit order
described under Tiled below (a corner matching terrains[0] sets its bit).
exportTileset() tries tileRules first and falls back to this shape, so
Tiled and Godot export a terrain asset's real Wang placement data rather than
a plain, rule-less atlas. A tile's role carries a name slug rather than a
parseable numeric suffix (tile-01-nw-ne-sw-se, not tile-01), so its
sourceIndex falls back to array position instead of being parsed from the
role — correct here because terrainTiles and the output list are pushed in
the same loop, but worth knowing if either is ever reordered independently.
A tile whose corner is "transition" (the extra ring PixelLab adds at
terrainTransitionSize: 1, or 0.5 on Pro) has no bit of its own in a two-terrain corner
mask; it is folded into whichever of terrains[0]/terrains[1] it isn't.
That 25-tile layout covers 21 distinct corner combinations. Four
wall-continuation tiles share their corners with a twin and differ only in
pattern_4x4, which PixelLab says is the field to match on. PixelKiln
stores each tile's pattern4x4 but builds masks from corners, so each
twin pair gets the same mask.
Command
pixelkiln export --style ground --only terrain --format generic
pixelkiln export --style ground --only terrain --format tiled
pixelkiln export --style ground --only terrain --format godot
export selects downloaded tiles and terrain generator entries; anything
else is not a tileset and does not match --style/--only. (Selecting only
tiles was itself a bug once terrain shipped: runExport()'s own generator
filter rejected every terrain asset before it ever reached exportTileset(),
so the normalization above ran and was tested but nothing in the CLI could
invoke it. Fixed in src/cli/commands/pack.ts; test/export-cli.test.ts
exercises runExport() itself so this can't regress silently again.)
--format defaults to generic. --out dist/terrain overrides the output
base when exactly one entry is selected. --columns controls atlas columns.
Without --out, files land in the style's outDir as
<asset>-tileset.<extension>, <asset>-tileset.png, and
<asset>-tileset.pixelkiln.json. A hand-edited set (pixelkiln edit
or the gallery's editor) is exported tile by tile from its edit files, and the
companion records those paths as the sources.
The companion record is engine-neutral: it stores portable source paths and
SHA-256s, export options (including raw provider rules), output hashes, and a
canonical fingerprint. verifyArtifactBundle() can detect changed inputs,
edited/missing outputs, or altered provenance offline without rebuilding the
atlas. The project manifest and lockfile are conservative inputs, so
newly declared or recorded tiles also make an older export stale. The TSJ/TRES
contracts therefore remain free of PixelKiln-only fields.
An existing export without a companion is adopted only when its bytes already
match. Once tracked, a changing output must still match its recorded hash;
manual edits stop the whole export rather than being silently replaced. Use
--force only after reviewing the difference to take ownership and re-baseline
the complete bundle.
An immutable transaction journal exists only while the three files are being replaced. Following abrupt termination, the next export restores the old set if commit was incomplete, or retains the fully committed new set and removes its backups. Recovery will not follow journal paths outside the current bundle or interrupt a live writer.
Generic JSON
The generic format is the lossless interchange contract:
format: "pixelkiln-tileset"andversion: 1- atlas image, cell size, sheet size, and columns
- one ordered tile record with atlas id, original provider
sourceIndex, role, rectangle, optional bitmask, andstampOnly - normalized rule type, arity, connectivity, terrain names, and masks
- the complete raw provider rule object in
providerRules, including rule families a current exporter does not yet normalize
Use it for custom engines, unsupported rule families, or as the input to a project-specific importer.
Tiled
The .tsj export creates an image tileset and a Wang set. Tiled Wang ids are
written in its documented order: top edge, northeast corner, right edge,
southeast corner, bottom edge, southwest corner, left edge, northwest corner.
For PixelLab corner masks, bits NW/NE/SW/SE = 3/2/1/0 are placed into the four corner positions. For edge masks, bits N/E/S/W = 0/1/2/3 are placed into the four edge positions. Terrain names come from the provider metadata; editor colors are deterministic display colors and do not alter the artwork.
Godot 4
The .tres export creates a TileSet containing one TileSetAtlasSource.
Four-corner masks use TERRAIN_MODE_MATCH_CORNERS; four-edge masks use
TERRAIN_MODE_MATCH_SIDES. Each mask becomes the corresponding terrain peering
bits on its atlas tile. Isometric and hex tile shapes are carried from the
resolved manifest spec.
The peering-bit property names depend on tile shape, and Godot enforces
it. A square tile's corners are top_left_corner/top_right_corner/
bottom_left_corner/bottom_right_corner; an isometric or oblique
(diamond) tile's are the diamond-point names top_corner/right_corner/
bottom_corner/left_corner — the square names are invalid on a diamond
tile and Godot's own is_valid_terrain_peering_bit() rejects them outright,
so writing the wrong set produces a .tres that looks plausible but carries
no usable terrain data at all once loaded. The corner-mask → isometric-name
mapping (mask bit 3/2/1/0 = NW/NE/SW/SE → top_corner/right_corner/
left_corner/bottom_corner) is confirmed against a production, hand-verified
isometric Wang set (a downstream consumer's docs/terrain-tiles.md §5: every
tile rendered, its corners sampled, checked against its mask). Edge/side
masks on an isometric or oblique tile are refused rather than guessed — the
diagonal peering-bit direction for that case hasn't been verified the same
way — so --format godot on an edge-ruled isometric tileset asks for
--format generic or --format tiled instead, neither of which has this
shape dependency.
The texture path is relative to the .tres, so the generated PNG and resource
can move together inside a Godot project.
terrain-generator tiles are always square, never isometric.
/create-tileset places tiles on a rectangular vertex grid (its own docs: "a
map of W×H cells has (W+1)×(H+1) vertices" — standard square Wang-tiling
math, with no shape parameter at all, unlike create-tiles-pro's tile_type
enum), and its raw output is fully opaque with no diamond alpha mask,
confirmed against a downstream consumer's real generated tiles. terrain's
resolved spec always carries tileType: "square_topdown" for exactly this
reason, so the exporter never falls back to tiles' own "isometric" default
(the real API default for that generator, not terrain's) for a terrain
asset. The isometric corner-name mapping above is real and does apply — to
an isometric tiles-generator tileset (tileFeature: "tileset" with
tileType: "isometric"), not to terrain.
Both this TileSet and the SpriteFrames that pack --format godot writes
are loaded by a headless Godot 4.7.2 in CI (npm run test:godot), which
reports the atlas tiles, terrain sets and names, animation names, frame
counts, speeds, and loop flags it sees, and — for both a square terrain
tileset and an isometric tiles tileset — round-trips an actual peering bit
through Godot's own get_terrain_peering_bit() rather than only checking
tile/terrain-set counts. That last check is what would have caught the
isometric-corner-names bug; the counts alone did not.
Deliberate limits
- Tiled and Godot exports require every image to match the atlas cell size.
- Six-edge hex masks remain generic-only. Their engine layouts depend on hex orientation and offset conventions that the current manifest does not yet declare.
outlineand building-kit images absent from the rule map are stamp-only. Generic export retains them; engine exporters reject a rule family whose placement semantics would have to be invented.- A
terrainasset's"transition"corners collapse into a two-color mask (see above); the cliff-tier 25-tile set exports, but its middle transition ring is not distinguished from the two named terrains in Tiled/Godot, and its four wall-continuation twins export with duplicate masks. - A
terrainset exports alone. A connected set (connectin the manifest,lower_base_tile_id/upper_base_tile_idin the API) shares a terrain with another set, the way PixelLab's Pro tilesets guide paints ocean, sand, grass, path, and cliffs on one map, but each set still exports as its own two-terrain Tiled Wang set or Godot terrain set. PixelKiln does not merge connected sets into one engine terrain set yet. - Godot edge/side masks on an isometric or oblique tile remain generic- or Tiled-only; see Godot 4 above.
- Export never changes source PNGs or the lockfile.
