# Pitfalls Research — v1.1 Map Groundwork

**Domain:** 2D Web MMO map authoring (LDtk → Phaser 3.90) + symlink-swap fast-deploy + in-game minimap, layered onto a live Phase 06.7 v1.0 stack
**Researched:** 2026-05-18
**Confidence:** HIGH for items grounded in existing code (legacy-origin helper, 06.5 symlink, extracted constants), MEDIUM for LDtk-specific items (verified against official LDtk docs + GitHub issues + community loaders), LOW for items projecting forward to multi-worlds (still experimental in LDtk).

> **Scope discipline.** This document only catalogs pitfalls that arise *because the v1.0 system already exists in a particular shape* (D-63 helper, 44×40 tile pitch, `/data/client-assets/current` symlink, Phaser 3.90 ADR 0001, no CI). Generic Phaser / LDtk / Vite gotchas are excluded — focus is on what bites *us* in *our* repo.

---

## Critical Pitfalls

### Pitfall 1: Custom LDtk→Phaser loader silently ignores the D-63 origin helper

**What goes wrong:**
The Phaser custom loader (LDtk has no maintained official Phaser plugin — `ldtk-ts` archived 2022, only `mobilex1122/phaser-ldtk-importer` exists as a community option) drops LDtk entities into the scene using either `setOrigin(0, 0)` (LDtk's native pivot convention is top-left like GameMaker), `setOrigin(0.5, 0.5)` (LDtk's `__pivot` for centered entities), or the LDtk `__pivot` field as-is. None of these are correct for entities whose visual coordinates were authored to match the legacy GM `(0,0)` top-left convention but which need to render against REBNO's existing Phaser convention. Player-attached effects ported in a future phase via `phaserOriginForLegacyPlayerAttached` will then disagree with floor-tile / entity placement by `(NAVI_WIDTH_PX/2, NAVI_VISIBLE_FEET_Y)` = `(18, 46)` pixels — except the disagreement is invisible until two systems try to align (e.g. a hexporter sprite + the TeleIn effect spawned over it).

**Why it happens:**
LDtk's JSON exposes both `pxX/pxY` (top-left in pixels) and `__pivot: [pivotX, pivotY]` (normalized 0..1 pivot for the entity definition). Loader authors reach for `__pivot` because it's what the LDtk docs and every community loader (Madour LDtkLoader C++, ldtkgo Go, ldtk-love LÖVE, godot-ldtk-importer) showcase. But `__pivot` is editor-cosmetic — it controls where the entity *handle* sits in the LDtk editor view, not what the runtime should do with the sprite. REBNO's runtime convention is fixed (Phaser bottom-center for player sprites, top-left-equivalent for floor tiles), and the `legacy-origin.ts` helper is the only sanctioned bridge between legacy GM coords and Phaser coords. A naïve loader bypasses it entirely.

**How to avoid:**
- Loader places entities at LDtk `pxX, pxY` (top-left, matches legacy GM) and then routes through `apps/client/src/render/legacy-origin.ts` for any entity that participates in player-attached or player-collocated rendering. Tile-grid floor tiles use `pxX, pxY` directly with `setOrigin(0, 0)` (matches `0023-Tile1` meta.json `originX/Y = 0` and the 44×40 pitch).
- Encode the convention pin in a loader-side type: `interface RebnoEntityPlacement { pxX: number; pxY: number; /* NO pivot field — runtime decides via helper */ }`.
- Add a loader unit test that constructs a synthetic LDtk JSON with `__pivot = [0.5, 0.5]`, runs the loader, and asserts the resulting Phaser sprite `originX/originY` came from the runtime helper, not the JSON.
- Extend the HARD-gate rule from D-63 (no inline origin math in `render/`) to forbid `__pivot` reads outside the loader's "discard editor metadata" zone.

**Warning signs:**
- Code in the loader path that reads `entity.__pivot` and feeds it directly into `setOrigin`.
- First port of any player-attached effect onto an LDtk-authored entity (e.g. hexporter receive station + TeleIn) shows pixel-level misalignment matching `(±18, ±46)` or `(±18, ±48)`.
- `legacy-origin.test.ts` drift-detection asserts pass, but a new test of `loader-origin.test.ts` is missing — gap.

**Phase to address:**
First phase that ships the custom Phaser LDtk loader (likely Phase 7.2 or 7.3, after extraction → conversion). Loader plan must explicitly call out D-63 + cite this pitfall.

---

### Pitfall 2: LDtk JSON deploy lands without atlas regeneration → tileset uid orphans

**What goes wrong:**
Phase 06.5 atlas pipeline produces `atlas-mvp.json` with content-hashed frame names. The LDtk project references the same source sprites by LDtk `tilesetUid` and per-tile grid coordinate `(srcX, srcY)`. After an asset-pipeline run (AST-02 / AST-03 in backlog or a sprite re-extract) the atlas can shift tile positions or change frame counts. An LDtk-only edit + fast-deploy pushes new `.ldtk` JSON via the symlink-swap path; the atlas is unchanged because no source sprite changed. But if the converse happens — atlas regenerated without re-running the LDtk conversion + re-baking auto-tile rules against the new tileset — every tile coordinate in the LDtk project silently points at the *previous* atlas coordinates. Result: floor tiles render as the wrong sprite (or transparent), entity sprites render as random art.

**Why it happens:**
Two source-of-truth chains exist:
1. Source sprite (`extracted/client-5-8/sprites/<id>/`) → asset pipeline → atlas (`atlas-mvp.json` + atlas PNG) → Phaser tileset.
2. Source sprite → LDtk tileset definition (uid + grid + tile coords baked at *LDtk import time*) → `.ldtk` JSON with tile refs.

Chain 2 freezes a snapshot of the atlas layout at the moment the tileset was imported into LDtk. Atlas regen doesn't reach into the `.ldtk` file. There's no version pin between them.

