# FreeExile Milestone 2: Client TileMapRenderer & Iso Culling Engine Architecture Report

**Author:** Architecture & Culling Math Explorer (`explorer_m2_1`)  
**Date:** 2026-10-01T20:10:00Z  
**Scope:** Client rendering pipeline, isometric frustum culling, procedural tile drawing, and `world_renderer.js` delegation  
**Authoritative References:** `ORIGINAL_REQUEST.md` (§R2), `PROJECT.md` (M2), `GEMINI.md`, `AGENTS.md`

---

## 1. Executive Summary

Milestone 2 bridges the procedural map generation delivered in Milestone 1 (`wilderness_map_generator.py`, `map_binary_serializer.py`, `tile_grid_loader.js`) to the client-side canvas rendering engine. The objective is to eliminate legacy hardcoded math loops in `world_renderer.js`, introduce strict viewport frustum culling with a $+2$ tile safety margin, implement a $16 \times 16$ tile chunk pre-rendering cache using an LRU `OffscreenCanvas` pool that strictly obeys the mobile RAM budget ($\le 8\text{ MB}$), and procedurally render all 20 `TileType` codes with distinct elevation offsets and biome-aware palettes.

Key findings and architectural metrics:
- **`world_renderer.js` Partition**: Lines 12–28 contain legacy hardcoded ambient terrain loops that must be delegated to `TileMapRenderer.render(...)`. Lines 29–461 contain critical interactive entities (Map Device with 6 orbiting portals, World Gate, Quest Gate, Wilderness Return Waypoints, safe zone indicators, secret vortexes, loot beams) and must remain 100% intact.
- **Isometric Culling Formula**: By applying the inverse isometric projection ($\text{isoToWorld}$) to the 4 viewport corners, the visible world bounding box spans $[cam_X - R_w, cam_X + R_w]$ and $[cam_Y - R_w, cam_Y + R_w]$ where $R_w = \frac{W}{128} + \frac{H}{64}$. Adding $+2$ tile padding produces exact integer grid ranges $[minTx, maxTx]$ and $[minTy, maxTy]$.
- **Chunk Caching & Draw Calls**: Partitioning the map into $16 \times 16$ tile chunks reduces draw calls per frame on mobile from over 1,500 individual tile calculations down to **2–4 `drawImage` calls**, drastically outperforming the acceptance threshold of $\le (W/64 + 5) \times (H/32 + 5)$.
- **Mobile RAM Budget**: A $16 \times 16$ chunk rendered to an `OffscreenCanvas` ($1088 \times 576\text{px}$) consumes $2.39\text{ MB}$ in 32-bit RGBA. An LRU pool capped at 3 active chunk canvases uses $7.17\text{ MB}$, leaving headroom under the $8.0\text{ MB}$ hard limit for the $10.5\text{ KB}$ tile grid.
- **Hygiene & Code Limits**: `tile_map_renderer.js` is architected with a compact palette lookup table and geometry helpers to stay under **320 lines** (well below the 350 soft cap and 500 hard cap).

---

## 2. Examination of `world_renderer.js` (Lines 12–28 vs 29–461)

An in-depth line-by-line inspection of `client/webapp/js/engine/world_renderer.js` (total 461 lines) reveals two distinct domains:

### 2.1. Legacy Terrain Rendering: Lines 12–28
```javascript
// 0. RENDER BASE AMBIENT TILE FIELD (EXTENDS TO VIEWPORT EDGES ON LARGE SCREENS)
const camObj = (typeof camera !== 'undefined' && camera) ? camera : (typeof window !== 'undefined' ? window.camera : null);
const camWx = (camObj && Number.isFinite(camObj.wx)) ? Math.round(camObj.wx) : 0;
const camWy = (camObj && Number.isFinite(camObj.wy)) ? Math.round(camObj.wy) : 0;
const tileRadius = Math.ceil(Math.max(viewport.clientWidth, viewport.clientHeight) / (TILE_H * 2)) + 2;
for (let x = camWx - tileRadius; x <= camWx + tileRadius; x++) {
  for (let y = camWy - tileRadius; y <= camWy + tileRadius; y++) {
    const pt = worldToIso(x, y);
    if (pt.x < -TILE_W || pt.x > viewport.clientWidth + TILE_W || pt.y < -TILE_H || pt.y > viewport.clientHeight + TILE_H) continue;
    let tileImg = ASSETS['tile_grass'];
    if (Math.abs(x - y) <= 1) tileImg = ASSETS['tile_stone'];
    else if (Math.hypot(x - 5, y - (-3)) < 3.2) tileImg = ASSETS['tile_water'];
    if (tileImg && tileImg.complete) {
      ctx.drawImage(tileImg, pt.x - TILE_W / 2, pt.y - TILE_H / 2, TILE_W, TILE_H);
    }
  }
}
```
**Deficiencies of Legacy Logic**:
1. **Inefficient O(N²) Screen Space Testing**: Iterates over a square grid of $(2 \cdot \text{tileRadius} + 1)^2$ tiles (often 1,500–2,500 iterations per frame), computes `worldToIso` for every tile, and discards out-of-bounds tiles after the calculation.
2. **Hardcoded Mathematical Topology**: Assigns tile graphics via synthetic equations (`Math.abs(x - y) <= 1` for stone, `Math.hypot(x - 5, y - (-3)) < 3.2` for water) instead of querying the data-driven map grid generated by the server.
3. **Zero Caching**: Executes individual `ctx.drawImage` operations every frame without offscreen buffering or dirty-flag tracking.

