# Architecture Research — v1.1 Map Groundwork Integration

**Domain:** LDtk authoring + map-deploy + in-game minimap integration into existing REBNO monorepo (v1.0 validated stack)
**Researched:** 2026-05-18
**Confidence:** HIGH for integration-point identification (existing code inspected); MEDIUM for the converter-extend-vs-fork choice (depends on how aggressively v1.1 wants to break the existing `CanonicalLayout` shape).

## Executive Recommendation

**Integration strategy: additive, not invasive.** The v1.0 stack already has every load-bearing primitive v1.1 needs — `tools/room-converter` (CLI + Ed25519 signing), `apps/server/src/RoomRegistry` (fs.watch + sig-verify + zod parse + broadcast), `apps/client/src/render/RoomRenderer` (two-shape layout discriminator), `scripts/client-release.sh` (atomic symlink swap), and `STATIC_ASSETS_DIR` env-driven resolver. v1.1's job is to **add a third layout shape** (LDtk-derived), **add an LDtk-source dir** to the runtime symlink-swap pattern, and **introduce two new client-only components** (LDtk JSON runtime loader + minimap overlay).

The seven decisions below answer each question in `<question>` with a single concrete choice plus its rationale.

| # | Decision | Reasoning |
|---|----------|-----------|
| 1 | `.ldtk` source: new top-level `maps/` dir | Mirrors `decomp/` + `extracted/` — content, not code |
| 2 | Extend `tools/room-converter`, add `ldtk-import` subcommand | Sign + atomic-write infra already there; reuse |
| 3 | New `packages/map-loader` (pure data, no Phaser) | Future server validation needs same parser |
| 4 | v1.1 server: map-agnostic (zone IDs only) | Anti-cheat is Phase 06.8, not v1.1 |
| 5 | New `/data/maps/current` symlink swap, independent of client bundle | Decouple "edit map" from "rebuild client" |
| 6 | Minimap = overlay container in GameScene (new `src/ui/MinimapHUD.ts`) | Lifecycle + camera-relative input parity with ChatHUD |
| 7 | Build order: converter → smoke-room → loader → minimap → BNCentral chunks | Each step gates the next on real data |

## Existing Architecture (v1.0 — validated)

```
┌────────────────────────────────────────────────────────────────────┐
│                          MONOREPO (pnpm)                            │
├────────────────────────────────────────────────────────────────────┤
│  apps/                                                              │
│  ├── client/   Vite + Phaser 3.90                                  │
│  │   ├── public/{forms,assets}                                     │
│  │   ├── public/atlas-mvp.{json,png}        (runtime-loaded)       │
│  │   ├── public/pipeline-manifest.json      (atlas catalogue)      │
│  │   └── src/{scenes,render,ui,net,prediction,assets}              │
│  ├── server/   Node 22 + Colyseus + Drizzle                        │
│  │   ├── rooms/{mvp-lobby,mvp-room}/<rev>.{json,sig}               │
│  │   ├── public/                            (Vite output, baked)   │
│  │   └── src/{RebnoRoom,RoomRegistry,static-assets,...}            │
│  └── obs/      OpenObserve sidecar Fly app                         │
│                                                                     │
│  packages/                                                          │
│  ├── protocol/   wire types + zod + msgpackr + Colyseus Schema      │
│  ├── game-logic/ pure deterministic step + AABB + splitmix64        │
│  └── db/         Drizzle 8-table baseline                           │
│                                                                     │
│  tools/                                                             │
│  ├── room-converter/   extracted-GM5-room → CanonicalLayout JSON   │
│  ├── extract-gmd/      .gmd → diffable trees                       │
│  ├── asset-pipeline/   sprites → atlas-mvp.png + pipeline-manifest │
│  ├── asset-catalog/    sprite inventory                            │
│  ├── protocol-doc/     opcode → typed protocol.ts source           │
│  ├── save-format-doc/  .bnu/.bno field schemas                     │
│  └── db-schema/        Drizzle ⇄ doc                               │
└────────────────────────────────────────────────────────────────────┘

                            ┌───────────────────┐
                            │      Fly.io       │
                            │  single machine   │
                            │   /data volume    │
                            ├───────────────────┤
   image (server-baked) ──▶ │ /app/public  ←┐  │  STATIC_ASSETS_DIR
                            │ /app/rooms    │  │  fallback chain
                            │               │  │
   client-release.sh   ───▶ │ /data/client- │  │
   (atomic mv -T)            │   assets/     │  │
                            │     releases/ │  │
                            │     current ──┘  │
                            └───────────────────┘
```

Load-bearing wire / persistence contract (v1.0):

- **`apps/server/rooms/<room_id>/<rev>.{json,sig}`** is the canonical room source. Ed25519 manifest signature gates load (`apps/server/src/RoomRegistry.ts:228-248`). zod-strict parse gates broadcast. fs.watch + 200ms debounce hot-reloads. Two layout shapes coexist (`legacyLayoutSchema` + `newLayoutSchema`, `packages/protocol/src/intents.ts:202-327`), discriminated by presence of `room_id`.
- **Server `layout-derive.ts`** runs at `s2c.room_layout` broadcast time to derive `collision_polys` + `room_size` from `tiles[]` + `tile_w/h` — keeps the on-disk JSON unmodified so the manifest_sig stays intact (`apps/server/src/layout-derive.ts:73-100`).
- **Client `RoomRenderer.ts`** branches on layout shape, places floor sprites + TSide1 bottom-edge from `tiles[]`, derives client-side `walkableGrid` for the per-axis prediction step (`apps/client/src/render/RoomRenderer.ts:111-285`).
- **`/data/client-assets/current` symlink swap** is the proven primitive for zero-cost content-only releases (`scripts/client-release.sh:86-93` uses `mv -T` for atomicity; `STATIC_ASSETS_DIR` env resolver in `apps/server/src/static-assets.ts` with bundled-fallback safety net).