**How to avoid:**
- Generate a single canonical "tileset manifest" (`atlas-mvp.json` or its successor) and a derived `tileset.ldtk.json` snippet at the same pipeline step. Loading an `.ldtk` project that references a tileset hash not in the current atlas is a **hard runtime error** (not a silent skip).
- Add `tilesetSourceHash` as a custom field on each LDtk tileset definition; assert at loader-init time against the active atlas hash.
- Treat any atlas-pipeline change as requiring a paired LDtk-tileset-reimport + auto-tile-rule re-bake commit. Author a one-line `pnpm asset:atlas-regen-then-relink` script that does both or fails loudly.
- The fast-deploy script (see Pitfall 4) must publish both `.ldtk` JSON and atlas as one atomic release directory — never one without the other.

**Warning signs:**
- Two consecutive commits where one touches `tools/asset-pipeline/` and the other touches `.ldtk` files but neither touches the other.
- A staging deploy where floor tiles render correctly but entity sprites are scrambled, or vice versa.
- Atlas frame count printed at loader init disagrees with the highest tile-coord referenced in the `.ldtk` JSON.

**Phase to address:**
Phase that introduces the LDtk loader **and** the phase that introduces the fast-deploy script (likely the same phase). Both must converge on the atlas-hash-pin convention.

---

### Pitfall 3: BNCentral authoring lag triggers premature "LDtk doesn't work for us"

