# Render pipeline

Everything is one `<canvas>` plus HTML overlays. No DOM tiles. Target ~8–12 fps is fine (the look is deliberately chunky); the village runs at an ~95ms tick.

## 0. Build the base once (offscreen, world-sized = 928×480)

Draw, in this order, onto an offscreen canvas you keep forever:

1. **Grass** over the whole field — `tile(b, {x,y}, MAT.grass, 16, seededRng(x,y))` per cell.
2. **Courtyard + walks** — `drawRegion` with `MAT.path` (see `zones.regions`).
3. **Room floors** — per room, fill its rect with its `floor` material.
4. **Overlay regions** — rugs (`round:true`), coolant pool (`water`), via `drawRegion` (autotiles its own edges: highlight on top/left where no neighbor, line on bottom/right).
5. **Walls** — per room: top edge via `drawWallSpec(style, base, accent)` (capstone + baseboard + 7px cast shadow onto the floor below); the other three edges via `wallBlk(roomColor)` one tile thick. Leave the `door` gap (2 tiles) open.
6. **Static furniture** — `zones.furniture`, each `FableArt.furniture[fn](b, x*16, y*16, 0, opt)`.
7. **Billboard bezels** — `FableArt.furniture.billboard(b, x*16,y*16,w*16,h*16)`.

Two invariants make it cohere: **ink `#2B2A38` outline on every shape**, and **top-left light** (each tile already bakes a 1px lighter top/left + darker bottom/right; the `box()` helper does the same for props).

## 1. Per-frame draw

Let `cam = {x, y, zoom}` (world px + scale), `VW×VH` the viewport, `S = sky(hour)` from the dayNight keyframes (lerp), `f` the frame counter.

```
ctx.setTransform(1,0,0,1,0,0)
ctx.fillStyle = '#0C1622'; fillRect(0,0,VW,VH)          // letterbox / void
ctx.imageSmoothingEnabled = false                        // crisp pixels at any zoom

// --- world space ---
ctx.setTransform(zoom,0,0,zoom, -cam.x*zoom, -cam.y*zoom)
ctx.drawImage(baseCanvas, 0, 0)                          // the whole village, scaled (nearest-neighbor)
for each crew c:                                         // dynamic, on top of base
    pos = c.walk ? wander(c, f) : c.spawn                // see §2
    if selected: stroke gold ellipse under feet
    FableArt.char(ctx, pos.x, pos.y, c.appearance, stepBit(f))

// --- screen space (reset transform) ---
ctx.setTransform(1,0,0,1,0,0)
softLight(S.ambient, 0.90)                               // time-of-day tint over everything
if !S.isDay: multiply(S.skyTop, 0.50)                    // deepen night
weatherVeil()                                            // tokens.weather[w].veil (op + color)
for each light L (zones.lights):                         // emissive, AFTER tint so it glows out
    sx=(L.x-cam.x)*zoom; sy=(L.y-cam.y)*zoom
    FableArt.light(ctx, sx, sy, L.r*zoom, L.color, L.alpha*(0.16 + S.glow))
weatherParticles(f)                                      // rain/snow/fog drawn last so it's on top
if storm: white flash (alpha = flash*0.06; flash=6 on trigger ~every 67f, decrement)
FableArt.vignette(ctx, VW, VH, S.vignette)
```

`softLight`/`multiply` = `ctx.save(); ctx.globalCompositeOperation='soft-light'|'multiply'; ctx.globalAlpha=a; fillStyle=color; fillRect(0,0,VW,VH); restore()`.
`FableArt.light` uses composite `'lighter'` with a radial gradient (color→transparent). That single `0.16 + S.glow` term is what makes lamps/screens/fire invisible at noon and blazing at night.

## 2. Crew motion

Stationary crew sit at `spawn`. Walkers drift on a Lissajous so they never leave the room and never need pathfinding:

```
px = spawn.x + sin(f*0.03 + ph) * ax
py = spawn.y + sin(f*0.021 + ph*1.3) * ay
step = (f >> 2) & 1            // 2-frame leg shuffle, passed as the last arg to char()
```

When you wire `agent.status`, swap this for real intent: walk toward a workstation when working, park near the door when away, hide when offline (see `crew.json` activity map). Keep the idle Lissajous as the fallback for "online, no task."

## 3. Camera

```
overviewCam:  zoom = min(VW/928, VH/480) * 0.98 ; center ; clamp
focusCam(rm): rw=rm.w*16+48, rh=rm.h*16+48
              zoom = min(VW/rw, VH/rh, 4.4)
              center on room center ; clamp to [0, world - viewport/zoom]
each frame:   cam += (camTarget - cam) * 0.16        // ease, all three components
```

`clamp`: if the world is smaller than the viewport on an axis, center it; else keep `[0, dim - viewport/zoom]`.

## 4. Overlays (projected HTML, updated every frame)

Project world→screen with `sx=(wx-cam.x)*zoom, sy=(wy-cam.y)*zoom` and set each element's `left/top` (account for the canvas's CSS scale if you don't render 1:1).

- **Room labels** — at room center; shown only in/near overview (`zoom < 2.4`). Click → `focusCam`.
- **Crew nametags** — above each head; shown when `zoom > 1.6`. Translucent pill, a team-color dot, gentle float; selected = gold.
- **Billboards** — sized `w*16*zoom × h*16*zoom` over the bezel; shown when `zoom > 1.9`. The clock line ticks every frame; the link is a real `<a target=_blank>`.
- **HUD** — sky dial (gradient `skyTop→skyBot` + sun/moon along an arc by hour), clock, weather + temp + location.

## 5. Hit testing

Click → world: `wx = cam.x + localX/zoom`. When `zoom > 1.4`: test crew bboxes (`x..x+16, y-6..y+28`), then billboard rects → open editor. Otherwise test room rects → `focusCam`. Click empty → back to overview.