Additionally, lines 30–40 render `ASSETS['vltk1_terrain']` (a single monolithic 1440×960 painted PNG) centered at `worldToIso(0, 0)`. When procedural map tiles are loaded, this legacy backdrop must be bypassed so procedural tiles are displayed cleanly.

### 2.2. Interactive Props, Waypoints, Portals, and Map Devices: Lines 29–461
Lines 29–461 represent the live gameplay layer and must remain completely intact:
- **Lines 42–91**: Ground drops, loot beams (Apple Metal vertical gradients), 3D item bobbing, and PoE-style rarity text banners.
- **Lines 93–132**: Secret chamber void vortex with rotating cyan/purple spiral arms and proximity interaction pills.
- **Lines 134–186**: Nearest portal proximity detection and pulsating isometric rune rings (`ctx.ellipse(0, 8, 44 + pulse, 22 + pulse * 0.5)`).
- **Lines 188–458**:
  - *Hideout / Sanctuary Domain* (Lines 197–378):
    - **PoE2 6-Portal Map Device** at `(1.6, -1.0)`: Stone dais base, inner rune ring, central conduit glow, 6 orbiting portals in hexagonal formation with active purple void swirls and depleted sockets.
    - **World Gate** at `(-2.8, -1.8)`: Arched cyan vortex portal with rotating 3-arm spirals and destination label.
    - **Quest Gate** at `(-2.8, 1.8)`: Golden portal arch with counter-rotating energy vortex.
  - *Wilderness Domain* (Lines 380–457):
    - **Wilderness Return Waypoint** at `(wp.wx, wp.wy)`: 8.0-unit immortal safe haven radius visual indicator (isometric ellipse $362 \times 181\text{px}$ with rotating dashed border), obelisk monolith, and celestial beacon column.

---

## 3. Mathematical Formulation of Isometric Viewport Frustum Culling

### 3.1. Transformation Equations in `iso_math.js`
The engine utilizes a 2:1 isometric projection with tile dimensions $\text{TILE\_W} = 64$ and $\text{TILE\_H} = 32$. $1\text{ world unit} = 1\text{ tile}$ ($tx = \lfloor wx \rfloor, ty = \lfloor wy \rfloor$).

Let:
- $W = \text{viewport.clientWidth}$, $H = \text{viewport.clientHeight}$
- $c_x = W / 2$, $c_y = H / 2$
- $cam_X = \text{camera.wx}$, $cam_Y = \text{camera.wy}$

**Forward Projection (`worldToIso`)**:
$$\begin{aligned}
x &= ( (wx - cam_X) - (wy - cam_Y) ) \cdot \frac{\text{TILE\_W}}{2} + c_x = ( (wx - wy) - (cam_X - cam_Y) ) \cdot 32 + c_x \\
y &= ( (wx - cam_X) + (wy - cam_Y) ) \cdot \frac{\text{TILE\_H}}{2} + c_y - wz \cdot 24 = ( (wx + wy) - (cam_X + cam_Y) ) \cdot 16 + c_y - wz \cdot 24
\end{aligned}$$

**Inverse Projection (`isoToWorld`)**:
Given screen coordinate $(s_x, s_y)$ relative to viewport center ($s_x = x - c_x$, $s_y = y - c_y$):
$$\begin{aligned}
\Delta wx &= \frac{1}{2} \left( \frac{s_x}{\text{TILE\_W} / 2} + \frac{s_y}{\text{TILE\_H} / 2} \right) = \frac{1}{2} \left( \frac{s_x}{32} + \frac{s_y}{16} \right) = \frac{s_x}{64} + \frac{s_y}{32} \\
\Delta wy &= \frac{1}{2} \left( \frac{s_y}{\text{TILE\_H} / 2} - \frac{s_x}{\text{TILE\_W} / 2} \right) = \frac{1}{2} \left( \frac{s_y}{16} - \frac{s_x}{32} \right) = \frac{s_y}{32} - \frac{s_x}{64} \\
wx &= \Delta wx + cam_X \\
wy &= \Delta wy + cam_Y
\end{aligned}$$