**What goes wrong:**
First serious authoring session on BNCentral (8000×6400 px, ~6000 entity instances) hits the documented LDtk editor lag for IntGrid editing above ~5000 px ([deepnight/ldtk#1029](https://github.com/deepnight/ldtk/issues/1029), [#1073](https://github.com/deepnight/ldtk/issues/1073)). Operator concludes "LDtk doesn't scale" and the team either (a) abandons LDtk and burns 3-6 weeks on a custom editor (the deferred fallback in the map-editor-decision note), or (b) ships BNCentral as a single huge level anyway and authoring grinds to 10-second-per-paint-stroke pain that bleeds into every subsequent zone.

**Why it happens:**
The map-editor-decision note already calls out the mitigation ("split BNCentral into GridVania chunks") but does not pin *when* to apply it. The natural authoring order is "smoke-test with a small layout → import legacy rooms by size order → BNCentral last." The first time BNCentral lag is observed is the moment the milestone has already invested heavily in single-level workflows.

**How to avoid:**
- Smoke-test the workflow with the planned BNCentral split *before* converting a single legacy room — synthesize a fake 8000×6400 zone with two-cell IntGrid noise, confirm the GridVania chunking + multi-world (or multi-level) authoring approach is tolerable, *then* start conversion.
- Pre-define the BNCentral chunk grid (e.g. 4×4 or 2×2 chunks of ≤2000×1600 px each) and write the legacy→LDtk converter to emit chunks from the start. Do not write a "single-level converter, chunk-split later" path.
- Pin the LDtk version in the repo (e.g. `tools/ldtk-version.txt`) and re-evaluate lag on every LDtk upgrade. Performance regressions are real (see `deepnight/ldtk` devlog — the auto-layer rule reworks were specifically perf fixes).

**Warning signs:**
- A paint stroke in LDtk takes >2 seconds to register on the operator's machine.
- LDtk RAM usage exceeds 1 GB on a single zone.
- The conversion script outputs one `.ldtk` per legacy room with no chunking parameter.

**Phase to address:**
Phase 7.1 (workflow smoke-test) — the BNCentral chunk decision must be locked **before** conversion of any zone larger than ~1500×1500 px.

---

### Pitfall 4: Fast-deploy of `.ldtk` JSON repeats the Phase 06.5 symlink-swap gap on a different axis

**What goes wrong:**
The "edit → push → see live" workflow is the headline v1.1 value. Built naively, it suffers exactly the failure mode of the resolved `fly-fullpath-deploy-data-symlink-gap` memory but on the LDtk asset axis: a deploy lands a new `.ldtk` JSON on the volume but the running Phaser scene already preloaded the previous JSON into memory and there's no hot-reload notification, OR the deploy updates `/data/maps/current/` symlink but the server's room-registry signing (Phase 04 ADR 0004) refuses to load the new room because the signature doesn't cover the new content hash. Operator sees "deployed" in logs, sees no change in the browser, concludes the pipeline is broken.

**Why it happens:**
Two distinct caching layers:
1. **Server side:** `apps/server/src/static-assets.ts` resolver prefers `/data/.../current` symlink over bundled `/app/public/`. Same resolver semantics now apply to `.ldtk` JSON. If the symlink isn't atomic-swapped (the bug the `client-release.sh` script existed to solve), or if there's no `STATIC_MAPS_DIR` env analog, the server serves stale `.ldtk` or a 404.
2. **Client side:** Phaser preloads `.ldtk` JSON at scene-start. Mid-session deploys won't take effect for connected players without an explicit `scene.restart()` or page reload trigger — and forcing a session-wide reload during chat is hostile UX.

Additionally: room-registry signing (ADR 0004) was scoped to server-room config, not client maps, but the signing pattern *should* extend to maps to prevent tampering. If extended, the deploy pipeline must re-sign on every push.

**How to avoid:**
- Reuse the exact `client-release.sh` atomic-swap pattern for maps: `releases/<sha>/` directory + `current` symlink, `mv -T` swap. Do **not** invent a new mechanism.
- Treat a map deploy as a client-bundle-equivalent release: pushing maps SHOULD bump a `/maps-manifest.json` hash that the client polls (or that the server pushes via Colyseus state) so connected clients can opportunistically reload.
- For HARD mid-session map swap, kick affected players back to the lobby with a "map updated" toast — better than serving stale geometry under their feet.
- Add a smoke-test endpoint (`/health/maps`) that returns the hash of the currently-served `.ldtk` for each zone. Manual operator check substitutes for the CI smoke we don't have.
- **DO NOT** dual-publish maps to both `/data/maps/current/` and the bundled `/app/public/` location — that's the same trap as the dual-shipping pino-vs-otel log mistake captured in another memory.

**Warning signs:**
- Deploy script copies maps to `/data/maps/current/` directly (not via `releases/<sha>/` + symlink).
- No `manifest.json` or `index.json` listing the currently-deployed map hashes.
- First test of "edit → push → see live" requires operator to manually `flyctl ssh console` to verify the file landed.

**Phase to address:**
Phase that builds the fast-deploy workflow (Phase 7.3 or wherever publish lands). The plan must reference `scripts/client-release.sh` explicitly and adopt the same shape.

---

### Pitfall 5: Legacy free-position instances grid-snapped into LDtk drift player-walkable lines

**What goes wrong:**
Legacy BNO instances are placed at free `(x, y)` pixel coords with the 44×40 tile pitch as a *visual* convention but not a *structural* constraint. The conversion script grid-snaps them into LDtk's IntGrid (which is necessarily cell-aligned). For most floor tiles this is fine (they were already cell-aligned in practice). For a small but unpredictable population — manually-tweaked tiles around hexport pads, mover acceleration zones, edge geometry between zones, the SPECIAL DYNAMIC corner case (LEGACY_FEATURE_REFERENCE.md line 24) — the snap shifts the walkable line by 1-4 pixels. The legacy collision system is AABB with the actual pixel coords. Players who could walk through a gap in the legacy game cannot in REBNO, or vice versa. Bug shows up as "I can't reach the hexporter" complaints with no obvious cause.

**Why it happens:**
- LDtk IntGrid is fundamentally grid-based — its strength (auto-tile rules) requires it. Free-position floor tiles cannot exist on an IntGrid layer; they'd have to be Entities, which defeats the auto-tile UX.
- The 44×40 pitch (NOT 32×32 — locked in CLAUDE.md) is non-square. Grid-snap rounding rules differ on X vs Y axes; coders intuitively reach for `Math.round(x / 32) * 32` and the bug is invisible until UAT.
- Original BNO author intent is partially encoded in `creationCode` (per-instance GML), which the LDtk schema doesn't natively express — see Pitfall 6.

**How to avoid:**
- **Conversion script discipline:** Snap to 44×40, never to a generic tile size. Anchor the conversion logic to the constants in `docs/extracted-engine/scene-room-model.md` and CLAUDE.md ("Extracted Constants"), with a unit test that fails if either constant drifts.
- **Lossless conversion report:** Every converted room emits a sidecar `conversion-report.json` listing every instance whose original `(x, y)` was NOT cell-aligned, with the rounding delta. Operator reviews high-delta cases manually before commit.
- **Two-layer convention:** Static floor → IntGrid (grid-aligned, lossy by design). Anything that requires sub-cell precision → Entity layer (pixel-precise pxX/pxY). Decide per-tile-species which layer it lives on — DO NOT mix.
- **UAT script:** Walk the legacy BNCentral path against the converted LDtk version on the same player object, log first divergence in walkable line.

**Warning signs:**
- Conversion script has no rounding-delta logging.
- A converted zone visually matches the legacy room but a player walking edges encounters invisible walls 1-2 px earlier or later than expected.
- The conversion script accepts "tile size" as a runtime arg instead of asserting `TILE_W=44, TILE_H=40` constants.

**Phase to address:**
Phase 7.2 (legacy→LDtk conversion). Conversion-report sidecar is a HARD gate before merging the first converted zone.

---

### Pitfall 6: Per-instance `creationCode` GML lost in conversion

**What goes wrong:**
Legacy BNO encodes per-instance behavior in `creationCode` — a GML snippet attached to each individual instance that runs at room load. Examples: a hexporter at position X has `creationCode` setting its departure directions and target station ID; a mover tile has acceleration vector and peak velocity; a warp has destination zone+coords; an NPC has dialog tree key. The naïve conversion script ports `objectId → LDtk entity definition` 1:1 and drops `creationCode` because LDtk's typed fields are static-per-definition (no executable code). The converted LDtk project visually looks right (correct sprite at correct location) but every behavior-bearing instance is dead — hexporters don't hexport, movers don't move, warps don't warp.

**Why it happens:**
LDtk's design assumes engine-side scripting reads typed fields and dispatches behavior. The original BNO author worked the opposite direction: data + code together at the instance. Mapping requires:
1. Parsing the per-instance GML snippet.
2. Identifying which fields it sets (`mover_speed`, `warp_dest_zone`, `hex_dir_mask`, etc.).
3. Emitting those as typed LDtk custom fields on the entity instance.
4. Re-implementing the GML-side dispatch in TypeScript on the REBNO client/server.

Step 1 is non-trivial for a free-form scripting language. Step 2 has *ambiguous mappings*: one `objectId` in legacy might correspond to N LDtk entity definitions depending on what `creationCode` configured (a "warp" object could be a 1-way warp, a 2-way warp, a dead-destination warp, or a teleport-on-spectrum-key warp — different LDtk entity types, same legacy `objectId`).

**How to avoid:**
- Conversion script extracts `creationCode` verbatim into a `__legacyCreationCode` LDtk custom field on each entity (string field). Lossy migration is fine; lossless preservation is the point of this string field.
- Build a per-objectId mapping table (`tools/room-converter/mapping.ts`) that explicitly enumerates which `creationCode` shapes map to which LDtk entity definition. Unknown shapes are emitted as a generic `LegacyInstance` entity + the verbatim creationCode field, flagged in conversion-report for manual triage.
- Track conversion coverage as a percent of original instances cleanly mapped (vs fallback `LegacyInstance`). The percent should monotonically grow as the mapping table is fleshed out.
- Build the mapping table from the GML scripts referenced in `docs/extracted-engine/` and `docs/extracted-server/` — DO NOT guess from instance data alone.

**Warning signs:**
- Conversion script silently drops `creationCode` field.
- LDtk project has only one entity definition per legacy `objectId` (no behavioral variants).
- First playtest of a converted zone shows correct-looking but inert entities.

**Phase to address:**
Phase 7.2 (legacy→LDtk conversion). Mapping table grows iteratively across all milestones that port new zones.

---

### Pitfall 7: Minimap reads world state O(N) per frame and tanks tick budget

**What goes wrong:**
Minimap implementation iterates the room's player/entity state every frame to populate dots. With ~6000 entity instances per zone (BNCentral) and 30 Hz tick rate, that's 180K entity-touches/sec just for the minimap. Even at "only render visible-on-minimap entities" filtering, the iteration cost itself doesn't go away. Frame budget collapses, the per-tick freeze pattern from the memory `per-tick-log-freeze-pattern.md` recurs.

**Why it happens:**
Minimap is conceptually a "view of world state" → implementers reach for the obvious "read world state each frame and re-render." Phaser has no native minimap primitive; both `Camera.ignore` + small viewport, and `RenderTexture` + manual draw, are valid approaches but the world-state-iteration cost is independent of which one you pick.

**How to avoid:**
- **Static layer:** Pre-render the zone's static geometry (floor tiles, immutable entities) into a `RenderTexture` once per zone-load. Display it scaled-down — zero per-frame cost.
- **Dynamic layer:** Subscribe to Colyseus room state add/remove/move events (already wired for player sprite updates in Phase 06.7). Maintain a `Map<sessionId, Phaser.GameObjects.Rectangle>` of minimap dots updated only on state-change events. Per-frame work = zero.
- **Self-player marker:** Single sprite, updated via the same `update` hook that updates the main player sprite — *not* via a separate iteration of state.
- Frame-time budget: minimap update path should consume <0.5 ms steady-state. Add it as a profiling assert in dev-mode.

**Warning signs:**
- Minimap code calls `room.state.entities.forEach(...)` inside `scene.update()`.
- Frame time spikes from 33 ms baseline to 50+ ms after minimap is enabled.
- A "minimap_render_ms" telemetry counter is logged per-tick (echoes the per-tick log freeze trap).

**Phase to address:**
Phase that builds the minimap (Phase 7.5 likely). HARD gate: dev-mode performance assert before merge.

---

### Pitfall 8: Minimap-camera conflicts with main camera ADR 0008 (Scale.NONE + manual setZoom)

**What goes wrong:**
Phase 06.6 locked ADR 0008: the main camera uses `Scale.NONE` + manual integer `setZoom()` for HiDPI integer-scale rendering. Minimap implementations using `this.cameras.add()` to create a second camera with its own viewport rect either (a) inherit the main camera's zoom (minimap renders at game scale, blowing out viewport), (b) get a separate zoom (works visually but interferes with the resize handler that recomputes integer zoom on window resize), or (c) attach to the main camera's bounds and cause a resize feedback loop. Worst case: the minimap camera's existence changes the framebuffer dimensions and breaks the integer-scale invariant.

**Why it happens:**
ADR 0008's Scale.NONE means the Scale Manager is not orchestrating viewport math — the game code is. Adding a second camera adds a second viewport-math participant the manual code didn't account for. Phaser's minimap-via-second-camera examples all assume `Scale.FIT` or `Scale.RESIZE`.

**How to avoid:**
- **Prefer RenderTexture-based minimap** over second-camera minimap. RenderTexture has an *internal* camera that doesn't interact with the scene's camera list — it cannot interfere with ADR 0008.
- If second-camera approach is unavoidable: pin the minimap camera's `zoom` and `setSize` independent of the main camera, and add a resize-handler unit test that verifies the integer-scale invariant survives minimap-toggle.
- Update ADR 0008 (or a new ADR) with the minimap decision before implementation, citing this pitfall.

**Warning signs:**
- `cameras.add(...)` appears in minimap code.
- Window-resize during minimap-visible state causes the main game to jitter, stretch, or change zoom level.
- The minimap renders at full game resolution (unscaled) on the first frame after enable.

**Phase to address:**
Phase that builds the minimap (Phase 7.5). ADR amendment is a planning-phase deliverable, not an execution-phase fixup.

---

### Pitfall 9: Multi-world JSON format change breaks loader silently

**What goes wrong:**
The map-editor-decision note flags LDtk multi-worlds (1.3+) as a future path for the combined-zones overworld. Multi-worlds is **experimental** in LDtk and gated behind a Project Settings → Advanced Options toggle. When enabled, the JSON structure changes: `levels[]` moves from the root into `worlds[].levels[]`, and `worldGridWidth/Height/Layout` move from the root into per-world fields. Per [LDtk's official docs](https://github.com/deepnight/ldtk/wiki/%5B0.10.0%5D-Multi-worlds) and the [1.3.0 devlog](https://deepnight.itch.io/ldtk/devlog/522221/130-multi-worlds-preview): "multi-worlds projects cannot be imported using older loaders." A custom loader written against the single-world schema will either throw on `levels` missing, or silently load zero levels.

**Why it happens:**
The deprecation cycle pattern is "old format preserved for 2 major versions" but multi-worlds doesn't follow the same shape — it's a *toggle*, not a deprecation. A team member enables multi-worlds in the editor to experiment, saves, and the loader is now consuming a different schema without warning.

**How to avoid:**
- Loader asserts the JSON shape at load-time: if `worlds` exists, the loader either handles it or throws a structured error pointing at this pitfall. No silent "loaded 0 levels".
- Use the QuickType-generated `LdtkJson.ts` types (always current per LDtk's own [QuickType integration](https://ldtk.io/docs/game-dev/json-overview/quicktype/)) and regenerate on every LDtk version bump. The type system catches the schema change at compile time.
- Pin the LDtk app version in `tools/ldtk-version.txt`. CI (when restored) or operator-local check asserts the version matches before any conversion run.
- If/when multi-worlds is adopted: that's an ADR. Don't toggle it ad hoc.

**Warning signs:**
- `LdtkJson.ts` was hand-written instead of QuickType-generated.
- Loader uses optional chaining like `data?.levels?.forEach(...)` (silently no-ops on multi-worlds).
- An operator's LDtk version differs from the version pinned in the repo.

**Phase to address:**
Phase that builds the custom loader (Phase 7.2 or 7.3). Hard schema assertion is part of the loader's MVP.

---

### Pitfall 10: No CI → schema/atlas drift undetected until human notices

**What goes wrong:**
v1.0 deploy CI was lost when GitHub Actions storage exhausted (per `docs/deploy/LOCAL-DEPLOY.md`). v1.1 introduces three new drift surfaces — (a) LDtk JSON schema version vs loader expectations, (b) atlas/tileset hash sync, (c) conversion-script output vs current legacy data after re-extract. Without CI, none of these are checked automatically. The substitute is "operator notices something looks wrong post-deploy." That worked for v1.0 because the surface was small (one MVP room, two players). v1.1's surface is 16 zones × 6000 entities each × per-instance fields × auto-tile rule sets — too large for human-eye detection.

**Why it happens:**
The convenience of operator-local deploy removes the gate. There's no `pnpm trace:check` blocking merge, no smoke-test, no schema-version assertion. The team developed v1.0 in a CI-equipped environment and built habits around "if it merged, it ran something" — those habits no longer hold.

**How to avoid:**
- Manual pre-deploy checklist that includes: LDtk version matches `tools/ldtk-version.txt`; `pnpm convert:check` reports zero conversion regressions vs the last-committed conversion-report.json; `pnpm asset:atlas-hash` matches the hash baked into all `.ldtk` tileset definitions; `pnpm trace:check` passes locally.
- Single command (`pnpm preflight`) runs all checks and is documented as MANDATORY before `flyctl deploy`.
- Restore CI as a v1.1 in-scope item if budget allows — the long-term cost of relying on operator discipline exceeds the cost of the CI restoration.
- Make checks fail loudly: console output should be color-coded and the operator should review every diff, not skim.

**Warning signs:**
- A deploy lands and is then rolled back within hours because of map/atlas drift.
- The pre-deploy checklist is not run, or is run mechanically without reviewing output.
- New milestone-spec docs reference CI gates that don't exist.

**Phase to address:**
Phase 0 of v1.1 (planning) — preflight script and checklist must exist before the first content-changing deploy. CI restoration is its own backlog item.

---

## Technical Debt Patterns

Shortcuts that seem reasonable but create long-term problems.

| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Use `__pivot` from LDtk JSON directly in `setOrigin()` | Loader code is 3 lines shorter; matches LDtk's own examples | Re-pin every entity placement when the first player-attached effect lands on top of an LDtk entity; debugging is hard because the misalignment is sub-tile | **Never.** Always route through `phaserOriginForLegacyPlayerAttached` (or a sibling helper for non-player-attached cases). |
| Single-level LDtk project for BNCentral with "split it later if lag is bad" | Conversion pipeline is simpler; one file per zone | Authoring grinds when lag hits; chunk-split refactor invalidates auto-tile rule sets per chunk boundary | Only if the editor's perf issues are demonstrably fixed in the pinned LDtk version (verify against [#1029](https://github.com/deepnight/ldtk/issues/1029), [#1073](https://github.com/deepnight/ldtk/issues/1073)). |
| Drop `creationCode` during conversion, "we'll restore behavior in TS" | Conversion script is 50% smaller | Lossy migration — no provenance to recover the original behavior shape per instance; have to re-read legacy GML for every behavioral bug | **Never.** Always preserve verbatim in `__legacyCreationCode` field. The disk cost is negligible. |
| Inline minimap state-iteration in `scene.update()` "just for the first version" | Working minimap in one afternoon | Per-tick perf collapses; same trap as the per-tick log freeze pattern; refactor requires unraveling subscription wiring | Only behind a dev-mode-only feature flag, never shipped. |
| Single deploy target `/data/maps/current/` without releases-dir + symlink | Deploy script is one `rsync` | Atomic-swap semantics lost; rollback story is "manual SSH and rsync the previous tarball"; identical to the bug that bit Phase 06.5 | **Never.** Reuse `client-release.sh` shape. |
| Skip LDtk-version pin file | One fewer file to maintain | Multi-worlds toggle or schema change lands silently when an operator upgrades; loader breaks weeks later | **Never.** Pin file is one line. |
| Hand-write `LdtkJson.ts` types | Full control over type shapes | Drifts behind LDtk's schema on every upgrade; manual review of full schema diff per release | Only if QuickType is genuinely unavailable for TS (it is available — use it). |
| Deploy maps + atlas as separate releases | Smaller diff per deploy | Window during deploy where map references atlas hashes not yet on disk → render glitches mid-deploy | **Never.** Atomic together. |

---

## Integration Gotchas

Common mistakes when connecting to external services / existing v1.0 subsystems.

| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| **LDtk → Phaser loader** | Reading `__pivot` and feeding to `setOrigin()` | Route through `apps/client/src/render/legacy-origin.ts` for entities; use `setOrigin(0, 0)` for floor tiles per `0023-Tile1` meta |
| **Atlas pipeline ↔ LDtk tileset** | Two independent regen flows, no hash-pin | `tilesetSourceHash` custom field on each LDtk tileset, asserted at loader init |
| **`.ldtk` deploy ↔ `/data` volume** | Direct rsync to `/data/maps/current/`, no atomic swap | `/data/maps/releases/<sha>/` + atomic `mv -T` symlink swap, mirroring `client-release.sh` |
| **`.ldtk` deploy ↔ ADR 0004 room-registry signing** | Maps unsigned, server accepts arbitrary deployed JSON | Extend signing to maps OR enforce volume-write-only-via-deploy-script (operator-local deploy provides this implicitly) |
| **Minimap camera ↔ ADR 0008 Scale.NONE invariant** | `this.cameras.add(...)` second camera | RenderTexture-based minimap, not second camera |
| **Minimap entity dots ↔ Colyseus room state** | Per-frame `state.entities.forEach()` iteration | Subscribe to add/remove/change events from Colyseus; per-frame zero iteration |
| **Conversion script ↔ extracted constants** | Take `tileSize` as a runtime arg | Assert `TILE_W=44, TILE_H=40` from `docs/extracted-engine/scene-room-model.md`; fail on drift |
| **Custom loader ↔ LDtk multi-worlds** | Optional chain on `levels[]` (silent zero-load) | Hard assertion: throw structured error if `worlds[]` present |
| **`pnpm trace:check` ↔ new REQ-MAP-* IDs** | Tag REQ-IDs in docs before adding them to `traceable-reqs.toml` | Add to manifest first, then tag artifacts |
| **D-63 helper ↔ new entity sprites with different bbox** | Reuse `NAVI_VISIBLE_FEET_Y = 46` for non-Navi entities | The 46 px constant is Navi-specific; a sibling helper or per-sprite metadata is needed for entities with different visible-feet lines |

---

## Performance Traps

Patterns that work at small scale but fail as v1.1 surface grows.

| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Iterating room state per minimap frame | Frame time spikes from 33 ms to 50+ ms after minimap enabled | Event-driven minimap (state subscriptions) + RenderTexture static layer | At ~6000 entities (BNCentral) or ~50 CCU concurrent players |
| Single LDtk level for BNCentral | LDtk editor paint-stroke lag, RAM >1 GB | GridVania chunking, ≤2000×1600 px per chunk | At authoring time on BNCentral itself; smaller zones are fine |
| Loading all 16 zones at scene start | Initial JSON parse + atlas decode multi-second hitch | Lazy zone-load on first zone-entry; preload only the current zone | At first deploy with >2 converted zones |
| Animated tile per-tile state machine without Phaser plugin | 30 Hz × ~6000 entities × per-tile state update = 180K updates/sec | Adopt `phaser-animated-tiles` plugin or batch-update via Phaser's `TileAnimationData` — never per-tile imperative | Any zone with >100 animated tile instances |
| RenderTexture minimap with full-resolution source | GPU upload cost on first draw is multi-frame hitch | Pre-render at minimap resolution (e.g. 1 pixel per tile, not 44×40) | At zones ≥3000 px in either dimension |
| Atlas size exceeds 4096×4096 (Phaser WebGL ceiling on some devices) | Sprites fail to render or appear as solid colors | Multi-page atlas, max 4096×4096 per page | When total tile species + frames × 44×40 exceeds 4096² |
| Phaser tilemap LayerData with all tiles "dirty" (re-uploaded each frame) | Frame time degrades linearly with map size | Use static TilemapLayer; flag dirty only when a tile actually changes | Always — Phaser does the right thing by default but a `layer.setDirty()` in a hot path breaks it |

---

## Security Mistakes

Domain-specific security issues beyond general web security.

| Mistake | Risk | Prevention |
|---------|------|------------|
| Operator-local deploy with no signing of `.ldtk` content | Compromised operator account silently pushes malicious map (e.g. unwalkable kill-zone, denial-of-service map) | Even without ADR 0004 extension to maps, log deploy-time hash of every map file to OpenObserve; periodic external audit |
| LDtk `__legacyCreationCode` field treated as executable by REBNO runtime | Same RCE-as-a-feature class CLAUDE.md hard rule #3 explicitly rejects | Field is a *string archive only* — never `eval()`, never feed to a script interpreter; verified by lint rule |
| Map deploy bypasses Colyseus auth (deploy webhook unauthenticated) | Anyone who finds the deploy endpoint can publish maps | Deploy is operator-only via `flyctl` CLI (no HTTP webhook). Document this and reject any "convenience" webhook proposal. |
| Client trusts `.ldtk` JSON for entity properties used in server validation | Modified `.ldtk` on a player's machine extends mover acceleration to "fly through walls" speed | Server holds the authoritative copy; client `.ldtk` is rendering-only; behavioral fields are server-loaded |
| Minimap reveals `HIDDEN` tile positions to client | Players see hidden tiles via minimap that they shouldn't see until collision-reveal | Minimap renders only currently-visible-to-player tiles; per-player visited-area state is server-side |

---

## UX Pitfalls

Common user experience mistakes specific to this milestone.

| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Mid-session map hot-reload changes geometry under a walking player | Player snaps to invalid position, falls into void (no fall-reset until Phase 06.8!) | Kick player to lobby with "map updated" toast on map-version change; resume on lobby re-entry |
| Minimap always-on with no toggle | Screen real-estate eaten on small viewports (HiDPI integer scale already squeezes); player can't focus | Hotkey toggle (e.g. `M`), state persisted to localStorage |
| Minimap reveals entire zone immediately | Removes exploration value of HIDDEN tiles, hexport discovery, warp surprises | Fog-of-war pattern: reveal cells the player has visually seen (or stood on, depending on intent). Persist per-player. |
| Minimap player-marker uses wrong origin convention | Marker offset by 18-46 px from player's actual position; visible jitter when player moves | Use the same Phaser-side coords the main player sprite uses; route through D-63 helper if needed |
| Edit → push delay measured in minutes (not seconds) | Authoring iteration is painful; team avoids small fixes | Target <30 second edit-to-live cycle; measure and assert in milestone success criteria |
| Conversion failures silent (zone missing entities, no toast) | Operator deploys and discovers gap mid-playtest | Conversion-report.json is reviewed pre-deploy AND surfaced in `/health/maps` endpoint |
| Hexport target zone not yet in LDtk-converted state when player hexports from converted zone | Player teleports into a void or a Phase-06.5-era placeholder MVP room | Pre-flight check: any zone with outbound hexports asserts all destination zones are converted before merge |

---

## "Looks Done But Isn't" Checklist

Things that appear complete but are missing critical pieces.

- [ ] **LDtk custom loader:** Often missing the `phaserOriginForLegacyPlayerAttached` route for entities — verify by inspecting the loader's `setOrigin` call sites and confirming none read `__pivot`.
- [ ] **LDtk custom loader:** Often missing multi-worlds schema assertion — verify by feeding a multi-worlds-toggled JSON to the loader and asserting it throws (not silently loads zero levels).
- [ ] **LDtk custom loader:** Often missing `tilesetSourceHash` assertion — verify by hand-editing an atlas frame's coords and confirming loader refuses to load.
- [ ] **Conversion script:** Often missing the `__legacyCreationCode` verbatim field — grep output `.ldtk` files for legacy GML fragments; absence = data loss.
- [ ] **Conversion script:** Often missing rounding-delta report — verify `conversion-report.json` exists per converted zone and lists every non-cell-aligned source instance.
- [ ] **Fast-deploy script:** Often missing atomic-swap (uses direct rsync) — verify pattern matches `scripts/client-release.sh` exactly.
- [ ] **Fast-deploy script:** Often missing `/maps-manifest.json` for client polling — verify endpoint exists and updates on each deploy.
- [ ] **Fast-deploy script:** Often missing rollback procedure — verify `releases/` retains last N deploys + documented `flyctl ssh` rollback command.
- [ ] **Minimap:** Often missing event-driven dynamic layer — verify `scene.update()` does NOT iterate `room.state.entities`.
- [ ] **Minimap:** Often missing RenderTexture static layer — verify zone-load creates a single static texture, not per-frame draw.
- [ ] **Minimap:** Often missing toggle hotkey + localStorage persistence — verify `M` (or whatever) toggles and survives reload.
- [ ] **Minimap:** Often missing ADR for the camera-vs-RenderTexture decision — verify ADR exists and references this PITFALLS doc.
- [ ] **Pre-flight script:** Often missing — verify `pnpm preflight` exists and runs LDtk-version, atlas-hash, conversion-regression, and trace:check together.
- [ ] **Pre-flight script:** Often missing failure exit code — verify it returns non-zero on any check failure (operator may have wired it as advisory-only).
- [ ] **`/health/maps` endpoint:** Often missing — verify endpoint returns current `.ldtk` hash per zone.
- [ ] **REQ-MAP-* IDs:** Often added to docs but missing from `traceable-reqs.toml` — verify `pnpm trace:check` reports zero `undeclared_id` findings.
- [ ] **D-63 audit script:** Already a backlog item (`tooling/no-inline-origin.ts`) — verify it's pulled into v1.1 scope OR explicitly deferred again.
- [ ] **LDtk version pin:** Often missing — verify `tools/ldtk-version.txt` exists and pre-flight asserts operator's installed version matches.

---

## Recovery Strategies

When pitfalls occur despite prevention, how to recover.

| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| **#1 Loader ignores D-63** | MEDIUM | Add helper-routing to loader; emit a one-shot diff report of all entity placements before/after; UAT all converted zones for visual regression |
| **#2 Atlas/tileset hash drift** | LOW-MEDIUM | Regenerate atlas; re-import tilesets into LDtk; re-bake auto-tile rules; bump `tilesetSourceHash` field; re-deploy. If LDtk auto-tile rules don't survive a tileset re-import cleanly, manual rule re-author per affected layer |
| **#3 BNCentral lag discovered late** | HIGH | Write chunk-split tool that decomposes a single .ldtk into N chunks; re-derive auto-tile rules per chunk; re-test paint perf; budget 1-2 weeks |
| **#4 Symlink-swap gap on map deploy** | LOW | Reuse `client-release.sh` shape verbatim. The Phase 06.5 fix template is on disk in `scripts/`. Manual remediation in the interim: `flyctl ssh console` + the `mv -T` pattern from the gap memory |
| **#5 Grid-snap drift breaks walkability** | MEDIUM | Identify affected instances via conversion-report deltas; bump them to Entity layer (pixel-precise) instead of IntGrid; re-deploy. May require a sub-pitch IntGrid variant if many instances are affected |
| **#6 `creationCode` lost** | HIGH (if deployed without verbatim field), LOW (if verbatim preserved) | If preserved: parse retroactively, populate typed fields, re-deploy. If lost: re-extract from legacy data, re-run conversion, re-deploy — full conversion re-roll |
| **#7 Minimap O(N) tank** | MEDIUM | Refactor to event-driven; introduce RenderTexture static layer; re-test frame budget. Roll back minimap deploy in the interim if perf is unshippable |
| **#8 Minimap-camera conflicts with ADR 0008** | MEDIUM | Refactor to RenderTexture-based; verify with HiDPI integer-scale resize harness; amend ADR 0008 or add new ADR |
| **#9 Multi-worlds schema break** | LOW (if loader asserts) — operator just disables multi-worlds in LDtk and resaves. HIGH (if loader silently no-ops) — undetected data loss for unknown duration | Loader assertion catches it immediately. Without assertion: audit all `.ldtk` files for `worlds[]` presence post-discovery |
| **#10 No CI → silent drift** | varies by drift type | Adopt pre-flight script (Pitfall #10 prevention) post-hoc; backfill checks; restore CI as separate backlog item |

---

## Pitfall-to-Phase Mapping

How v1.1 phases should address these pitfalls. Phase numbering per `.planning/PROJECT.md` starts at Phase 7 (v1.0 ended at 6.8). The phase IDs below are *suggested groupings*, to be locked at roadmap-creation time.

| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| #1 Loader ignores D-63 helper | Phase 7.3 (LDtk loader) | Loader unit test: synthetic `.ldtk` with `__pivot=[0.5,0.5]` → sprite origin from helper, not JSON |
| #2 Atlas/tileset hash drift | Phase 7.2 (conversion) + Phase 7.3 (loader) | Loader-init asserts `tilesetSourceHash` matches active atlas; CI/preflight check on every change |
| #3 BNCentral lag | Phase 7.1 (workflow smoke-test) | BNCentral chunk decision LOCKED before Phase 7.2 starts; smoke-test with synthetic 8000×6400 zone |
| #4 Fast-deploy symlink-swap gap | Phase 7.4 (fast-deploy) | Deploy script invokes same `releases/<sha>/` + `mv -T` pattern as `client-release.sh`; rollback drilled |
| #5 Grid-snap drift | Phase 7.2 (conversion) | `conversion-report.json` per zone reviewed pre-merge; walkability UAT vs legacy on at least one converted zone |
| #6 `creationCode` lost | Phase 7.2 (conversion) | Output `.ldtk` files grep-asserted for `__legacyCreationCode` presence on all behaviorally-significant entities |
| #7 Minimap O(N) tank | Phase 7.5 (minimap) | Dev-mode perf assert: minimap update path <0.5 ms steady-state |
| #8 Minimap-camera vs ADR 0008 | Phase 7.5 (minimap) | ADR amendment authored at planning; HiDPI resize integration test passes with minimap toggled |
| #9 Multi-worlds schema break | Phase 7.3 (LDtk loader) | Loader throws on `worlds[]` presence; pinned LDtk version in `tools/ldtk-version.txt`; QuickType regen verified |
| #10 No-CI drift | Phase 7.0 (planning) / 7.1 (smoke-test) | `pnpm preflight` exists, runs in <30 s, fails loudly; operator checklist in `docs/deploy/LOCAL-DEPLOY.md` extended with map-deploy section |

---

## Sources

- LDtk official docs:
  - [Loading LDtk in your game](https://ldtk.io/docs/game-dev/loading/) — __pivot field semantics, helper fields prefixed `__`
  - [JSON overview](https://ldtk.io/docs/game-dev/json-overview/) — schema generation
  - [Deprecation cycles](https://ldtk.io/docs/game-dev/json-overview/deprecation-cycles/) — 2-major-version preservation pattern
  - [JSON format next version](https://ldtk.io/json/next/) — preview of upcoming schema changes
  - [Multi-worlds JSON wiki](https://github.com/deepnight/ldtk/wiki/%5B0.10.0%5D-Multi-worlds) — `worlds[]` root key migration
  - [1.3.0 Multi-worlds preview devlog](https://deepnight.itch.io/ldtk/devlog/522221/130-multi-worlds-preview) — experimental status + "older loaders can't import"
- LDtk known performance issues:
  - [deepnight/ldtk#1029](https://github.com/deepnight/ldtk/issues/1029) — IntGrid editing lag at >5000 px
  - [deepnight/ldtk#1073](https://github.com/deepnight/ldtk/issues/1073) — related large-level lag
- LDtk community loaders surveyed (none official for Phaser):
  - [mobilex1122/phaser-ldtk-importer](https://github.com/mobilex1122/phaser-ldtk-importer) — closest Phaser-specific community option
  - [Madour/LDtkLoader](https://github.com/Madour/LDtkLoader) — C++ reference for forward-reference resolution patterns
  - [Excalibur LDtk plugin](https://excaliburjs.com/docs/ldtk-plugin/) — file-watch hot-reload pattern reference
  - [godot-ldtk-importer](https://github.com/heygleeson/godot-ldtk-importer) — EntityRef two-phase resolution explanation
- Phaser tilemap performance:
  - [Phaser Tilemap docs — culling](https://docs.phaser.io/api-documentation/class/tilemaps-tilemaplayer) — built-in culling, `cullPaddingX/Y`
  - [Managing Big Maps in Phaser 3](https://phaser.io/news/2018/10/managing-big-maps-in-phaser-3) — compartmentalization guidance
- Phaser minimap patterns:
  - [Phaser camera ignore docs](https://newdocs.phaser.io/docs/3.54.0/focus/Phaser.Cameras.Scene2D.Camera-ignore) — second-camera ignore-list approach
  - [Phaser RenderTexture docs](https://docs.phaser.io/api-documentation/class/gameobjects-rendertexture) — internal-camera approach
  - [Minimap texture forum thread](https://phaser.discourse.group/t/minimap-texture/6273) — RenderTexture-based pattern
- Phaser animated tiles:
  - [phaser-animated-tiles plugin](https://github.com/nkholski/phaser-animated-tiles) — community standard
  - [phaser-tilemap-plus](https://github.com/colinvella/phaser-tilemap-plus) — alternative with broader scope
- Vite caching:
  - [Vite troubleshooting](https://vite.dev/guide/troubleshooting) — HMR + cache invalidation patterns
  - [Vite #15372 cascading cache invalidation](https://github.com/vitejs/vite/discussions/15372) — known cascade trap
- REBNO repo references (HIGH confidence — direct file reads):
  - `CLAUDE.md` — D-63 coordinate convention pin, extracted constants, Hard Rules
  - `.planning/PROJECT.md` — v1.1 milestone scope, v1.0 state, ADRs
  - `.planning/notes/map-editor-decision.md` — LDtk decision rationale, accepted tradeoffs
  - `apps/client/src/render/legacy-origin.ts` — D-63 helper canonical entry
  - `docs/extracted-engine/scene-room-model.md` — 44×40 px source-of-truth derivation
  - `docs/LEGACY_FEATURE_REFERENCE.md` — floor tile species + interactive tile catalog
  - `~/.claude/projects/.../memory/fly-fullpath-deploy-data-symlink-gap.md` — Phase 06.5 symlink-swap gap precedent (RESOLVED) and `scripts/client-release.sh` template
  - `~/.claude/projects/.../memory/per-tick-log-freeze-pattern.md` — per-tick iteration trap precedent

---

*Pitfalls research for: v1.1 Map Groundwork (LDtk + fast-deploy + minimap onto v1.0 stack)*
*Researched: 2026-05-18*

[doc->REQ-DEP-04]