## Recommended v1.1 Layout

```
rebno/
├── apps/
│   ├── client/
│   │   ├── public/
│   │   │   └── maps/                            ← NEW: runtime LDtk JSON
│   │   │       └── (mirrors /data/maps/current symlink target)
│   │   └── src/
│   │       ├── render/
│   │       │   └── RoomRenderer.ts              MODIFIED — third shape branch
│   │       └── ui/
│   │           └── MinimapHUD.ts                ← NEW
│   └── server/
│       └── src/
│           ├── RoomRegistry.ts                  UNCHANGED (registry already
│           │                                    accepts a third zod shape via
│           │                                    layoutSchema union extension)
│           └── ldtk-import-helpers.ts           OPTIONAL (only if server-side
│                                                spawn-validation lands in v1.1)
│
├── packages/
│   ├── protocol/
│   │   └── src/intents.ts                       MODIFIED — extend layoutSchema
│   │                                            union with ldtkLayoutSchema
│   └── map-loader/                              ← NEW package
│       ├── package.json
│       ├── src/
│       │   ├── index.ts                         barrel
│       │   ├── ldtk-types.ts                    QuickType-generated from
│       │   │                                    LDtk's JSON schema
│       │   ├── ldtk-parse.ts                    LDtk JSON → REBNO room model
│       │   ├── tile-flattening.ts               IntGrid + auto-tile rules
│       │   │                                    → tiles[] array
│       │   └── entity-mapping.ts                LDtk entities → REBNO
│       │                                        platform_defs / scripted_triggers
│       └── test/
│
├── tools/
│   └── room-converter/                          MODIFIED — add ldtk-import
│       ├── cli.ts                               new subcommand
│       └── src/
│           ├── ldtk-import.ts                   ← NEW: .ldtk → rooms/<id>/<rev>.json
│           └── (existing convert.ts, mvp-room.ts, sign.ts, types.ts)
│
├── maps/                                        ← NEW top-level
│   ├── README.md                                authoring conventions
│   ├── overworld.ldtk                           single multi-world project
│   ├── overworld/                               LDtk auto-created sidecar
│   │   ├── 0000-BNCentral_chunk_NW.ldtkl
│   │   ├── ... (one .ldtkl per Level)
│   │   └── ...
│   └── tilesets/                                source PNG references
│       └── (symlinks or copies from extracted/client-5-8/sprites/)
│
└── scripts/
    ├── client-release.sh                        UNCHANGED
    └── maps-release.sh                          ← NEW: independent map deploy
                                                 ($SHA → /data/maps/releases/$SHA
                                                  → atomic mv -T to /data/maps/current)
```

### Why `maps/` is top-level, not under `apps/client/public/` or `packages/maps`

| Candidate | Verdict | Why |
|-----------|---------|-----|
| `apps/client/public/maps/` | ✗ | Vite would bundle into the client image (~MB → image bloat). Defeats the symlink-swap "content vs. code" decoupling that 06.5 established. Also implies maps are client-only data, which is wrong once Phase 06.8 anti-cheat needs them server-side. |
| `packages/maps/` | ✗ | "Package" implies importable TS. `.ldtk` is JSON authoring source — it has the same role as `extracted/` (content), not the same role as `packages/protocol` (code). pnpm workspace discovery would resolve `package.json` files inside the `.ldtk` sidecar tree which authoring tools may write. |
| `maps/` (top-level) | ✓ | Mirrors existing `decomp/`, `extracted/`, `legacy/`. Gitignored selectively (the .ldtk source IS checked in; baked runtime JSON is NOT). Authoring discoverable. Independent of Vite + Docker build context. |

**Gitignore policy** (proposed):

```
# /maps/.gitignore
backup/                # LDtk auto-backup; large + churny
*.ldtk.bak
```

Source `.ldtk` + `.ldtkl` files ARE checked in (text JSON, diffable, the authoring artefact). Baked runtime artefacts (`out/`, `dist/`) are produced by the converter into a separate `maps-baked/` or directly into a temp dir for upload; not committed.

**File-size budget:** BNCentral chunked into ~4-8 GridVania levels stays under ~500 KB per `.ldtkl`. Tilesets reference PNGs already in `extracted/` — do NOT copy. LDtk supports relative tileset paths; configure `tilesets/` as a symlink (POSIX) or a small index file pointing at `../extracted/client-5-8/sprites/`.

## Decision 2 — Extend `tools/room-converter`, don't fork

### What `tools/room-converter` currently is (inspected)

`tools/room-converter/cli.ts` is a 333-line standalone Node CLI with four subcommands:

```
room-converter convert <src-dir> <room-id>    # extracted-GM5-room → CanonicalLayout
room-converter edit <room-id>                 # $EDITOR → next rev
room-converter verify <room-id>               # Ed25519 sig check
room-converter mvp-room                       # synthesise 20×20 MVP room (Plan 06-14)
```

Load-bearing infrastructure already present:

- `signRoomLayout` + `verifyRoomLayout` over Ed25519 (`src/sign.ts`)
- `loadOrGenerateKeys(keyPath)` — auto-generates `keys/rebno-room-signing.ed25519` + `.pub.pem` sibling on first run
- `nextRev(roomDir)` — numeric-sorted next revision id (`000`, `001`, ..., `1000`+) — already past the lex-sort BL-04 bug
- `atomicWritePair(roomDir, rev, json, sig)` — `.tmp` + `renameSync` pair, matching `RoomRegistry`'s atomic-rename debounce contract
- Schema validation at `cli.ts:38-47` (`REQUIRED_LAYOUT_KEYS`) currently checks legacy keys — `tile_grid`, `collision_polys`, `spawn_points`, etc.

### v1.1 extension plan

Add `ldtk-import <ldtk-file> <world-name>` subcommand. New module: `src/ldtk-import.ts`.

```typescript
// tools/room-converter/src/ldtk-import.ts (sketch)
import { parseLdtk } from '@rebno/map-loader/parse';
import { signRoomLayout } from './sign.js';

export async function importLdtkWorld(opts: {
  ldtkPath: string;
  outRoot: string;            // apps/server/rooms (default) OR maps-baked/
  keyPath: string;
  prefix?: string;            // 'overworld-' or 'zone-'
}): Promise<{ written: string[] }> {
  const parsed = parseLdtk(readFileSync(opts.ldtkPath, 'utf-8'));
  // parsed.levels → array of { identifier, width_tiles, height_tiles, tiles[],
  //                            entities[], intGridLayers[] }
  const keys = loadOrGenerateKeys(opts.keyPath);
  const written: string[] = [];
  for (const level of parsed.levels) {
    const room_id = sanitize(opts.prefix + level.identifier);
    const layout = ldtkLevelToCanonicalLayout(level);   // map-loader output
    const roomDir = join(opts.outRoot, room_id);
    const rev = nextRev(roomDir);
    const json = JSON.stringify(layout, null, 2);
    const sig = signRoomLayout(keys.privateKey, room_id, rev, json);
    atomicWritePair(roomDir, rev, json, sig);
    written.push(`${room_id}/${rev}`);
  }
  return { written };
}
```

**Why extend, not fork:**

1. `Ed25519 + atomic-write + nextRev` is non-trivial to re-implement and already battle-tested. The RoomRegistry's signature-verify gate is the security boundary; a fresh converter that re-implements signing risks subtle drift (wrong-payload ordering, wrong key encoding).
2. The shared dependency `keys/rebno-room-signing.ed25519` is read by `pnpm --filter room-converter convert` AND any future `pnpm --filter room-converter ldtk-import` — one keypair, one source-of-truth file.
3. `room-converter verify` is the contract test for the wire format. Adding a new tool would require a parallel verify CLI — wasteful.
4. Naming pivot: keep the directory name `tools/room-converter/`, update the package `description` to "GM5 + LDtk → REBNO canonical layout".

**Modification surface:**

- `cli.ts:38-47` — `REQUIRED_LAYOUT_KEYS` must become a union check (legacy keys OR new-extended keys OR ldtk-derived keys), OR drop in favour of running the actual `layoutSchema.parse()` from `@rebno/protocol`. Currently the tool re-declares types locally (per Phase 1 D-18 standalone-tool rule). v1.1 should consider whether to relax D-18 for room-converter specifically — at minimum the converter's local `CanonicalLayout` type (`src/types.ts`) must learn the LDtk-derived shape so `verify` accepts the new rooms.
- `package.json` `scripts` — add `ldtk-import` script entry mirroring the `convert` shape (passes `--key` + `--out` defaults).

## Decision 3 — New `packages/map-loader` (pure data, future server-reusable)

### Why a package, not inline

Three downstream consumers will eventually want the LDtk parser:

| Consumer | What it needs | When |
|----------|---------------|------|
| `tools/room-converter` ldtk-import | Full parse: levels, tiles, entities, IntGrid → REBNO `tiles[]` array | v1.1 (now) |
| `apps/client` runtime | Same parse (over fetched JSON), then Phaser sprite placement | v1.1 (now) |
| `apps/server` validation | Same parse, for spawn-point legality + walkable-grid extraction for Phase 06.8 anti-cheat | v1.1 (deferred) or v1.2 |

The parse logic — LDtk `levels[].layerInstances[]` → flat `tiles[]`, auto-tile rule evaluation, IntGrid → walkability extraction, entity → REBNO `platform_defs` / `scripted_triggers` mapping — is identical across all three. Inlining in `apps/client/src/render/` would force a copy when the server needs it later, and copies always drift.

**Package contract:**

```typescript
// packages/map-loader/src/index.ts
export interface ParsedLdtkLevel {
  identifier: string;
  width_tiles: number;
  height_tiles: number;
  tile_w: number;        // must be 44 per CLAUDE.md
  tile_h: number;        // must be 40 per CLAUDE.md
  tiles: Array<{ tileset_sprite_id: string; x: number; y: number; frame?: number }>;
  entities: Array<{ kind: string; x: number; y: number; props: Record<string, unknown> }>;
  walkable: Uint8Array;  // row-major, width_tiles * height_tiles
  spawnPoints: Array<{ name: string; x: number; y: number }>;
}

export interface ParsedLdtkWorld {
  worldGridSize: { w: number; h: number };
  levels: ParsedLdtkLevel[];
  levelGridLayout: 'GridVania' | 'Free' | 'LinearHorizontal' | 'LinearVertical';
}

// Pure functions: data in → data out. No Phaser, no fs, no DOM.
export function parseLdtk(json: string): ParsedLdtkWorld;
export function levelToCanonicalLayout(level: ParsedLdtkLevel): CanonicalLayout;
```