### 3.2. Viewport Frustum Bounding Box Derivation
The screen viewport is defined by its 4 vertices:
- $P_0 = (0, 0)$ (Top-Left)
- $P_1 = (W, 0)$ (Top-Right)
- $P_2 = (W, H)$ (Bottom-Right)
- $P_3 = (0, H)$ (Bottom-Left)

Evaluating $\text{isoToWorld}$ at each vertex yields the relative world offsets:
$$\begin{aligned}
P_0 (0, 0): &\quad \Delta wx_0 = -\frac{W}{128} - \frac{H}{64}, &\quad \Delta wy_0 = -\frac{H}{64} + \frac{W}{128} \\
P_1 (W, 0): &\quad \Delta wx_1 = +\frac{W}{128} - \frac{H}{64}, &\quad \Delta wy_1 = -\frac{H}{64} - \frac{W}{128} \\
P_2 (W, H): &\quad \Delta wx_2 = +\frac{W}{128} + \frac{H}{64}, &\quad \Delta wy_2 = +\frac{H}{64} - \frac{W}{128} \\
P_3 (0, H): &\quad \Delta wx_3 = -\frac{W}{128} + \frac{H}{64}, &\quad \Delta wy_3 = +\frac{H}{64} + \frac{W}{128}
\end{aligned}$$

Notice that the world-space bounding extents are strictly bounded by:
$$R_w = \frac{W}{128} + \frac{H}{64}$$
$$\begin{aligned}
\min(wx) &= cam_X - R_w \\
\max(wx) &= cam_X + R_w \\
\min(wy) &= cam_Y - R_w \\
\max(wy) &= cam_Y + R_w
\end{aligned}$$

Alternatively, calling `isoToWorld` directly on the 4 viewport corners:
```javascript
const c0 = isoToWorld(0, 0);
const c1 = isoToWorld(W, 0);
const c2 = isoToWorld(W, H);
const c3 = isoToWorld(0, H);

const minWx = Math.min(c0.wx, c1.wx, c2.wx, c3.wx);
const maxWx = Math.max(c0.wx, c1.wx, c2.wx, c3.wx);
const minWy = Math.min(c0.wy, c1.wy, c2.wy, c3.wy);
const maxWy = Math.max(c0.wy, c1.wy, c2.wy, c3.wy);
```

### 3.3. Safety Margin (+2 Tile Padding) and Map Boundary Clamping
Because isometric diamond tiles have non-zero width ($64\text{px}$) and height ($32\text{px}$), and elevated tiles (e.g. walls up to $22\text{px}$ elevation) project upwards on screen, a safety margin of **$+2$ tiles** is added to all four boundaries:
```javascript
const TILE_PADDING = 2;
const minTx = Math.max(0, Math.floor(minWx) - TILE_PADDING);
const maxTx = Math.min(mapWidth - 1, Math.ceil(maxWx) + TILE_PADDING);
const minTy = Math.max(0, Math.floor(minWy) - TILE_PADDING);
const maxTy = Math.min(mapHeight - 1, Math.ceil(maxWy) + TILE_PADDING);
```

### 3.4. Chunk Range Calculation ($16 \times 16$ Tiles)
For chunks of size $C = 16$:
```javascript
const minChunkX = Math.max(0, Math.floor(minTx / 16));
const maxChunkX = Math.min(Math.floor((mapWidth - 1) / 16), Math.floor(maxTx / 16));
const minChunkY = Math.max(0, Math.floor(minTy / 16));
const maxChunkY = Math.min(Math.floor((mapHeight - 1) / 16), Math.floor(maxTy / 16));
```
On an iPhone screen ($852 \times 393\text{px}$), $R_w = 852/128 + 393/64 \approx 12.8\text{ tiles}$. The entire visible span is $\approx 26\text{ tiles} + 4\text{ padding} = 30\text{ tiles}$. In $16 \times 16$ chunks, this spans at most $2 \times 2 = 4$ chunks!

---

## 4. Chunk Pre-Rendering Architecture & Mobile RAM Budget

