# Feature Research — v1.1 Map Groundwork

**Domain:** 2D MMO map-authoring toolchain + fast-deploy + in-game minimap (Phaser 3 + LDtk + legacy GameMaker import)
**Researched:** 2026-05-18
**Confidence:** MEDIUM-HIGH (LDtk JSON shape and Phaser patterns HIGH from official docs; converter strategies MEDIUM — extrapolated from analogous Tiled/Unity importer patterns; minimap fog-of-war persistence MEDIUM — no direct MMO 2D Phaser precedent located)

## Scope reminder

This document covers the FOUR feature areas asked for the v1.1 Map Groundwork milestone. v1.0 features (Phaser 3.90 client, Colyseus authoritative server, AST-01 content-hashed manifest, Phase 06.5 `/data/client-assets/current` symlink-swap, single MVP synthetic room, client-authoritative movement) are TREATED AS BUILT — not re-researched. References to them are by name only.

---

## Feature Landscape

### Area 1 — Legacy → LDtk converter

#### Table Stakes

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| One-shot importer `tools/legacy-to-ldtk` (CLI) reading `extracted/client-5-8/rooms/{N-Name}/{meta,instances,tiles,backgrounds,creation-code}.{json,gml}` and emitting a single `.ldtk` JSON project | A reusable round-trip importer is the only way ~6000 BNCentral instances cross over without per-room hand-authoring | LARGE | LDtk JSON is well-documented and QuickType generates `LdtkJson.ts` types directly from the official schema. No first-party Phaser loader exists (`ldtk-ts` archived 2022; `mobilex1122/phaser-ldtk-importer` is alpha with 3 stars and abandoned). Build the importer against QuickType-generated `LdtkJson.ts`, not against a wrapper lib. |
| **Per-instance `creationCode` carry-forward** as opaque string field on entity (`F_Text` multi-line) | 6000 instances; some have GML (e.g. BNCentral entity 128992 has `"dyspeed = -1; dxspeed = 0.25;"`). Discarding it is lossy and unrecoverable from the `.ldtk` round-trip | MEDIUM | Use a `creationCode: F_Text` custom field on the generic `LegacyEntity` definition. Do NOT attempt to parse/typify GML in v1.1 — that's a runtime concern, not an authoring concern. **Anti-feature warning below covers typed parsing.** |
| Mapping legacy `objectId` catalog → LDtk Entity definitions | The `objectId` is the only reliable identity key between extracted data and authored data | MEDIUM | One LDtk EntityDef per legacy `objectId`. EntityDef name = legacy object name (from extraction catalogue). Custom fields: `legacyObjectId: F_Int` (auto-populated, read-only at author time), `instanceId: F_Int` (legacy `instanceId` preserved for trace), `creationCode: F_Text`. Default sprite = the extracted sprite for that object. |
| Floor-tile/entity decomposition per the milestone categories (static / animated / metadata / interactive / entity) | The milestone explicitly calls for this decomposition; LDtk maps cleanly to it via different layer types | LARGE | See dedicated table below — this is the load-bearing authoring decision. |
| Round-trip ID stability: re-running the importer on the same `extracted/` input produces a byte-stable `.ldtk` (modulo `__c` calculated fields) | Without stable ordering, the importer is hostile to git diff review during the conversion campaign | MEDIUM | Sort instances by legacy `instanceId` ASC; mint LDtk UIDs deterministically from a hash of `(roomId, instanceId)` rather than auto-incrementing. LDtk uses int UIDs but does not require densely-packed ones. |
| 44×40 px tile-grid alignment baked into the importer | Hard-coding 32×32 anywhere breaks the load-bearing extracted-constant from CLAUDE.md | LOW | Importer reads `meta.json` width/height (the doc-confirmed BNCentral is 8000×6400), derives world grid from `Tile1` 44×40. Every layer in the emitted `.ldtk` MUST use `gridSize: 44` on X axis and `40` on Y axis. Note that LDtk's `gridSize` is a single int per layer — see "Gotcha" below. |

#### Differentiators

| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| **Per-category authoring sub-decomposition** (5 layer kinds, one importer pass) | Maps milestone categories 1:1 to LDtk's three layer types (IntGrid, Tile, Entities), giving artists fast paint UX for static tiles while keeping per-instance state on entities | LARGE | See "Authoring sub-decomposition" table below. |
| LDtk Auto-Layer rules **generated from extracted DYNAMIC tile variants** | Hand-authoring all 17+ neighbor-sensitive sprite rules per DYNAMIC tile family (Floes of Ghennam ice, water, snow) is tedious + error-prone. Auto-generating rules from legacy sprite naming convention saves days of authoring | MEDIUM | LDtk rules support 1×1 → 9×9 patterns, NOT/AND, randomization weights, mirror/rotate per rule. Legacy DYNAMIC family naming convention (LONE/TopN/TopEnd/TLCorner/LRBridge/Surrounded/Special) is structured enough to translate. Per LDtk issue #985 + #1006, rule authoring above ~10 variants is the dominant LDtk pain point — automating that pays back immediately. |
| Asset reference linker: importer emits LDtk Tilesets pointing to atlas frames produced by AST-01 pipeline (NOT raw extracted PNGs) | Keeps LDtk decoupled from raw asset layout; v1.0 AST-01 already content-hashes; one source of asset truth | MEDIUM | Generate a `tilesets/` directory of per-family atlases at importer time, OR (preferred) reuse AST-01's existing manifest output and emit LDtk Tilesets that reference them by relative path. Names locked to `objectId` keep refs stable. |
| BNCentral split into GridVania chunks at import time | Editor lag at 8000×6400 is documented (LDtk issues #1029, #1073) | MEDIUM | Importer takes a `--split=auto\|none` flag. Auto = quadrant or 4×4 split for any room exceeding ~5000 px on either axis. Quadrants snap to 44×40 grid; emit as separate Levels in a GridVania world with `worldX/worldY` adjacency preserved. |

#### Anti-Features (NOT in v1.1)

| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| **Parse legacy `creationCode` GML into typed LDtk fields** at import time | "Surely we can know `dyspeed = -1` means platform-Y-velocity and store it as `F_Float`" | The `creationCode` is arbitrary GML — calls scripts, branches on globals, etc. A typed extractor for any non-trivial subset is a multi-week semantics-replication project that belongs in the runtime layer (v1.x), not the authoring layer | Store opaque `F_Text`; runtime port reads + interprets per entity class as Phase 7+ ports each interactive type. **REQ candidate: REJECT typed-field carry-forward in v1.1.** |
| **Bidirectional round-trip** (LDtk → legacy `extracted/` JSON) | "We should be able to ship edits back into the extraction" | Extraction is one-shot from `.gmd` source-of-truth; LDtk is the new authoring surface, period. Editing then re-writing extraction creates two competing sources of truth | One-way: `extracted/` → `.ldtk` (importer), then `.ldtk` is canonical for all subsequent edits. |
| **Replicate GameMaker depth value as numeric `F_Int` per instance** | "Legacy GM uses per-instance depth sort" | LDtk has no within-layer z-order. Wedging depth into a sort field re-imports the GM mental model rather than the LDtk mental model | Use **layer stacking** for z-order (Floor → HexUnder → [player runtime] → HexOver → Entities); within-layer order = author order; if a legacy instance needs hexport sub-tile depth, assign it to the correct layer at import time. This is the entry in the editor-decision note's accepted-tradeoff list — implement it. |
| **Convert backgrounds.json to LDtk image-layer with parallax** | Looks like a 1:1 mapping | LDtk image layers are a recent addition with rendering quirks; legacy BNO backgrounds are often a single repeating tile, easier as a Tile layer at z=0 | Carry backgrounds.json shape into an opaque world-level custom field; runtime renders parallax in Phaser directly. Don't fight LDtk's image-layer model. |
| **Auto-populate LDtk enums from the entire legacy object catalogue** | "Every objectId could be an enum value" | Catalogue is ~hundreds of items; enums in LDtk are best at <50 values. Picker UX degrades fast | Per-objectId is the EntityDef itself; enums reserved for orthogonal axes (e.g. `HexporterDirection: U/D/L/R`, `MoverDirection: U/D/L/R`). |

#### Authoring sub-decomposition (load-bearing for the milestone)

The five categories called out in the milestone map to LDtk constructs as follows:

| Milestone category | LDtk construct | Why | Conversion strategy |
|---|---|---|---|
| Static floor tiles (BORDERED, BORDERLESS) | **Tile Layer** (auto-layer driven by IntGrid) | Paint-fast, small file, no per-instance state | Importer reads tile sprite ID per instance; emits an IntGrid value (1=BORDERED, 2=BORDERLESS); auto-layer rule paints the corresponding tile from the floor-tile tileset |
| Animated floor tiles (GLOW pulse, FLICKER) | **Tile Layer + IntGrid value** with `animationKey: F_String` on IntGrid value metadata; OR Entity if per-instance phase | Animation timing is a runtime concern (LDtk has no native animated tiles, accepted tradeoff in editor-decision note) | IntGrid values 3+ encode "animated-static-tile-of-species-X"; Phaser owns frame timing. FLICKER's randomized present-time/gone-time stays runtime — no per-instance LDtk data needed unless tile groups want shared phase. |
| Tiles with metadata (HIDDEN, MOVER, DYNAMIC-neighbor-sensitive, SLIPPERY, QUARTERED) | **Mixed: Tile Layer (IntGrid auto-rules) for DYNAMIC; Entity Layer for MOVER (carries `dxspeed/dyspeed`)** | DYNAMIC needs auto-tile rules on neighbor pattern → pure IntGrid+auto-layer. MOVER has per-instance velocity → must be an entity to carry custom fields | Importer fork: pattern-only metadata → IntGrid; per-instance metadata (in extracted `creationCode`) → Entity. The fork key = "does the legacy instance have non-empty `creationCode`?" |
| Interactive tiles (HEXPORTER, HEXSLING, HEXBRIDGE, WARP, SPECTRAL_GATE) | **Entity Layer** with typed custom fields (enum for direction set, `F_EntityRef` for warp destinations, `F_Bool` for station-only) | Per-instance state is mandatory. Warp dest references must be resolvable cross-level → `F_EntityRef` is the only LDtk field type that crosses level boundaries | Per-objectId EntityDef. WARP carries `destination: F_EntityRef` (LDtk 1.2+ supports cross-level refs). HEXPORTER carries `directions: F_EnumArray<U,D,L,R>` + `stationOnly: F_Bool`. Importer parses the (small, finite) `creationCode` subset for these specific entities — this is the ONE place creationCode parsing is warranted, since these are runtime-load-bearing. |
| Entities (NPCs, drops, props, chatob, etc.) | **Entity Layer** — generic `LegacyEntity` def per objectId | Same as above, but no `creationCode` parsing — opaque carry-forward only | One EntityDef per `objectId`. Sprite = extraction-catalogue sprite. Fields = `creationCode: F_Text` (opaque), `instanceId: F_Int` (trace), `objectId: F_Int` (auto, read-only). |

**Gotcha — LDtk `gridSize` is a single int.** BNO's 44×40 is non-square. LDtk historically assumes square cells. Two options:

1. Set `gridSize: 4` (GCD of 44 and 40) and have the importer place every instance at world coords (instance pixel-coordinates remain at native resolution because LDtk Entities are placed in pixel space anyway, not grid space; this works for entity layers). **For Tile/IntGrid layers, this gives sub-cell paint precision — usable but wasteful for BORDERED floors.**
2. Set `gridSize: 44` and accept that the y-axis-on-paint will be off by `4/40 = 10%`, requiring the runtime to scale Y on render — UGLY, do not do this.
3. **Recommended:** Use **gridSize: 4** for IntGrid/Tile floor layers (allows native pixel precision per axis without LDtk caring); use **gridSize: 1** for Entity layers; runtime loader treats LDtk grid coords as pixel coords with no scaling. Validate before committing the importer.

This is the most likely-to-surprise corner of the importer and should be a HARD verification step in plan-execute.

---

### Area 2 — Map publish + fast-deploy workflow

#### Table Stakes

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Vite dev-server hot-reload on `.ldtk` file change | Authors save in LDtk → see the change in-game within ~1 second. Cycle time is the dominant authoring constraint | MEDIUM | Vite plugin uses `this.addWatchFile(ldtkPath)` in a `load` hook or the `hotUpdate` (≥ Vite 6) / `handleHotUpdate` plugin hook. Plugin emits a custom HMR event (`ldtk:update`) that the Phaser scene listens for and rebuilds the active LDtk world from. **Plan task:** ~1 day. Pattern is mainstream — same approach Excalibur's official LDtk plugin uses; Vite docs `liana.one` tutorial is the directly-applicable reference. |
| Production bake step: validate `.ldtk` → emit content-hashed asset bundle → ship to `/data/client-assets/current` via Phase 06.5 symlink-swap | The whole AST-01 + Phase 06.5 mechanism already exists and is validated. Map publish MUST ride on it, not parallel to it. CLAUDE.md notes "Static client asset split via `/data` symlink swap (Phase 06.5)" as validated — zero-cost client-only releases | MEDIUM | Add LDtk JSON (and any referenced tilesets) as inputs to AST-01's manifest hashing. Bake step runs `tools/legacy-to-ldtk validate` + emits the run-time-loadable form (could be the raw `.ldtk` if size is OK, or a pre-baked binary if not). Output participates in the same `current` symlink swap. **Existing Phase 06.5 symlink swap is the deploy primitive — do NOT introduce a parallel deploy path.** |
| Atomic swap with rollback (Linux `mv -T` / `ln -sfn` semantics) | Half-deployed map state = broken game; rollback to last-known-good must be instant | LOW | Already validated in Phase 06.5. Confirm: the existing implementation uses `mv -T` (preferred per atomic-deploy literature) and NOT `ln -sfn` (two syscalls, brief failure window per Hacker Noon write-up). If currently `ln -sfn`, file as a hardening ticket — this is genuinely a known bug class in the symlink-swap literature. |
| Pre-flight validation: importer/baker fails the bake if `.ldtk` references missing tilesets, contains unresolvable `F_EntityRef`s, or contains entities with `objectId` not in the catalogue | Broken maps must not reach `current` symlink | MEDIUM | Validation is a pure JSON-schema + reference-graph walk on the LDtk file. Fail loud. CI hook in Phase 5+ once CI is restored; until then, the operator-local `flyctl deploy` script runs validation before staging the upload. |

#### Differentiators

| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Version-pinned map releases on Fly volume (`/data/client-assets/releases/{hash}/` directories, `current` symlink → one of them; last N retained) | Instant rollback by symlink re-point; matches industry atomic-deploy pattern (Deployer, Etsy, Capistrano-style) | MEDIUM | Already the AST-01 + Phase 06.5 design implicitly. Make the retention count explicit (N=5 suggested). Confirm the existing implementation has this — if not, add it as part of v1.1 hardening, since map-iteration cadence in v1.1 will multiply the rollback need. |
| Map diff visibility: bake step emits a human-readable diff (level count, entity count delta, tileset changes) and posts it to operator's local console + commit message | Author can see "this bake adds 12 entities to Bahoo" before pushing | LOW-MEDIUM | Use LDtk's structured JSON to compute a semantic diff. Avoids reviewing arbitrary `.ldtk` line diff (which is large + noisy). |
| In-Phaser dev-mode visual diff overlay (toggle key to show "instances added/removed since last commit" tinted) | Lets a map author confirm their change applied as intended without git-diff-reading | MEDIUM | Probably v1.2 — flagged as differentiator to log the idea, not v1.1 scope. |
| Per-zone deploy granularity (rebuild only the `.ldtk` files that changed, not the whole bundle) | At full parity scale (16 rooms + ~hundreds of tilesets) full rebake gets slow | MEDIUM | AST-01 manifest hashing already makes this granular per asset; the bundle-level swap is still atomic. Confirm + document. |

#### Anti-Features (NOT in v1.1)

| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| **Live in-game map editing** (LDtk-style editor embedded in the Phaser client) | "Edit and see immediately in the actual game" | Reimplements LDtk inside the game; massive scope; competes with the locked editor decision | Use LDtk's standalone editor + Vite HMR; pattern already proven by Excalibur plugin. |
| **Push map updates to live players without reload** (hot-reload across connected clients) | "Authors paint, players see new tiles appearing live" | Requires complex client state migration; conflicts with v1.0 client-authoritative-movement model (a player's position may become invalid mid-tick); pushes load on Colyseus binary state schema in a direction it isn't designed for | All map updates require a client reload. Reload is fast (Phase 06.5 AST-01 manifest already cache-busts). |
| **Server-side per-tick map mutation API** | "Map updates without redeploy" | Tempting but creates two sources of truth (`.ldtk` files vs runtime mutations) and a new persistence requirement on top of SQLite + Litestream | All map data is baked + immutable per release; mutations require a publish. If runtime mutation surfaces later (event triggers, etc.) it lives in a separate "map events" subsystem, not in the LDtk pipeline. |
| **Database-backed map storage** (`.ldtk` → SQLite at deploy, server reads from SQLite) | Looks like fewer files | Loses git review of changes (the whole point of file-based authoring); SQLite blob is opaque to LDtk editor on re-open | Keep `.ldtk` as files in git, baked outputs as static assets on `/data` volume. Single source of truth: the `.ldtk` in git at the commit being deployed. |
| **Auto-deploy on `.ldtk` commit** | CI-driven instant deploy | CI is currently out of service (CLAUDE.md: GH Actions storage exhausted, operator-local deploy). Building auto-deploy now would only be relevant after CI restoration, which is itself a separate decision. | Operator-local `flyctl deploy` per `docs/deploy/LOCAL-DEPLOY.md`. Defer auto-deploy until CI decision lands. |

---

### Area 3 — In-game minimap UI

#### Table Stakes

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Player-position marker on minimap | Every minimap has one; without it, the minimap is "where am I" useless | LOW | Marker = small Phaser Image/Graphics on a UI camera, position computed as `(player.x / room.width, player.y / room.height) * minimap.size`. Re-anchor every frame. |
| Current-zone rendering: shows the current LDtk Level's geometry | Players need to orient within the zone they're in | MEDIUM | Pre-bake one minimap-PNG per LDtk Level at bake time (see comparison table below). Loaded as a separate texture; rendered to minimap UI element. |
| Fixed-position HUD overlay (corner of screen) that survives camera moves | Mainstream minimap UX expectation | LOW | Phaser 3 pattern: create a separate `minimapCamera` via `this.cameras.add(x, y, w, h)`, OR render to a `Phaser.GameObjects.RenderTexture` and place it in a UI scene that runs above the world scene. See "Implementation pattern decision" below. |
| Toggle visibility (hotkey to show/hide) | Some players want full-screen view, some want it gone | LOW | Standard. Bind to a configurable key; default `M` or `Tab`. |

#### Differentiators

| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| **Multi-zone overworld view using LDtk multi-worlds adjacency** | The single load-bearing differentiator — pulled directly from the milestone seed at `.planning/seeds/in-game-minimap.md` + the editor-decision note's accepted tradeoff section | MEDIUM-LARGE | LDtk's `worlds[]` array (1.3+) + GridVania per-level `worldX/worldY` give the data structure directly. Minimap reads the same `.ldtk` project as the runtime; renders levels at scale to their `worldX/worldY` positions. Per-level `__neighbours` (n/s/e/w/ne/nw/se/sw, GridVania) gives the adjacency arrows / fast-travel hints (if added). **No re-architecture cost; this is the headline LDtk feature that made it win over Tiled.** |
| Points-of-interest layer (warps, hexporters, NPCs, spawn points) | Lets players plan routes; classic MMO minimap feature | MEDIUM | Iterate LDtk entity layers at bake time, classify by EntityDef name, emit a JSON `poi-{level}.json` companion file. Runtime overlays POI markers on minimap textures. |
| Other-players-in-same-zone markers | Social/MMO core feature; complements existing chat | LOW | Reuse v1.0 Colyseus state stream — player positions are already broadcast. Render dot per peer player at scaled coords. Server load: zero. |
| Visited-area persistence (fog of war / explored-tiles bitmap) | Sense of progression; classic MMO/Metroidvania feature | LARGE | See dedicated comparison below. **Recommend: NOT v1.1.** This is the single biggest risk to milestone scope creep — easy to underestimate. |
| Click-to-show-zone-name tooltip on overworld | Quality-of-life | LOW | Standard. |

#### Anti-Features (NOT in v1.1)

| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| **Fog-of-war / visited-area persistence** in v1.1 | "MMO minimap classic" | Requires new persistence schema (per-player-per-zone visited bitmap), Litestream replication of additional state, careful storage size estimate (8000×6400 px / 44×40 tile = ~32k cells, ×N zones, ×N players — fits but adds a schema); ALSO requires explicit "what counts as visited" gameplay-rule decision | Render full known map immediately on minimap. Defer fog-of-war to a later seed-promoted phase once gameplay rules are decided. |
| **Click-to-fast-travel from minimap** | "Click warp on minimap, teleport there" | Implicates server-side travel-validity rules, anti-abuse (cooldown? cost?), conflicts with WrpPlugn requirement of legacy WARP entity. Out of scope for "show me where I am." | Defer to a fast-travel seed (already noted in `seeds/in-game-minimap.md` "out of scope for this seed"). |
| **Real-time minimap stream from server** (server pushes per-tile state) | "Authoritative minimap" | Map geometry is immutable per release (see Area 2 anti-features); only player markers change, and those already stream. No reason to add a minimap-specific server stream. | Bake static minimap textures; overlay player dots from existing player state. |
| **WebGL shader-based fog-of-war** (alpha mask with smooth reveal) | "Looks gorgeous" | Phaser 3 supports custom shaders but adds a maintainability surface; visual polish before persistence design is wrong order | If fog-of-war ships in a later phase, start with a simple bit-grid + sprite-tinted reveal. Shader can come later. |
| **Use Phaser tilemap rendering for minimap** (load actual tilemap, render at small scale via main render path) | "Reuse what's already there" | Minimap doesn't need per-tile fidelity; pre-baked PNG is 10-100× cheaper per frame; and the main tilemap is only fully loaded for the player's CURRENT zone, not all zones for an overworld view | Pre-bake per-level minimap PNGs at deploy time; render small. See comparison. |

#### Implementation pattern decision — minimap rendering

Three approaches surfaced in research; comparing for BNO's 8000×6400 zones, <50 CCU, 16 zones at parity:

| Approach | Pros | Cons | Verdict |
|---|---|---|---|
| **A. Second Phaser camera with `setViewport` + zoomed-out world view + `ignore` on UI/POI sprites** | Reuses Phaser's render pipeline; minimap reflects "live" state including dynamic entities | Renders the entire active scene a second time per frame; at 8000×6400 with all tiles + entities, this is wasteful; minimap only ever needs CURRENT zone's static geometry (other zones are not in the scene). Doesn't help with overworld view at all (other zones aren't loaded as Phaser scenes). | NOT recommended as the primary minimap surface. Acceptable for in-zone "live action" view if a future need arises. |
| **B. Pre-baked minimap textures per LDtk Level** (PNG generated at deploy/bake time; loaded as a regular Phaser texture; rendered to UI scene) | Cheap per frame (1 image draw); supports overworld view trivially (all level PNGs available, position them by `worldX/worldY`); minimap stays consistent across reloads; no extra render passes | Bake adds time (~ms per level — trivial); minimap doesn't reflect runtime entity state (NPCs that move, players nearby) without overlay layer | **RECOMMEND.** Combine with Approach C for player-and-peer markers. |
| **C. Vector overlay drawn from level data** (read LDtk JSON at runtime, draw rectangles/dots via `Phaser.GameObjects.Graphics`) | No baked PNG needed; fully data-driven; tiny memory; trivially supports POI/player markers | Visually unstyled (looks like a wireframe); for floor-tile rendering looks worse than a baked PNG; OK for overworld zone-outlines | **HYBRID with B:** B for level geometry (bottom layer), C for player + peer + POI markers (top layer). |

**Recommendation:** Approach B (pre-baked PNG per LDtk Level) for geometry + Approach C (Graphics-drawn markers) for live data. Render via a dedicated UI Scene that runs above the world Scene. Use `Phaser.Cameras.Scene2D.Camera.ignore()` for cross-scene exclusion of in-world objects if a second camera is added later. This is mainstream Phaser 3 practice — `cameras.add` + `ignore` example in Phaser official examples library.

---

### Area 4 — Test-bed minimal room

#### Table Stakes

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| One LDtk Level — `TestBed_001` — 20×20 BORDERED floor tiles, no entities, no auto-tile rules | Smallest end-to-end smoke test that exercises the entire pipeline (importer → `.ldtk` → bake → symlink swap → Phaser loader → render → walk on it) | LOW | Authored by hand (not imported) — proves the loader before the importer must work. Dimensions: 880 × 800 px (20·44 × 20·40 per CLAUDE.md extracted constants). Tile pitch MUST be 44×40 — this is the test that catches 32×32 regression. |
| The existing v1.0 MVP synthetic room is REPLACED by `TestBed_001` (not run alongside) | One canonical "smoke test room" avoids drift between two definitions | LOW | Phase 06 left the MVP room as a hand-coded synthesizer in the client. v1.1 retires that path in favour of the LDtk-loaded `TestBed_001`. Code deletion is part of the milestone. |
| Player spawn point as a single LDtk entity (`PlayerSpawn`) at known coords | Loader needs to know where to drop the player; entity-based spawn proves the entity layer works end-to-end | LOW | One EntityDef `PlayerSpawn`, no custom fields. Loader picks first instance. |
| Camera bounds + world bounds derived from the LDtk Level dimensions | Proves the loader correctly translates LDtk coords to Phaser world; catches off-by-one against existing v1.0 camera math (Scale.NONE + integer setZoom per ADR 0008) | LOW | World bounds = Level.pxWid × Level.pxHei. Camera bounds = same. Both already wired in v1.0; loader just needs to set them from LDtk data, not from hard-coded synth-room constants. |
| HUMAN-UAT: two players load `TestBed_001` from a deployed staging release, walk, chat — same gate shape as CLI-08 | Without HUMAN-UAT, "the loader works" is unverified at the integration boundary | MEDIUM | Same UAT pattern that cleared CLI-08 at commit `1f6073e` (HUMAN-UAT round 2). Two browsers, two real accounts (`dunsen_uat` + `rebbie_uat`), check movement + chat survive the LDtk-loaded room. |

#### Differentiators (probably v1.2)

| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| `TestBed_002` — 20×20 with one BORDERED MOVER entity carrying `creationCode` carry-forward | Smoke-tests the entity layer and `creationCode` opaque payload | LOW | Defer to v1.1 mid-milestone, once `TestBed_001` proves the pipeline. |
| `TestBed_003` — minimal multi-Level project with two adjacent Levels via GridVania | Smoke-tests the multi-world adjacency that the minimap needs | LOW | Defer to area-3 minimap work. |

#### Anti-Features

| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| **Use BNCentral or Prairie_Flats as the first imported room** instead of `TestBed_001` | "Why not test on real data?" | First real import will trip the BNCentral split decision, the gridSize 4-vs-44 decision, the auto-tile rule generation, AND the loader simultaneously — making any failure non-diagnostic | Smallest synthetic first (`TestBed_001`); ladder up: small imported room (Online_Lobby) next; BNCentral last. |
| **Keep the v1.0 synthetic MVP room as a fallback** if LDtk loader fails | "Belt and suspenders" | Two code paths drift; the synth room outlives its useful life | Delete the synth path with the milestone; if the loader fails, the milestone is blocked — that's the right signal. |

---

## Feature Dependencies

```
[Area 1: Importer]
    └──requires──> [Area 1: LDtk EntityDef catalogue from objectId map]
                       └──requires──> [v1.0 extracted/client-5-8/* — already exists]

[Area 1: gridSize decision (44×40 → LDtk 4×4 sub-cell strategy)]
    └──blocks──> [Area 1: Auto-layer rules for DYNAMIC tiles]
    └──blocks──> [Area 4: TestBed_001 authoring]
    └──blocks──> [Area 2: Vite plugin] (loader needs coord convention)

[Area 4: TestBed_001 + Phaser LDtk loader]
    └──proves──> [Area 1: Loader-side of importer pipeline]
    └──proves──> [Area 2: Vite HMR plugin] (test bed = first watched file)
    └──proves──> [Area 2: Bake + symlink swap] (test bed = first deployed map)
    └──unblocks──> [Area 3: Minimap pre-bake step] (needs at least one Level to bake)

[Area 2: Bake step]
    └──requires──> [v1.0 AST-01 content-hashed manifest pipeline — already exists]
    └──requires──> [v1.0 Phase 06.5 /data symlink swap — already exists]

[Area 3: Minimap]
    └──requires──> [Area 4: TestBed_001 working in deployed env] (proof loader works)
    └──requires──> [Area 1: Multi-level LDtk project] (for overworld view)
    └──optional──> [Area 1: Multi-world LDtk project with GridVania adjacency] (overworld differentiator)

[Anti-feature: typed creationCode parsing in importer]
    └──conflicts──> [Milestone scope] — REJECT in v1.1
[Anti-feature: fog-of-war persistence]
    └──conflicts──> [Milestone scope] — REJECT in v1.1
[Anti-feature: in-game live editing]
    └──conflicts──> [Locked LDtk editor decision] — REJECT permanently
```

### Dependency Notes

- **Area 4 (TestBed) precedes Area 1 (Importer) in execution order** — even though Area 1 produces the LDtk files Area 4 needs eventually, the loader (Phaser-side) is shared between them. Build the loader against a hand-authored `TestBed_001`, prove deployment, THEN write the importer.
- **gridSize decision is the early HARD gate.** Resolve before any LDtk file (test bed or imported) is created, otherwise re-authoring cost compounds.
- **Phase 06.5 symlink-swap is the ONLY deploy primitive.** Areas 2 and 4 both consume it. Do not introduce a parallel mechanism.
- **Multi-world adjacency (Area 3 differentiator) is the headline LDtk capability** — defer if scope tight, but if shipped, gives minimap overworld for free at zero re-architecture cost.

---

## MVP Definition for v1.1

### Launch With (v1.1 milestone)

- [ ] **Phaser-side LDtk loader** that reads a `.ldtk` JSON, instantiates Tile/IntGrid layers as Phaser tilemaps, instantiates Entity layers as Phaser GameObjects, applies camera/world bounds — proven against hand-authored `TestBed_001`
- [ ] **gridSize convention decision** (recommended: 4×4 IntGrid sub-cells) committed as a one-page ADR
- [ ] **Vite HMR plugin** watching `.ldtk` files, triggering Phaser scene reload of the active LDtk world
- [ ] **Bake step** integrated into existing AST-01 manifest pipeline; emits LDtk + dependent tilesets into hashed bundle; ships via Phase 06.5 symlink swap
- [ ] **`TestBed_001` deployed** end-to-end; HUMAN-UAT round (two players walk + chat on LDtk-loaded room)
- [ ] **Legacy → LDtk importer** (`tools/legacy-to-ldtk`) with the five-category decomposition, generic LegacyEntity with opaque `creationCode` carry-forward, deterministic UID minting, BNCentral split flag
- [ ] **One real imported room** end-to-end (recommend: Online_Lobby — smallest of the world rooms; Prairie_Flats acceptable backup)
- [ ] **Minimap MVP**: per-level pre-baked PNG + player marker on current zone (no overworld, no POI, no peer markers, no fog-of-war)
- [ ] **Old synthetic MVP room code path removed** (single source of room truth = LDtk)

### Add After v1.1 (v1.2 / mid-milestone if time)

- [ ] Importer: HEXPORTER/WARP/HEXSLING-specific `creationCode` parsing for the limited runtime-load-bearing subset
- [ ] Minimap: multi-zone overworld view using LDtk multi-worlds adjacency (the headline differentiator — promote to v1.1 launch if importer + minimap MVP slot lands cleanly)
- [ ] Minimap: peer-player markers + POI markers
- [ ] Bake: human-readable map diff in commit message + console output
- [ ] All ~16 rooms imported (full BNO catalogue)
- [ ] Auto-tile rule generation for DYNAMIC tile families

### Future Consideration (v2+)

- [ ] Fog-of-war / visited-area persistence (requires separate persistence design)
- [ ] In-game live-edit overlay
- [ ] Click-to-fast-travel from minimap
- [ ] WebGL shader fog-of-war polish
- [ ] Map event mutations (timed reveals, etc.)

---

## Feature Prioritization Matrix

| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| Phaser LDtk loader (TestBed_001) | HIGH (gates everything) | MEDIUM | P1 |
| gridSize decision ADR | HIGH (blocks file authoring) | LOW | P1 |
| Vite HMR plugin | HIGH (authoring cycle time) | LOW-MEDIUM | P1 |
| Bake + symlink swap integration | HIGH (deploy path) | MEDIUM | P1 |
| TestBed_001 + HUMAN-UAT | HIGH (proves end-to-end) | LOW | P1 |
| Legacy → LDtk importer (basic, 1 room) | HIGH (unblocks parity) | LARGE | P1 |
| Minimap MVP (current zone, player marker) | MEDIUM-HIGH (orientation) | MEDIUM | P1 |
| Old synth room removal | MEDIUM (hygiene) | LOW | P1 |
| Multi-world overworld minimap | HIGH (LDtk's killer feature) | MEDIUM | P2 (promote to P1 if scope allows) |
| All 16 rooms imported | HIGH (parity) | LARGE | P2 |
| Auto-tile DYNAMIC rule generation | MEDIUM-HIGH (saves days of authoring) | MEDIUM | P2 |
| HEXPORTER/WARP creationCode parsing | MEDIUM (interactivity in imported rooms) | MEDIUM | P2 |
| Peer-player markers on minimap | MEDIUM (social) | LOW | P2 |
| POI markers on minimap | MEDIUM (orientation) | LOW-MEDIUM | P2 |
| Map diff in bake output | LOW-MEDIUM (DX) | LOW | P3 |
| Fog-of-war persistence | LOW (premature; gameplay rule undefined) | LARGE | P3 / defer |
| In-game live-edit | LOW (against editor-decision) | LARGE | P3 / reject |

**Priority key:**
- P1: Must have for v1.1 launch
- P2: Should have; add mid-milestone if time, or v1.2 if not
- P3: Future consideration / explicit defer / reject

---

## Quality-gate trace

- ✓ Categories clear (table-stakes / differentiator / anti-feature) — every area has all three
- ✓ Complexity noted per feature (S/M/L) — LOW/MEDIUM/LARGE used per template
- ✓ Dependencies on existing v1.0 features identified — Phase 06.5 symlink, AST-01 manifest, Colyseus state stream, ADR 0008 viewport, extracted `meta.json`/`instances.json` shapes, extracted 44×40 constant all cited by name
- ✓ Legacy data-shape considered — `creationCode` (opaque carry vs typed-parse anti-feature), free-position (LDtk Entity is pixel-positioned natively), 8000×6400 BNCentral (importer split flag + LDtk editor lag mitigation), ~6000 entity instances (deterministic UID minting from instanceId hash + Entity layer)
- ✓ Phase 06.5 symlink-swap referenced explicitly — Area 2 tables; named as "the ONLY deploy primitive"
- ✓ Multi-world adjacency referenced for minimap multi-zone — Area 3 differentiator table + dependency graph + MVP promotion note

## Downstream-consumer trace (REQ candidates)

Each P1 above maps cleanly to ≥1 testable requirement. Suggested REQ IDs (prefixes used for v1.1; final IDs at requirements-definition step):

| Suggested REQ | Maps to feature | Testable as |
|---|---|---|
| `REQ-MAP-01` | Phaser LDtk loader | unit: load `TestBed_001.ldtk` and assert tile/entity counts |
| `REQ-MAP-02` | gridSize convention | doc: ADR `docs/adr/NNNN-ldtk-grid-convention.md` |
| `REQ-MAP-03` | Vite HMR plugin | int: modify `.ldtk` while dev server runs, assert reload event fires |
| `REQ-MAP-04` | Bake step | int: bake produces hashed bundle, manifest entry, symlink updates |
| `REQ-MAP-05` | TestBed_001 deployed + HUMAN-UAT | int + HUMAN-UAT round |
| `REQ-MAP-06` | Legacy → LDtk importer | unit: re-import is byte-stable; int: emitted `.ldtk` loads in Phaser |
| `REQ-MAP-07` | Minimap MVP | unit: pre-bake PNG dimensions correct; int: player marker tracks position |
| `REQ-MAP-08` | Old synth room removal | doc + grep gate: no `synthRoom`/synthetic-room references remain in client |

---

## Sources

- LDtk docs — [Loading LDtk](https://ldtk.io/docs/game-dev/loading/), [World](https://ldtk.io/docs/general/world/), [World layout JSON](https://ldtk.io/docs/game-dev/json-overview/world-layout/), [Entities](https://ldtk.io/docs/general/editor-components/entities/), [Auto layers / rules](https://ldtk.io/docs/general/auto-layers/auto-layer-rules/), [Entity fields](https://ldtk.io/docs/game-dev/json-overview/entity-fields/)
- LDtk multi-worlds — [1.3.0 multi-worlds preview](https://deepnight.itch.io/ldtk/devlog/522221/130-multi-worlds-preview), [0.10.0 wiki](https://github.com/deepnight/ldtk/wiki/%5B0.10.0%5D-Multi-worlds)
- LDtk performance issues — [#1029 IntGrid editing lag](https://github.com/deepnight/ldtk/issues/1029), [#985 Auto-layer rule improvements](https://github.com/deepnight/ldtk/issues/985), [#1006 Multiple IntGrid references](https://github.com/deepnight/ldtk/issues/1006)
- Phaser 3 minimap pattern — [Camera `ignore` example v3.85](https://phaser.io/examples/v3.85.0/camera/view/ignore-gameobjects), [Camera `setViewport` API](https://newdocs.phaser.io/docs/3.55.2/focus/Phaser.Cameras.Scene2D.Camera-setViewport), [BaseCamera docs](https://docs.phaser.io/api-documentation/class/cameras-scene2d-basecamera)
- Phaser-LDtk importer ecosystem — [`mobilex1122/phaser-ldtk-importer`](https://github.com/mobilex1122/phaser-ldtk-importer) (alpha, 3 stars, maintainer seeking help — confirms "no maintained loader" finding in editor-decision note)
- Vite HMR for external files — [HMR API](https://vite.dev/guide/api-hmr), [hotUpdate plugin hook](https://vite.dev/changes/hotupdate-hook), [Custom-language plugin tutorial](https://liana.one/custom-language-plugin-for-vite)
- Atomic symlink deploy pattern — [Deployer atomic symlink](https://deployer.org/blog/atomic-symlinks), [Atomic deploys from scratch](https://stevegrunwell.com/blog/atomic-deployments-from-scratch/), [Etsy atomic deploys](https://www.etsy.com/codeascraft/atomic-deploys-at-etsy/), [Atomically switch nginx directory](https://gehrcke.de/2018/11/atomically-switch-a-directory-tree-served-by-nginx/), [Truly atomic deployments with nginx](https://medium.com/hackernoon/truly-atomic-deployments-with-nginx-and-php-fpm-aed8a8ac1cd9)
- Project context (in-repo) — `.planning/PROJECT.md`, `.planning/notes/map-editor-decision.md`, `.planning/seeds/in-game-minimap.md`, `docs/LEGACY_FEATURE_REFERENCE.md`, `docs/extracted-engine/scene-room-model.md`, `CLAUDE.md` (extracted constants section + Phase 06.5 deploy reality), `extracted/client-5-8/rooms/0058-BNCentral/{meta,instances,creation-code}.{json,gml}` (instance shape + 43k-line instance volume + creationCode example sample)

---
*Feature research for: v1.1 Map Groundwork milestone — LDtk authoring + fast-deploy + in-game minimap*
*Researched: 2026-05-18*