**Pure-data rule** mirrors the existing `packages/game-logic` discipline — no Phaser, no Node fs, no DOM, no globals. Runs identically in Node (converter) and browser (Phaser scene). Tests with `vitest` from the package root.

### Where the QuickType types come from

LDtk publishes a JSON schema (`https://ldtk.io/files/LDTK_JSON_SCHEMA.json`). QuickType (`npx quicktype`) generates current TS types from this schema. Bake the generated `LdtkJson.ts` into `packages/map-loader/src/ldtk-types.ts` and add a `pnpm regen:ldtk-types` script that re-runs QuickType (mirroring the `tools/protocol-doc` pattern). This is the documented standard pattern per the map-editor-decision note: "`ldtk-ts` archived 2022. Plan = `LdtkJson.js` (QuickType-generated types, always current) + custom Phaser scene loader."

### `@rebno/protocol` integration

Extend the layout-schema union with `ldtkLayoutSchema`:

```typescript
// packages/protocol/src/intents.ts (modification sketch)
const ldtkLayoutSchema = z.object({
  room_id: z.string(),
  version: z.number().int().positive(),
  source: z.literal('ldtk'),
  ldtk_world_iid: z.string(),       // round-trip back to authoring source
  ldtk_level_iid: z.string(),
  width_tiles: z.number().int().positive(),
  height_tiles: z.number().int().positive(),
  tile_w: z.literal(44),
  tile_h: z.literal(40),
  tiles: z.array(/* same as newLayoutSchema */),
  // Plus: LDtk-specific extensions (animated_tiles, intgrid_attrs, etc.)
}).strict();

export const layoutSchema = z.union([
  legacyLayoutSchema,
  newLayoutSchema,
  ldtkLayoutSchema,
]);
```

`RoomRegistry.scan` + `tryLoadLatest` REQUIRE NO CHANGE — they pipe through `layoutSchema.parse()`, which accepts the new variant via union extension. The schema-drift lint (`lint-no-clipboard-rce` + `lint:room-layout`) needs the same union update.

## Decision 4 — Server stays map-agnostic in v1.1

### Recommended split

```
┌──────────────────────────────────────────────────────────────────┐
│  CLIENT (Phaser)              SERVER (Colyseus)                  │
├──────────────────────────────────────────────────────────────────┤
│  Renders tiles[]              Stores zone_id (string)            │
│  Renders entities              Stores last-known-good            │
│  Owns minimap                    position per player             │
│  Derives walkable grid        Broadcasts s2c.room_layout         │
│  Plays animated tiles            (signed JSON pass-through)      │
│                               Derives wall-rect collision_polys  │
│                                  + room_size from tiles[]/       │
│                                  tile_w/h (already does this)    │
│  ────────────────────────────────────────────────────────────    │
│  v1.1 server DOES NOT:                                            │
│  - Parse LDtk JSON                                                │
│  - Validate entity placement                                      │
│  - Re-derive walkable grid for anti-cheat (06.8 work)             │
│  - Reason about zone adjacency                                    │
└──────────────────────────────────────────────────────────────────┘
```

### Why server-agnostic is the right v1.1 default

1. **Movement is client-authoritative (06.7 carve-out).** Server validation of player position is Phase 06.8, explicitly out-of-scope per `.planning/PROJECT.md` ("Carry-overs from v1.0 explicitly NOT in v1.1"). Server has no reason to know "is this tile walkable" yet.
2. **`layout-derive.ts` already does what the server needs.** Wall rects derived from `tile_w/h * width_tiles/height_tiles` give the AABB world-bound for `isPositionLegal` at any time. The LDtk-derived `tiles[]` is structurally identical to mvp-room's `tiles[]`; the existing derivation handles the new shape with zero changes.
3. **Signed JSON pass-through preserves the security boundary.** RoomRegistry verifies the Ed25519 manifest_sig BEFORE the server learns the layout. If LDtk parsing were server-side, a buggy parser becomes a CVSS-relevant attack surface; keeping it converter-side + client-side means any parser bug can only ship via an operator-driven import workflow.
4. **Phase 06.8 will need `walkable` extraction server-side.** When it lands, lift `packages/map-loader` import into `apps/server/src/` — that's a 5-line wire-up because the parser is already pure. **This is the dividend on putting it in a package now.**

### Forward-compat note for Phase 06.8

When anti-cheat lands, the natural path is:

```typescript
// apps/server/src/layout-derive.ts (future extension)
import { deriveWalkable } from '@rebno/map-loader';

export function deriveLayoutBounds(layout: Layout): DerivedBounds {
  // existing wall-rect derivation
  if ('source' in layout && layout.source === 'ldtk') {
    return { ...existing, walkable: deriveWalkable(layout.tiles, layout.width_tiles, layout.height_tiles) };
  }
}
```

`s2c.room_layout` already broadcasts derived fields as envelope-separate from the signed `layout_bytes` (see `RoomRegistry.ts:269` packing comment). Adding `walkable` to the envelope is non-breaking for v1.1 clients.

## Decision 5 — Independent `/data/maps/current` symlink swap

### Why a second symlink path, not bundling into `/data/client-assets`

Three concrete reasons:

1. **Editing a map should not require a Vite rebuild.** "Edit → push → see live" is an explicit v1.1 target feature. If maps live under `apps/client/public/maps/`, every map change forces `pnpm --filter @rebno/client build:staging` (~30-60s) → `tar` → SFTP → release.sh. Independent `/data/maps/current` gives map-only deploys at ~5s.
2. **Map data is server-relevant in Phase 06.8.** When the server starts validating positions, it reads the SAME `/data/maps/current/<level>.json` the client fetches. Bundling into `/data/client-assets/current` would force the server to either (a) HTTP-fetch its own static-assets URL (ugly) or (b) read from the client bundle dir (path coupling that breaks the bundled-fallback safety net in `static-assets.ts`).
3. **Atomic-swap primitive already exists.** `scripts/client-release.sh` is the recipe. A `scripts/maps-release.sh` is a near-clone with `ROOT=/data/maps` substituted. Pattern-1 (atomic `mv -T`) + Pattern-2 (stage-check-swap) carries over verbatim.

### Recommended directory layout on the Fly machine

```
/data/
├── client-assets/                    (existing — Phase 06.5)
│   ├── releases/<sha>/...
│   └── current → releases/<sha>
└── maps/                             ← NEW
    ├── releases/
    │   └── <sha>/
    │       ├── overworld-bncentral_nw.json
    │       ├── overworld-bncentral_nw.sig     (Ed25519 from converter)
    │       ├── overworld-bahoo.json
    │       ├── overworld-bahoo.sig
    │       └── manifest.json                  (list of (room_id, sha256))
    └── current → releases/<sha>
```

### Server-side resolution: extend `static-assets.ts` pattern

`apps/server/src/static-assets.ts` already documents the resolver pattern. v1.1 adds a sibling `apps/server/src/maps-dir.ts` with the **identical decision matrix** (env-set → exists → use; env-set → missing → warn + fallback; env-unset → bundled):

```typescript
// apps/server/src/maps-dir.ts (sketch — mirrors static-assets.ts)
export function resolveMapsDir(env: NodeJS.ProcessEnv, log: Logger): MapsDirResolution {
  const envDir = env.MAPS_DIR;
  const bundled = join(dirname(fileURLToPath(import.meta.url)), '..', 'rooms');
  // ... identical branches to static-assets.ts
}
```

`MAPS_DIR=/data/maps/current` in `fly.staging.toml` (mirroring `STATIC_ASSETS_DIR`). `ROOMS_DIR` (currently `/app/rooms`) **stays distinct** — it serves the legacy MVP rooms (mvp-lobby, mvp-room) which remain bundled. The RoomRegistry can scan BOTH dirs:

```typescript
// apps/server/src/index.ts — extend RoomRegistry boot
const bundledRegistry = new RoomRegistry(env.ROOMS_DIR, keys.publicKey);
const ldtkRegistry = new RoomRegistry(env.MAPS_DIR, keys.publicKey);   // optional
bundledRegistry.scan();
ldtkRegistry.scan();
// merge for s2c lookups; bundled wins ties on duplicate room_id (defensive)
```

OR — simpler — keep one RoomRegistry but make it accept multiple dirs in its constructor. The pattern is non-invasive either way.

### Atomic dual-deploy or independent?

**Independent.** A map-only update keeps client + server bytes untouched. A client-only update (new bundle) doesn't require a map swap. Coupling them removes the speed win that's the whole point of `/data` symlink swap. The one edge case where atomic-pair matters: a new tile sprite. That requires (a) updated `atlas-mvp.png` (client-asset deploy), (b) updated `pipeline-manifest.json` (client-asset deploy), (c) map referencing the new `tileset_sprite_id` (map deploy). Ordering rule: **deploy assets first, then maps.** If a map references a missing tile, the client's `RoomRenderer` already logs `missing atlas frame` warnings (line 162-166) and keeps rendering — graceful degradation, not crash. The reverse order (map first, atlas later) shows missing-tile gaps for a few seconds; acceptable.

## Decision 6 — Minimap as overlay container in GameScene

### Pattern: mirror ChatHUD lifecycle

`apps/client/src/ui/ChatHUD.ts` is the proven pattern for in-game overlays:

- Owns Phaser GameObjects (Text + Rect + Container) on the Scene's UI layer
- Listens to scene `shutdown` for dispose
- Receives input via the scene's existing key-capture (`installKeyCapture`)
- Co-exists with the world camera

**New file: `apps/client/src/ui/MinimapHUD.ts`** with the same shape:

```typescript
export class MinimapHUD {
  private container: Phaser.GameObjects.Container;
  private bg: Phaser.GameObjects.Rectangle;
  private rt: Phaser.GameObjects.RenderTexture;   // for tile mini-render
  private localDot: Phaser.GameObjects.Arc;
  private remoteDots = new Map<string, Phaser.GameObjects.Arc>();

  constructor(private scene: Phaser.Scene, private layout: RoomLayout) { /* ... */ }
  update(localPos: {x, y}, remotePlayers: Map<string, {x, y}>): void { /* ... */ }
  toggle(): void { /* ... */ }
  destroy(): void { /* ... */ }
}
```

### Why an overlay, not a separate Scene

Phaser supports multi-scene rendering, but a dedicated minimap scene adds:

- Cross-scene event plumbing (player positions, room_layout)
- Lifecycle coordination (start/stop alongside GameScene)
- Scale-NONE + integer-zoom math duplicated (ADR 0008 — `apps/client/src/render/integer-zoom.ts`)

The minimap reads the same `RoomRenderer.layout` + the same `PlayerRenderer` position cache the world view uses. An overlay container in GameScene is one render pass + zero plumbing. Mirror the camera-ignore pattern: `scene.cameras.main.ignore(minimapContainer)` so the main camera doesn't apply world-zoom to the minimap.

### Decoupling from world-renderer (specific simplifications)