### 4.1. Mathematical Invariance of Offscreen Chunk Rendering
In isometric projection, the relative position of any local tile $(lx, ly) \in [0, 15] \times [0, 15]$ within a chunk $(cx, cy)$ with chunk origin $(W_{0}, W_{1}) = (cx \cdot 16, cy \cdot 16)$ is:
$$\begin{aligned}
dx &= (lx - ly) \cdot \frac{\text{TILE\_W}}{2} = (lx - ly) \cdot 32 \\
dy &= (lx + ly) \cdot \frac{\text{TILE\_H}}{2} = (lx + ly) \cdot 16
\end{aligned}$$
Since $lx - ly \in [-15, +15]$ and $lx + ly \in [0, 30]$:
- Horizontal span: from $-480\text{px}$ to $+480\text{px}$ (width $960\text{px} + 64\text{px tile width} = 1024\text{px}$)
- Vertical span: from $0\text{px}$ to $+480\text{px}$ (height $480\text{px} + 32\text{px tile height} = 512\text{px}$)

Adding an elevation safety margin of $32\text{px}$ on top and $32\text{px}$ border padding:
- OffscreenCanvas dimensions: **$\text{Width} = 1088\text{px}$**, **$\text{Height} = 576\text{px}$**.
- Local chunk anchor point: $(X_{\text{anchor}}, Y_{\text{anchor}}) = (512, 32)$.

When rendering the chunk:
Each local tile $(lx, ly)$ is drawn at:
$$\begin{aligned}
\text{localX} &= (lx - ly) \cdot 32 + 512 \\
\text{localY} &= (lx + ly) \cdot 16 + 32
\end{aligned}$$
When blitting the pre-rendered chunk to the viewport canvas:
$$\begin{aligned}
\text{screenOrigin} &= \text{worldToIso}(W_0, W_1) \\
\text{ctx.drawImage}(\text{chunk.canvas}, &\ \text{screenOrigin}.x - 512, \ \text{screenOrigin}.y - 32)
\end{aligned}$$
Because $\text{screenOrigin}.x - 512 + \text{localX} = \text{worldToIso}(W_0 + lx, W_1 + ly).x$, the projection is **sub-pixel exact and 100% invariant to camera movement**.

### 4.2. Mobile RAM Budget Verification ($\le 8\text{ MB}$)
- **Tile Grid Memory**: For the largest canonical zone ($120 \times 90$ in `zone_boundless_celestial_palace`):
  $$\text{Grid Data} = 120 \times 90 \times 1\text{ byte} = 10,800\text{ bytes} \approx 10.55\text{ KB}$$
- **Chunk OffscreenCanvas Memory**:
  $$\text{Per Chunk Canvas} = 1088 \times 576 \times 4\text{ bytes (RGBA)} = 2,506,752\text{ bytes} \approx 2.39\text{ MB}$$
- **LRU Pool Sizing**:
  - Setting $\text{MAX\_CHUNKS} = 3$:
    $$\text{Cache RAM} = 3 \times 2.39\text{ MB} = 7.17\text{ MB}$$
    $$\text{Total RAM} = 7.17\text{ MB} + 0.01\text{ MB} = \mathbf{7.18\text{ MB}} \le \mathbf{8.0\text{ MB}}$$
  - Setting $\text{MAX\_CHUNKS} = 4$ would exceed 8 MB ($9.56\text{ MB}$).
  - **Architectural Decision**: Bound the active LRU cache strictly to **3 chunks** (or 3 active canvases with dynamic eviction). Since an iPhone viewport intersects at most 2–4 chunks at any moment, 3 LRU slots provide optimal hit rate while strictly satisfying the $\le 8\text{ MB}$ budget.

---

## 5. Procedural Isometric Texture Palettes for All 20 TileTypes (0–19)

When external sprite sheet assets are unavailable or loading, `TileMapRenderer` falls back to high-fidelity procedural isometric vector drawing. Each tile diamond is rendered with:
1. Base fill color (modulated by biome palette)
2. Bevel edge stroke highlight
3. 2.5D vertical extrusion block for elevated obstacles
4. Distinctive procedural glyph/accent

### 5.1. Comprehensive 20-TileType Palette Matrix

