Skip to content

t1k:designer:base:tool-export-adapters

FieldValue
Modulebase
Version1.14.1
Effortmedium
Tools

Keywords: coordinate mapping, dense grid, design tool, deterministic emission, engine loader, engine schema, export adapter, GameDesignToolTemplate, grid unit, layer index, level editor, level export, level JSON, mahjong, sparse runtime, validation bridging

/t1k:designer:base:tool-export-adapters

Game Design Tool — Engine Export Adapters

Section titled “Game Design Tool — Engine Export Adapters”
  • Wiring the GameDesignTool’s level editor (or any module) output to a game engine’s level loader.
  • The editor’s raw JSON shape does not match what the engine parses (the common case).
  • Adding a new game to the template and needing a <game>-export.ts converter.
  • Reviewing an existing export adapter for correctness (coordinate mapping, determinism, validation).

This complements tool-architecture (which covers the module system + 5-stage pipeline). That skill’s Stage 5 (“Engine output”) is where export adapters live — this skill is the missing detail for that stage.

Core Principle — The Editor Is Engine-Blind

Section titled “Core Principle — The Editor Is Engine-Blind”

The Game Design Tool stores levels in a dense, editor-friendly shape (per-layer 2D grids, named layers, UI flags). The engine wants a sparse, runtime-friendly shape (flat coordinate lists, integer layer indices, the fields its loader reads). These almost never match. The adapter is a pure function that maps editor SSOT → engine format. It is the ONLY place that knows the engine’s schema.

Never make the editor store the engine format directly — that couples the design tool to one engine and breaks reuse. Keep the editor canonical; convert on export.

A good export adapter is:

  1. Pure(level, options) → engineJson. No I/O, no globals. Trivially testable.
  2. Lossy on purpose — drop editor-only data the engine doesn’t need (tile palette identity, layer names, visible/locked flags). Document what’s dropped and why.
  3. Deterministic — stable emission order. Critical when the engine assigns seeded/shuffled IDs to entries in list order (any reorder changes the game).
  4. Coordinate-faithful — the editor’s cell grid and the engine’s world grid may use different spacing. Map so that gameplay-relevant adjacency is preserved (see below).
  5. Validated at the boundary — warn/guard on the engine’s invariants (even tile count, required fields, non-empty) before writing.

The Coordinate-Mapping Trap (most common bug)

Section titled “The Coordinate-Mapping Trap (most common bug)”

Editor grids are contiguous (cells at step 1: col 0,1,2…). Engines often use a different unit, and game rules can depend on that spacing. If an engine’s blocking/adjacency rule checks neighbours at ±N, a naive 1:1 cell→coordinate mapping silently changes the rules.

Rule: find the engine’s grid unit and adjacency rules FIRST, then map editorCell × gridUnit → engineCoord. Confirm that two cells the designer sees as adjacent become neighbours the engine recognizes.

Engine grid typeEditor cell (col,row) maps toWatch out for
Square, unit = N{ x: col*N, y: row*N }Adjacency rules that check ±N
Stacked layerslayer = layerArrayIndexVertical blocking = same (x,y) on higher layer
Hex (axial){ q, r } from offset coordsOffset→axial conversion is not ×N
Half-step / staggeredPure ×N can’t express itMay need sub-cell support in the editor

Worked Example — Mahjong Solitaire (this repo)

Section titled “Worked Example — Mahjong Solitaire (this repo)”

File: src/modules/level-editor/lib/mahjong-export.ts

The editor stores a Level as named layers each holding a dense data[row][col] grid of tile-def strings (or null). The Unity loader (Assets/Mahjong/Scripts/Data/LevelLoader.cs) instead parses:

{ "maxTileId": 34, "LevelJson": [ { "x": 4, "y": 3, "layer": 0 }, ... ] }

The adapter:

  • Discards tile identity — Mahjong assigns IDs at runtime from a server seed, so only tile presence matters, not which palette tile was painted.
  • Scales coordinates ×2 (MAHJONG_GRID_UNIT = 2) — the blocking rule checks x ± 2 horizontal neighbours (spec §6/§9 GridUnit=2). Two adjacent editor cells must land 2 apart or blocking breaks.
  • Maps layer array index → layer int — bottom layer = 0; vertical blocking = a tile at the same (x,y) on a higher layer.
  • Emits layer-major, then row, then col — stable order, because Unity assigns seed-shuffled IDs to tiles in list order (spec §3).
  • Adds maxTileId (default 34) — required; LevelLoader errors if maxTileId <= 0.
  • Guards parity — warns on odd tile count (every tile needs a pair; the unpaired one can never match), mirroring LevelLoader’s warn-not-reject behaviour.
export function levelToMahjong(level: Level, opts: MahjongExportOptions = {}): MahjongLevel {
const gridUnit = opts.gridUnit ?? MAHJONG_GRID_UNIT // 2
const maxTileId = opts.maxTileId ?? MAHJONG_MAX_TILE_ID // 34
const tiles: MahjongTile[] = []
level.layers.forEach((layer, layerIndex) => {
for (let row = 0; row < layer.data.length; row++) {
const cols = layer.data[row]
for (let col = 0; col < cols.length; col++) {
if (cols[col] != null) tiles.push({ x: col * gridUnit, y: row * gridUnit, layer: layerIndex })
}
}
})
return { maxTileId, LevelJson: tiles }
}

Add a second export button alongside the raw JSON export (keep the raw one — the editor’s import round-trips the native format). Guard the engine’s invariants in the handler before saveAs:

const handleExportEngine = () => {
const out = engineConvert(editor.level)
if (out.entries.length === 0) { alert('Nothing to export.'); return }
if (out.entries.length % 2 !== 0 && !window.confirm('Odd count — export anyway?')) return
saveAs(new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }), `${name}.engine.json`)
}

Mirror the engine’s hard invariants on the tool side so designers catch failures before the engine does:

  • Required fields present (what the loader errors on — e.g. maxTileId > 0).
  • Count/parity rules (pairs, min/max entities).
  • Solvability/reachability if cheap to check tool-side (else defer to the engine’s load-time validator — tool-architecture Stage 4).
  • Surface failures with the engine’s own error wording so the fix is obvious.
  • Round-trip the shape, not just types: feed a hand-built Level through the adapter and assert exact {x,y,layer} outputs (including the ×gridUnit scaling and layer stacking).
  • Compare against a real shipped level — key names and structure must match byte-for-byte intent (e.g. LevelJson not list, top-level maxTileId).
  • Transpile the real adapter file and run it (npx esbuild file.ts --format=esm) — type-only imports erase, so the pure function runs standalone in Node.
  • ❌ Storing the engine format in the editor (couples tool to one engine; breaks reuse).
  • ❌ 1:1 cell→coordinate mapping without checking the engine’s adjacency unit.
  • ❌ Non-deterministic emission order when the engine seeds IDs by list position.
  • ❌ Trusting a spec doc over the engine’s actual loader code + a shipped sample (Mahjong’s spec said "list"; the loader parses "LevelJson"). Ground-truth = loader source + a real level file.
  • ❌ Silently exporting invalid output (odd counts, missing required fields) with no warning.
  • tool-architecture — module system + 5-stage pipeline (this skill details Stage 5).
  • data-authoring — CSV/JSON authoring conventions for flat/nested data.
  • The engine kit’s loader (e.g. Unity LevelLoader.cs) is the authoritative schema source.