| World renderer concern | Minimap simplification |
|------------------------|------------------------|
| 44×40 px floor sprites | 2×2 px or 4×4 px abstracted tile dots |
| TSide1 bottom-edge sprites | Skip entirely |
| Atlas frame lookups | Pre-bake a single greyscale RenderTexture at `render(layout)` time; redraw only on `s2c.room_layout` change |
| Per-tile depth calculations | Skip — minimap is flat |
| Player sprite (PlayerRenderer with anim states) | One coloured arc per player |
| Camera follow | Static; bounds-fit to room size |

The RenderTexture bake-once pattern means the minimap costs ~zero per-frame work — only the local + remote dot positions update each frame.

### What the minimap reads — single integration surface

```typescript
// GameScene wiring (modification sketch)
this.minimap = new MinimapHUD(this, this.roomRenderer.layout!);
// in onLayoutChange (existing handler at GameScene s2c.room_layout):
this.minimap.rebuild(layout);
// in update loop:
this.minimap.update(localPlayer.pos, this.remotePlayers);
```

Toggle key: bind to `M` via the existing `installKeyCapture` system.

## Decision 7 — New vs. modified components, build order

### Modifications to existing files (5)

| File | Change | Risk |
|------|--------|------|
| `tools/room-converter/cli.ts` | New `ldtk-import` subcommand dispatcher; expand `REQUIRED_LAYOUT_KEYS` check | LOW — additive |
| `tools/room-converter/src/types.ts` | Add `LdtkSourcedLayout` to local type union | LOW — additive |
| `packages/protocol/src/intents.ts` | Add `ldtkLayoutSchema` to `layoutSchema` union | LOW — additive, must keep PROTOCOL_VERSION bump policy in mind |
| `apps/client/src/render/RoomRenderer.ts` | Add `isLdtkLayout(layout)` branch (third shape); minimal new branch reuses `renderNew` path with LDtk-specific atlas key | LOW — third branch, no existing branch touched |
| `apps/server/src/index.ts` | Boot a second RoomRegistry on `MAPS_DIR` OR teach RoomRegistry constructor to accept multiple dirs | MEDIUM — touches Colyseus boot |

### New files / packages (8)

| Path | Purpose |
|------|---------|
| `maps/` (top-level dir) | LDtk authoring source |
| `maps/overworld.ldtk` + sidecar | Single multi-world LDtk project |
| `tools/room-converter/src/ldtk-import.ts` | `.ldtk` → signed rooms/<id>/<rev>.{json,sig} |
| `packages/map-loader/` | Pure-data LDtk parser; types + parse + flatten + entity-map |
| `apps/client/src/ui/MinimapHUD.ts` | In-game minimap overlay |
| `apps/server/src/maps-dir.ts` | `MAPS_DIR` env resolver (mirrors static-assets.ts) |
| `scripts/maps-release.sh` | `/data/maps/current` atomic swap (mirrors client-release.sh) |
| `docs/deploy/MAPS-DEPLOY.md` | Runbook (mirrors LOCAL-DEPLOY.md structure) |

### Build order (dependency-respecting)

```
Task 1: Tile inventory + LDtk tileset prep
  ↓ Catalog which extracted/client-5-8/sprites/ are floor tiles vs entities
  ↓ Confirm 44×40 carries through to LDtk's grid cell config

Task 2: packages/map-loader scaffold
  ↓ QuickType-gen LDtk types, write parseLdtk(json) → ParsedLdtkWorld
  ↓ Unit tests against a hand-authored 3×3 LDtk fixture
  ↓ NO converter integration yet — just the pure parser

Task 3: tools/room-converter ldtk-import subcommand
  ↓ Wire map-loader into a new subcommand
  ↓ Emit rooms/<id>/<rev>.{json,sig} for one trivial LDtk level
  ↓ Round-trip: extract → ldtk-import → room-converter verify PASSES

Task 4: Smoke-test layout in LDtk (4×4 BORDERED floor tiles, one spawn)
  ↓ Author in LDtk by hand
  ↓ ldtk-import → server boots → client connects → renders
  ↓ This is the v1.1 "smoke-test the workflow" gate from PROJECT.md

Task 5: Protocol schema extension + RoomRenderer third-shape branch
  ↓ Add ldtkLayoutSchema to packages/protocol union
  ↓ Add ldtk branch in RoomRenderer (or unify with renderNew if shapes converge)
  ↓ E2E: smoke-test level loads in browser, player walks on it

Task 6: Map-deploy pipeline
  ↓ scripts/maps-release.sh + apps/server/src/maps-dir.ts
  ↓ MAPS_DIR env wired in fly.staging.toml
  ↓ Local dev: ROOMS_DIR + MAPS_DIR both scanned
  ↓ Operator runbook: edit .ldtk → ldtk-import → maps-release.sh

Task 7: MinimapHUD
  ↓ Container + RenderTexture bake-on-layout-change
  ↓ Local + remote dots
  ↓ M-key toggle
  ↓ Verified against smoke-test level

Task 8: BNCentral chunking (Task 6 of milestone scope)
  ↓ Author BNCentral as GridVania levels in LDtk (4-8 chunks per editor-lag mitigation)
  ↓ Map legacy 6000 entities → LDtk entities or IntGrid values
  ↓ Confirm visual parity with extracted/client-5-8/rooms/0058-BNCentral

Task 9: Remaining zones (Bahoo, Noiya, Schweisstar, etc.)
  ↓ Each zone is a single LDtk level (<5000 px → under editor-lag threshold)
  ↓ Reuse the Task 8 entity-mapping table
```

**Why this order:**