| Code | TileType Enum | Walkable | Blocks Vision | Cost | Base Color | Side Shadow | Edge Highlight | Elevation ($H_{elev}$) | Procedural Feature |
|:---:|:---|:---:|:---:|:---:|:---|:---|:---|:---:|:---|
| **0** | `VOID` | ❌ | 👁️ Block | — | `#050508` | `#020204` | none | $0\text{px}$ | Pitch black abyss |
| **1** | `FLOOR` | ✅ | 👁️ Clear | 1.0 | `#2e3440` | `#222730` | `rgba(255,255,255,0.06)` | $0\text{px}$ | Clean slate flagstone |
| **2** | `WALL` | ❌ | 👁️ Block | — | `#52525b` | `#27272a` | `rgba(255,255,255,0.22)` | $+18\text{px}$ | 2.5D monolithic stone cliff |
| **3** | `DESTRUCTIBLE_BARRICADE` | ❌ | 👁️ Clear | — | `#78350f` | `#451a03` | `#d97706` | $+14\text{px}$ | Spiked bone-wood palisade |
| **4** | `MUD_POOL` | ✅ | 👁️ Clear | 2.0 | `#3b2716` | `#23170d` | `rgba(180,130,80,0.30)` | $-2\text{px}$ | Depressed muddy mire & bubble |
| **5** | `SPIKE_TRAP` | ✅ | 👁️ Clear | 1.0 | `#334155` | `#1e293b` | `#e2e8f0` | $0\text{px}$ | Steel puncture spikes & blood |
| **6** | `CRUMBLED_DEBRIS` | ❌ | 👁️ Clear | — | `#475569` | `#1e293b` | `#94a3b8` | $+10\text{px}$ | Shattered boulder pile |
| **7** | `BONE_PILE` | ✅ | 👁️ Clear | 1.0 | `#e4e4e7` | `#71717a` | `#ffffff` | $+6\text{px}$ | Mound of bleached skulls |
| **8** | `POISON_VENT` | ✅ | 👁️ Clear | 1.0 | `#14532d` | `#052e16` | `#22c55e` | $0\text{px}$ | Noxious viridian gas fissure |
| **9** | `CHASM` | ❌ | 👁️ Clear | — | `#09090b` | `#000000` | `#71717a` | $-8\text{px}$ | Bottomless crevasse edge |
| **10** | `BOSS_GATE` | ❌/✅ | 👁️ Block | — | `#581c87` | `#3b0764` | `#e879f9` | $+22\text{px}$ | Heavy purple seal gate / rune |
| **11** | `BOSS_ALTAR` | ❌ | 👁️ Clear | — | `#1e1b4b` | `#0f172a` | `#a855f7` | $+12\text{px}$ | Sacrificial dais with core glow |
| **12** | `RUNIC_FLOOR` | ✅ | 👁️ Clear | 1.0 | `#1e293b` | `#0f172a` | `#818cf8` | $0\text{px}$ | Glowing arcane ley line floor |
| **13** | `PATH` | ✅ | 👁️ Clear | 0.8 | `#574c3e` | `#3d3429` | `#d97706` | $0\text{px}$ | Worn cobblestone dirt trail |
| **14** | `DENSE_TERRAIN` | ✅ | 👁️ Clear | 1.43 | `#1c3321` | `#0f1f13` | `#4ade80` | $+3\text{px}$ | Thorny briars & dense brush |
| **15** | `POI` | ✅ | 👁️ Clear | 1.0 | `#78350f` | `#451a03` | `#fbbf24` | $+4\text{px}$ | Inlaid golden relic pedestal |
| **16** | `ENCOUNTER_LOW` | ✅ | 👁️ Clear | 1.0 | `#362a22` | `#231c17` | `#d97706` | $0\text{px}$ | Faint amber hunting dust |
| **17** | `ENCOUNTER_MEDIUM`| ✅ | 👁️ Clear | 1.0 | `#3d2320` | `#271513` | `#ea580c` | $0\text{px}$ | Orange-red combat skirmish zone |
| **18** | `ENCOUNTER_HIGH` | ✅ | 👁️ Clear | 1.0 | `#45181b` | `#2d0f11` | `#ef4444` | $0\text{px}$ | Vivid crimson apex lair |
| **19** | `WATER` | ❌ | 👁️ Clear | — | `#0c4a6e` | `#082f49` | `#38bdf8` | $-4\text{px}$ | Murky deep water with waves |

### 5.2. Biome Palette Modulations
When `biomeCode` is provided by `currentMapMetadata` (1–5), the base floor, path, and wall colors receive tone adjustments:
- `BLEACHED_BONE_CANYON` (1): Dusty limestone, chalky bone white accents.
- `SAVAGE_MANGROVE_SWAMP` (2): Murky olive-drab, dark moss tint, teal water.
- `CRIMSON_BLOOD_FOREST` (3): Deep burgundy red tint, ash gray trails.
- `OUTCAST_MINE_SHAFTS` (4): Iron ore dark gray, coal dust, copper trim.
- `CORRUPTED_FIEND_RUINS` (5): Deep obsidian violet, eldritch magenta edges.

---

## 6. TileMapRenderer Class Contract & Architecture

