t1k:designer:base:tool-export-adapters
| Field | Value |
|---|---|
| Module | base |
| Version | 1.14.1 |
| Effort | medium |
| 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
How to invoke
Section titled “How to invoke”/t1k:designer:base:tool-export-adaptersGame Design Tool — Engine Export Adapters
Section titled “Game Design Tool — Engine Export Adapters”When This Skill Triggers
Section titled “When This Skill Triggers”- 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.tsconverter. - 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.
The Adapter Contract
Section titled “The Adapter Contract”A good export adapter is:
- Pure —
(level, options) → engineJson. No I/O, no globals. Trivially testable. - 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.
- Deterministic — stable emission order. Critical when the engine assigns seeded/shuffled IDs to entries in list order (any reorder changes the game).
- 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).
- 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 type | Editor cell (col,row) maps to | Watch out for |
|---|---|---|
| Square, unit = N | { x: col*N, y: row*N } | Adjacency rules that check ±N |
| Stacked layers | layer = layerArrayIndex | Vertical blocking = same (x,y) on higher layer |
| Hex (axial) | { q, r } from offset coords | Offset→axial conversion is not ×N |
| Half-step / staggered | Pure ×N can’t express it | May 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 checksx ± 2horizontal neighbours (spec §6/§9GridUnit=2). Two adjacent editor cells must land 2 apart or blocking breaks. - Maps layer array index →
layerint — 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;LevelLoadererrors ifmaxTileId <= 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 }}Wiring It Into The Editor
Section titled “Wiring It Into The Editor”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`)}Validation Bridging
Section titled “Validation Bridging”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-architectureStage 4). - Surface failures with the engine’s own error wording so the fix is obvious.
Testing
Section titled “Testing”- Round-trip the shape, not just types: feed a hand-built
Levelthrough the adapter and assert exact{x,y,layer}outputs (including the×gridUnitscaling and layer stacking). - Compare against a real shipped level — key names and structure must match byte-for-byte intent (e.g.
LevelJsonnotlist, top-levelmaxTileId). - 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.
Anti-Patterns
Section titled “Anti-Patterns”- ❌ 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.
Related
Section titled “Related”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.