- Task 1 unblocks every visual decision in LDtk authoring.
- Task 2 is pure data — testable without running the server or browser.
- Task 3 is the converter integration; failure here is a TypeScript bug, not a deployment bug.
- Task 4 is the **first end-to-end smoke** — failure can be in any of the three pieces but the contract is "does this LDtk save → load in Phaser at all". This is where surprises about LDtk's tile coordinate convention, animated-tile metadata, etc. surface.
- Task 5 is the protocol breaking-point; it locks the wire format for v1.1.
- Task 6 splits map deploys from client deploys — the speed win is realised here.
- Task 7 is a pure client overlay; no wire or deploy work.
- Task 8-9 are content tasks that depend on every preceding piece being stable.

## Architectural Patterns (v1.1-specific)

### Pattern A: Three-shape schema union with passthrough derivation

**What:** Add new layout shapes via `z.union()` extension, NOT by replacing existing shapes. Derivation logic (collision_polys, walkable, room_size) lives at broadcast time (`layout-derive.ts`) and reads structural fields the new shape provides.

**When to use:** Any time the canonical on-disk schema evolves but the security boundary (Ed25519 sig over file bytes) must stay intact.

**Trade-off:** Schema union grows unboundedly. Mitigated by a `lint:room-layout` gate that enforces "exactly one shape claimed per file". Phase 7 PAR-03 may collapse legacy + new + ldtk into one shape; defer that decision.

### Pattern B: Pure-data package mirroring `@rebno/game-logic`

**What:** Browser + Node + tooling share a parser package with no Phaser, no fs, no DOM dependencies. Test once, use everywhere.

**When to use:** Whenever logic must run identically across converter (Node), client (browser), and future server validation.

**Trade-off:** A pure-data package can't optimise for any single host (no Phaser RenderTexture pre-bake). The host-specific layer (RoomRenderer, MinimapHUD) wraps the pure parser output.

### Pattern C: Independent symlink-swap channels

**What:** Each independently-deployable content category gets its own `/data/<channel>/current → releases/<sha>` symlink, owned by a per-channel release script.

**When to use:** When deploy cadence diverges from rebuild cadence and the security model is "atomic content swap, server bytes immutable".

**Trade-off:** Each channel is a new env var, resolver, and release script. Acceptable cost for the ~10× cadence speed-up on the high-churn channel (maps in v1.1).

## Anti-Patterns

### Anti-Pattern 1: Bundling LDtk JSON into the Vite client bundle

**What people do:** Put `.ldtk` (or its baked JSON output) under `apps/client/public/maps/` so Vite hashes it and a content-hashed asset URL appears in the manifest.
**Why it's wrong:** Defeats the entire Phase 06.5 content/code split. Every map edit forces a client image rebuild + server deploy. Also hides map data from server-side anti-cheat (Phase 06.8) — server would have to HTTP-fetch its own static URL.
**Do this instead:** Top-level `maps/` for source; `/data/maps/current` for runtime; map-loader package fetches at runtime via standard fetch().

### Anti-Pattern 2: New tool `tools/ldtk-import/` parallel to `tools/room-converter/`

**What people do:** Treat LDtk import as a new "tool", create a sibling directory with duplicated Ed25519 + atomic-write + nextRev plumbing.
**Why it's wrong:** Splits the source-of-truth signing key path between two CLIs. The room-converter `verify` subcommand is the contract test for the wire format; a fork creates a parallel verify CLI or worse, no verify for LDtk-derived layouts.
**Do this instead:** Extend room-converter with an `ldtk-import` subcommand. Update the package description.

### Anti-Pattern 3: Server-side LDtk parsing in v1.1

**What people do:** Add `parseLdtk` to `apps/server/src/index.ts` boot path, claim it's "ready for Phase 06.8".
**Why it's wrong:** Expands the security boundary unnecessarily. The Ed25519 manifest_sig gate is the trust boundary; pushing the parser server-side means a parse bug becomes a server-crash vector. Phase 06.8 needs walkable-grid extraction — that's `Uint8Array` derivation from `tiles[]`, NOT raw LDtk parse.
**Do this instead:** LDtk → REBNO canonical-layout JSON happens in `tools/room-converter` (offline + signed). Server only ever sees signed canonical JSON. Phase 06.8 lifts `deriveWalkable(tiles[])` from map-loader; the LDtk JSON never reaches the server.

### Anti-Pattern 4: Minimap as a separate Phaser Scene

**What people do:** Add `MinimapScene` to the scene list, plumb player positions + room_layout cross-scene.
**Why it's wrong:** Adds Phaser scene lifecycle bookkeeping (start, stop, restart on logout), cross-scene events, and a second integer-zoom math owner. ADR 0008 is per-scene.
**Do this instead:** `MinimapHUD` overlay container inside GameScene; main camera ignores it; UI camera or static-position GameObjects render it.

### Anti-Pattern 5: Forking RoomRenderer for LDtk

**What people do:** Add `LdtkRoomRenderer.ts` parallel to `RoomRenderer.ts`.
**Why it's wrong:** Two atlases, two dispose paths, two depth conventions. Drift is guaranteed.
**Do this instead:** If LDtk layout structurally matches `newLayoutSchema` (and it should — same `tiles[]`, same `width_tiles/height_tiles`), the existing `renderNew` branch handles it. Add ONE if-branch in `render()` to read LDtk-specific extra fields (animated_tiles timing, intgrid layer attrs). One file. One depth convention.

## Integration Points

### Existing → New (data flow)