### 6.1. Interface Contract Specification
```typescript
interface ChunkData {
  cx: number;
  cy: number;
  canvas: HTMLCanvasElement | OffscreenCanvas;
  ctx: CanvasRenderingContext2D;
  dirty: boolean;
  lastUsedFrame: number;
}

class TileMapRenderer {
  // Initialization & lifecycle
  static init(mapGrid: Uint8Array, width: number, height: number, biomeId?: string | number): void;
  static clearCache(): void;

  // Frame execution
  static render(ctx: CanvasRenderingContext2D, camera: { wx: number, wy: number }, viewport: { clientWidth: number, clientHeight: number }): void;

  // Dynamic mutation & dirty-flagging
  static markChunkDirty(tx: number, ty: number): void;
  static markChunkDirtyByCoords(cx: number, cy: number): void;

  // Memory & performance instrumentation
  static getMemoryUsage(): number; // Returns bytes in RAM (strictly <= 8 MB)
  static getLastDrawCalls(): number; // Returns chunk draw count for last frame (<= 4)
  static getActiveChunksCount(): number;
}
```

### 6.2. Concrete Architectural Blueprint for `tile_map_renderer.js`
The implementation will be encapsulated in `client/webapp/js/engine/tile_map_renderer.js` conforming to ES modules / global window patterns.

```javascript
// --- FREEEXILE POE2 TILE MAP RENDERER (FRUSTUM CULLING & LRU CHUNK CACHE) ---
const CHUNK_SIZE = 16;
const TILE_W = 64;
const TILE_H = 32;
const MAX_LRU_CHUNKS = 3; // Guaranteed <= 8 MB RAM (3 * 2.39 MB = 7.17 MB)

// Compact palette dictionary (0-19)
const TILE_PALETTES = [
  /* 0: VOID */ { base: '#050508', side: '#020204', stroke: '#000000', elev: 0 },
  /* 1: FLOOR */ { base: '#2e3440', side: '#222730', stroke: 'rgba(255,255,255,0.06)', elev: 0 },
  /* 2: WALL */ { base: '#52525b', side: '#27272a', stroke: 'rgba(255,255,255,0.22)', elev: 18 },
  /* 3: BARRICADE */ { base: '#78350f', side: '#451a03', stroke: '#d97706', elev: 14 },
  /* 4: MUD_POOL */ { base: '#3b2716', side: '#23170d', stroke: 'rgba(180,130,80,0.3)', elev: -2 },
  /* 5: SPIKE_TRAP */ { base: '#334155', side: '#1e293b', stroke: '#e2e8f0', elev: 0 },
  /* 6: DEBRIS */ { base: '#475569', side: '#1e293b', stroke: '#94a3b8', elev: 10 },
  /* 7: BONE_PILE */ { base: '#e4e4e7', side: '#71717a', stroke: '#ffffff', elev: 6 },
  /* 8: POISON_VENT */ { base: '#14532d', side: '#052e16', stroke: '#22c55e', elev: 0 },
  /* 9: CHASM */ { base: '#09090b', side: '#000000', stroke: '#71717a', elev: -8 },
  /* 10: BOSS_GATE */ { base: '#581c87', side: '#3b0764', stroke: '#e879f9', elev: 22 },
  /* 11: BOSS_ALTAR */ { base: '#1e1b4b', side: '#0f172a', stroke: '#a855f7', elev: 12 },
  /* 12: RUNIC_FLOOR */ { base: '#1e293b', side: '#0f172a', stroke: '#818cf8', elev: 0 },
  /* 13: PATH */ { base: '#574c3e', side: '#3d3429', stroke: '#d97706', elev: 0 },
  /* 14: DENSE_TERRAIN */ { base: '#1c3321', side: '#0f1f13', stroke: '#4ade80', elev: 3 },
  /* 15: POI */ { base: '#78350f', side: '#451a03', stroke: '#fbbf24', elev: 4 },
  /* 16: ENC_LOW */ { base: '#362a22', side: '#231c17', stroke: '#d97706', elev: 0 },
  /* 17: ENC_MED */ { base: '#3d2320', side: '#271513', stroke: '#ea580c', elev: 0 },
  /* 18: ENC_HIGH */ { base: '#45181b', side: '#2d0f11', stroke: '#ef4444', elev: 0 },
  /* 19: WATER */ { base: '#0c4a6e', side: '#082f49', stroke: '#38bdf8', elev: -4 },
];

export const TileMapRenderer = {
  grid: null,
  width: 0,
  height: 0,
  biomeCode: 1,
  chunks: new Map(), // key: `${cx}_${cy}` -> chunk
  frameCounter: 0,
  lastDrawCalls: 0,

  init(mapGrid, width, height, biomeCode = 1) {
    this.grid = mapGrid;
    this.width = width;
    this.height = height;
    this.biomeCode = biomeCode;
    this.clearCache();
  },

  clearCache() {
    this.chunks.clear();
  },

  markChunkDirty(tx, ty) {
    const cx = Math.floor(tx / CHUNK_SIZE);
    const cy = Math.floor(ty / CHUNK_SIZE);
    const key = `${cx}_${cy}`;
    const chunk = this.chunks.get(key);
    if (chunk) chunk.dirty = true;
  },

  getMemoryUsage() {
    const gridBytes = this.grid ? this.grid.byteLength : 0;
    // Each chunk canvas is 1088 x 576 x 4 bytes
    const chunkBytes = this.chunks.size * (1088 * 576 * 4);
    return gridBytes + chunkBytes;
  },

  render(ctx, camera, viewport) {
    if (!this.grid || this.width <= 0 || this.height <= 0) return;
    this.frameCounter++;
    this.lastDrawCalls = 0;

    // Viewport frustum culling
    const W = viewport.clientWidth;
    const H = viewport.clientHeight;
    const Rw = W / 128 + H / 64;
    const camX = camera?.wx ?? 0;
    const camY = camera?.wy ?? 0;

    const minTx = Math.max(0, Math.floor(camX - Rw) - 2);
    const maxTx = Math.min(this.width - 1, Math.ceil(camX + Rw) + 2);
    const minTy = Math.max(0, Math.floor(camY - Rw) - 2);
    const maxTy = Math.min(this.height - 1, Math.ceil(camY + Rw) + 2);

    const minCx = Math.floor(minTx / CHUNK_SIZE);
    const maxCx = Math.floor(maxTx / CHUNK_SIZE);
    const minCy = Math.floor(minTy / CHUNK_SIZE);
    const maxCy = Math.floor(maxTy / CHUNK_SIZE);

    for (let cy = minCy; cy <= maxCy; cy++) {
      for (let cx = minCx; cx <= maxCx; cx++) {
        const chunk = this.getOrBakeChunk(cx, cy);
        if (!chunk) continue;
        chunk.lastUsedFrame = this.frameCounter;

        // Blit chunk: origin at (cx * 16, cy * 16)
        const screenPt = window.worldToIso(cx * CHUNK_SIZE, cy * CHUNK_SIZE);
        ctx.drawImage(chunk.canvas, screenPt.x - 512, screenPt.y - 32);
        this.lastDrawCalls++;
      }
    }

    this.pruneLRU();
  },

  getOrBakeChunk(cx, cy) {
    const key = `${cx}_${cy}`;
    let chunk = this.chunks.get(key);
    if (!chunk) {
      const canvas = (typeof OffscreenCanvas !== 'undefined')
        ? new OffscreenCanvas(1088, 576)
        : document.createElement('canvas');
      canvas.width = 1088;
      canvas.height = 576;
      chunk = { cx, cy, canvas, ctx: canvas.getContext('2d'), dirty: true, lastUsedFrame: this.frameCounter };
      this.chunks.set(key, chunk);
    }

    if (chunk.dirty) {
      this.bakeChunk(chunk);
      chunk.dirty = false;
    }
    return chunk;
  },

  bakeChunk(chunk) {
    const cctx = chunk.ctx;
    cctx.clearRect(0, 0, 1088, 576);

    const startX = chunk.cx * CHUNK_SIZE;
    const startY = chunk.cy * CHUNK_SIZE;

    // Draw tiles in isometric painter's depth order: x + y from 0 to 30
    for (let sum = 0; sum <= 30; sum++) {
      for (let lx = 0; lx <= sum; lx++) {
        const ly = sum - lx;
        if (lx >= CHUNK_SIZE || ly >= CHUNK_SIZE) continue;
        const tx = startX + lx;
        const ty = startY + ly;
        if (tx >= this.width || ty >= this.height) continue;

        const tileCode = this.grid[ty * this.width + tx] ?? 2;
        const px = (lx - ly) * 32 + 512;
        const py = (lx + ly) * 16 + 32;
        this.drawProceduralTile(cctx, tileCode, px, py);
      }
    }
  },

  drawProceduralTile(ctx, code, px, py) {
    const p = TILE_PALETTES[code] || TILE_PALETTES[1];
    const elev = p.elev;

    // Elevated 2.5D side walls
    if (elev > 0) {
      ctx.fillStyle = p.side;
      ctx.beginPath();
      ctx.moveTo(px - 32, py - elev);
      ctx.lineTo(px, py + 16 - elev);
      ctx.lineTo(px, py + 16);
      ctx.lineTo(px - 32, py);
      ctx.closePath();
      ctx.fill();

      ctx.fillStyle = p.side;
      ctx.beginPath();
      ctx.moveTo(px, py + 16 - elev);
      ctx.lineTo(px + 32, py - elev);
      ctx.lineTo(px + 32, py);
      ctx.lineTo(px, py + 16);
      ctx.closePath();
      ctx.fill();
    }

    // Top diamond
    ctx.fillStyle = p.base;
    ctx.beginPath();
    ctx.moveTo(px, py - 16 - elev);
    ctx.lineTo(px + 32, py - elev);
    ctx.lineTo(px, py + 16 - elev);
    ctx.lineTo(px - 32, py - elev);
    ctx.closePath();
    ctx.fill();

    if (p.stroke) {
      ctx.strokeStyle = p.stroke;
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  },

  pruneLRU() {
    if (this.chunks.size <= MAX_LRU_CHUNKS) return;
    let oldestKey = null;
    let oldestFrame = Infinity;
    for (const [key, chunk] of this.chunks.entries()) {
      if (chunk.lastUsedFrame < oldestFrame) {
        oldestFrame = chunk.lastUsedFrame;
        oldestKey = key;
      }
    }
    if (oldestKey) this.chunks.delete(oldestKey);
  }
};
```