| Source | Sink | Format | Cadence |
|--------|------|--------|---------|
| `maps/*.ldtk` (operator edit) | `tools/room-converter ldtk-import` | LDtk JSON | manual per edit |
| `room-converter ldtk-import` | `maps-baked/<room_id>/<rev>.{json,sig}` (temp) OR `/data/maps/releases/<sha>/` | Signed canonical JSON | per import run |
| `maps-baked/` | `scripts/maps-release.sh` | tarball + sftp | per deploy |
| `/data/maps/current` | `RoomRegistry.scan` (boot) + fs.watch (hot) | Signed JSON via Ed25519 verify | boot + on-change |
| `RoomRegistry.onChange` | `RebnoRoom` `s2c.room_layout` broadcast | msgpack(parsed layout) + envelope | per swap |
| `s2c.room_layout` (wire) | `apps/client/src/render/RoomRenderer.render(layout)` | Parsed Layout | per broadcast |
| `RoomRenderer.layout` | `apps/client/src/ui/MinimapHUD.rebuild(layout)` | Same Layout reference | per RoomRenderer.render() |

### Touch-points in existing files

- `apps/server/src/RoomRegistry.ts` — **NO CHANGE** if the constructor accepts one dir and the boot creates two registries. **OR** add `constructor(dirs: string[], pubKey)` and merge.
- `apps/server/src/index.ts` — boot a second registry OR pass multiple dirs.
- `apps/server/src/static-assets.ts` — **NO CHANGE**. Mirror the file as `maps-dir.ts`.
- `apps/server/src/layout-derive.ts` — **NO CHANGE in v1.1**. Phase 06.8 extends.
- `apps/server/fly.staging.toml` — add `MAPS_DIR = "/data/maps/current"` in `[env]`.
- `apps/server/Dockerfile` — **NO CHANGE**. Maps are not baked.
- `apps/client/src/scenes/GameScene.ts` — instantiate MinimapHUD; call `rebuild` in the existing `s2c.room_layout` handler; call `update` in the existing tick loop.
- `apps/client/src/render/RoomRenderer.ts` — third branch in `render()`, OR unify with `renderNew` if LDtk layout structurally maps to `newLayoutSchema`.
- `packages/protocol/src/intents.ts` — extend `layoutSchema` union.

### External services / integrations

| Service | Integration Pattern | Notes |
|---------|---------------------|-------|
| LDtk editor app | File-on-disk only; no API | Operator runs LDtk locally, saves `.ldtk` to repo |
| QuickType (build-time) | `pnpm regen:ldtk-types` npx command | Run when LDtk publishes a schema bump |
| Fly.io machine | Identical to client-release: SFTP + `flyctl machine exec` for maps-release.sh | No new auth surface |
| Existing Ed25519 keypair (`keys/rebno-room-signing.ed25519`) | Reuse for LDtk-derived room signatures | One trust root for all room sources |

## Scaling Considerations

| Scale | Architecture adjustment |
|-------|------------------------|
| 1 LDtk world, ~10 levels, <50 CCU | v1.1 design as written — no changes |
| 1 world, 50+ levels | Consider sharding maps-release.sh by level identifier prefix |
| Per-CCU minimap RenderTexture cost | Pre-baked once per layout swap — O(1) per frame regardless of CCU |
| Editor lag at large levels | GridVania-chunk BNCentral (per map-editor-decision.md mitigation) — accepted |

### First bottleneck: editor lag (LDtk-side, not REBNO architecture)

Above ~5000 px in a single IntGrid layer, LDtk editing slows perceptibly (deepnight/ldtk#1029, #1073, cited in the editor-decision note). Mitigation is GridVania chunking, already accepted. Architecture supports it directly: each chunk becomes a separate REBNO room_id (e.g. `overworld-bncentral-nw`, `overworld-bncentral-ne`). The cross-chunk transition is a zone-change request → server → broadcast — the same primitive that already handles every BNO room transition.

## Sources

- `apps/server/src/RoomRegistry.ts` (inspected lines 1-289) — registry contract
- `apps/server/src/layout-derive.ts` (inspected lines 1-80) — server-side derivation pattern
- `apps/server/src/static-assets.ts` (inspected lines 1-92) — env resolver pattern
- `apps/client/src/render/RoomRenderer.ts` (inspected lines 1-415) — two-shape branching
- `apps/client/src/render/RoomCollision.ts` (inspected lines 1-60) — walkable-grid derivation
- `apps/client/src/scenes/BootScene.ts` (full read) — asset-loading entry pattern
- `tools/room-converter/cli.ts` (full read) — existing converter shape
- `tools/room-converter/src/convert.ts` + `types.ts` (full read) — local schema duplication policy
- `packages/protocol/src/intents.ts:202-329` (grep) — schema union shape
- `scripts/client-release.sh` (full read) — atomic swap recipe
- `apps/server/Dockerfile` — image-bake boundaries
- `apps/server/rooms/mvp-room/000.json` — canonical layout shape reference
- `.planning/PROJECT.md` — milestone scope + carry-overs explicitly excluded
- `.planning/notes/map-editor-decision.md` — LDtk decision rationale + accepted tradeoffs
- `docs/extracted-engine/scene-room-model.md` — 44×40 px tile constant + room model
- `docs/deploy/LOCAL-DEPLOY.md` — operator runbook conventions
- CLAUDE.md — Hard Rule 1 carve-out, MAPS-DIR pattern precedent in STATIC_ASSETS_DIR

---
*Architecture research for: v1.1 Map Groundwork — LDtk authoring + map-deploy + in-game minimap integration*
*Researched: 2026-05-18*