---

## 7. Refactoring Strategy for `world_renderer.js`

### 7.1. Proposed Change in `world_renderer.js`
In `client/webapp/js/engine/world_renderer.js`:
1. **Lines 12–28**: Replace with:
```javascript
      // 0. RENDER PROCEDURAL TILE MAP WITH FRUSTUM CULLING & LRU CHUNK CACHE
      if (typeof window !== 'undefined' && window.TileMapRenderer && window.currentMapGrid) {
        window.TileMapRenderer.render(ctx, camObj, viewport);
      } else {
        // Fallback ambient tile loop for standalone offline tests
        const tileRadius = Math.ceil(Math.max(viewport.clientWidth, viewport.clientHeight) / (TILE_H * 2)) + 2;
        for (let x = camWx - tileRadius; x <= camWx + tileRadius; x++) {
          for (let y = camWy - tileRadius; y <= camWy + tileRadius; y++) {
            const pt = worldToIso(x, y);
            if (pt.x < -TILE_W || pt.x > viewport.clientWidth + TILE_W || pt.y < -TILE_H || pt.y > viewport.clientHeight + TILE_H) continue;
            let tileImg = (typeof ASSETS !== 'undefined') ? ASSETS['tile_grass'] : null;
            if (Math.abs(x - y) <= 1) tileImg = ASSETS && ASSETS['tile_stone'];
            else if (Math.hypot(x - 5, y - (-3)) < 3.2) tileImg = ASSETS && ASSETS['tile_water'];
            if (tileImg && tileImg.complete) {
              ctx.drawImage(tileImg, pt.x - TILE_W / 2, pt.y - TILE_H / 2, TILE_W, TILE_H);
            }
          }
        }
      }
```
2. **Lines 30–40**: Guard `vltk1_terrain` background image:
```javascript
      // 1. RENDER MASTER VLTK1 PAINTED TERRAIN MAP (FALLBACK ONLY WHEN NO MAP GRID LOADED)
      if (!window.currentMapGrid && typeof ASSETS !== 'undefined') {
        const tPos = worldToIso(0, 0);
        const terrainImg = ASSETS['vltk1_terrain'];
        if (terrainImg && terrainImg.complete) {
          ctx.save();
          ctx.filter = 'brightness(0.60) contrast(1.35) saturate(0.50) sepia(0.20) hue-rotate(-18deg)';
          const tw = 1440;
          const th = 960;
          ctx.drawImage(terrainImg, tPos.x - tw / 2, tPos.y - th / 2, tw, th);
          ctx.restore();
        }
      }
```
3. **Lines 42–461**: Kept 100% unaltered.

---

## 8. Verification Strategy & Acceptance Criteria

1. **Draw Call Counter Verification**:
   - `TileMapRenderer.lastDrawCalls` must be $\le 4$ on mobile viewport ($852 \times 393$) and $\le 12$ on desktop ($1920 \times 1080$), satisfying $\le (W/64 + 5) \times (H/32 + 5)$.
2. **Chunk Dirty Flag Verification**:
   - Calling `TileGridLoader.setTileAt(tx, ty, tileCode)` marks the chunk dirty. The next frame re-bakes the chunk once and resets `dirty = false`. Subsequent frames must not re-bake.
3. **Memory Budget Verification**:
   - `TileMapRenderer.getMemoryUsage()` must return $\le 8,388,608\text{ bytes}$ ($8\text{ MB}$) for a $120 \times 90$ grid with 3 active LRU chunks.
4. **Performance Benchmark Tooling**:
   - Benchmark script `tools/perf/map_render_benchmark.js` executes 500 frames of simulated camera panning across a $120 \times 90$ map, asserting average FPS $\ge 30$, draw calls $\le 4$, and memory $\le 8\text{ MB}$.
5. **Regression Guard**:
   - All 894 unit tests in `tests/unit/` and 81 E2E tests in `tests/e2e/test_poe2_map_system_e2e.py` must continue to pass with 0 regressions.
