# Phase 6: Client Rebuild — MVP Gate (CLI-08) - Pattern Map

**Mapped:** 2026-05-09
**Files analyzed:** ~28 new + 5 modified surfaces
**Analogs found:** 25 / 28 (3 have no direct analog — flagged in §No Analog Found)

---

## Workspace + Boundary Rules (read these first)

These cross-cutting rules govern *where* every Phase-6 artifact lives. Decided in Phase 1 D-17 / Phase 2 D-15, locked by `pnpm-workspace.yaml`:

```yaml
# pnpm-workspace.yaml (locked)
packages:
  - 'apps/*'
  - 'packages/*'
  # NOTE: tools/* deliberately excluded — preserves Phase 1 D-17 / Phase 2 D-15
  # boundary that tools are standalone, not workspace members.
```

Implications for Phase 6 file placement:

| New artifact | Workspace member? | Rationale |
|---|---|---|
| `apps/client/` | YES (auto via `apps/*` glob) | D-13 — second `apps/*` member, alongside `apps/server/` |
| `tools/asset-pipeline/` | NO | D-13 — standalone tool; Phase 1 D-17 boundary preserved (mirrors `tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc`, `tools/room-converter`) |
| `assets/source/sprites/<id>.{png,json}` | N/A (raw asset, no manifest) | D-14 committed Aseprite-export source |
| `tools/scripts/lint-*.mjs` | NO (loose .mjs invoked by `node tools/scripts/...`) | Mirrors existing Phase-4/5 lint shape (8 lints in tree today) |
| `scripts/verify-phase-6.mjs` | NO (loose .mjs at repo root) | Mirrors `scripts/verify-phase-4.mjs`, `scripts/verify-phase-5.mjs` |
| `apps/server` patches (D-07/D-08/D-09) | YES (existing) | In-place edit to existing `@rebno/server` workspace member |
| `packages/protocol` patches (D-08/D-09) | YES (existing) | Schema bump + `PROTOCOL_VERSION++` in existing `@rebno/protocol` |

**Tool ↔ workspace bridge** (D-16 hybrid manifest): `tools/asset-pipeline build` reads `apps/client/dist/manifest.json` (Vite output) and emits a richer `tools/asset-pipeline/output/pipeline-manifest.json` keyed against Vite-hashed paths. The static-asset bytes (atlas PNGs, background PNG) ship via `apps/server/public/` static mount (D-18); `tools/asset-pipeline` writes them into `apps/client/public/atlas/` (or whichever Vite static dir the planner picks) before `pnpm --filter @rebno/client build:<env>` runs.

---

## File Classification

### New files (apps/client/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `apps/client/package.json` | config | n/a | `apps/server/package.json`, `packages/protocol/package.json` | exact (workspace member shape) |
| `apps/client/tsconfig.json` | config | n/a | `apps/server/tsconfig.json`, `tools/asset-catalog/tsconfig.json` | exact |
| `apps/client/vite.config.ts` | config | n/a | NONE in repo (first Vite app) | NO ANALOG — see §No Analog Found |
| `apps/client/vitest.config.ts` | config | n/a | `apps/server/vitest.config.ts`, `tools/asset-catalog/vitest.config.ts` | exact |
| `apps/client/playwright.config.ts` | config | e2e | NONE in repo (first Playwright config) | NO ANALOG |
| `apps/client/.env.staging` / `.env.prod` | config | n/a | `apps/server/fly.staging.toml` / `fly.prod.toml` (env-shape pattern) | role-match (env-per-target) |
| `apps/client/index.html` | static-shell | n/a | NONE | NO ANALOG (Vite scaffold) |
| `apps/client/src/main.ts` | bootstrap | request-response | `apps/server/src/index.ts` (boot-then-listen pattern) | role-match |
| `apps/client/src/scenes/BootScene.ts` | scene | request-response | NONE in repo (first Phaser scene) | NO ANALOG (use research patterns) |
| `apps/client/src/scenes/LoginScene.ts` | scene + auth | request-response | NONE — but auth flow mirrors `apps/server/src/index.ts` POST-`/api/auth/sign-in/email` middleware (consumer side) | partial |
| `apps/client/src/scenes/GameScene.ts` | scene + prediction | streaming + request-response | `apps/server/src/RebnoRoom.ts` (tickLoop + step + state delta) — symmetrical client side | role-match (mirror) |
| `apps/client/src/net/colyseus-client.ts` | net | streaming | `apps/server/test/authority.integ.test.ts` lines 36-54 (`@colyseus/sdk` Client + joinOrCreate) | exact (already used in tests + soak) |
| `apps/client/src/net/auth-client.ts` | auth | request-response | (consumer of) `apps/server/src/index.ts` lines 156-260 + `apps/server/src/auth.ts` | role-match |
| `apps/client/src/net/protocol-version-check.ts` | net + guard | request-response | `apps/server/src/RebnoRoom.ts` lines 178-188 (validateAuthFrame consumer side) | exact-mirror |
| `apps/client/src/net/reconnect-banner.ts` | ui | event-driven | `apps/server/test/reconnect.integ.test.ts` (consumer of `allowReconnection`) | role-match |
| `apps/client/src/prediction/predictor.ts` | prediction | streaming | `apps/server/src/RebnoRoom.ts` `tickLoop` + `advanceAccumulator` + `step()` call | exact-mirror (deterministic same code) |
| `apps/client/src/prediction/reconciler.ts` | prediction | streaming | NONE — D-10 threshold-gated lerp/snap is new | NO ANALOG (use research §reconciliation patterns) |
| `apps/client/src/prediction/extrapolator.ts` | prediction | streaming | `packages/game-logic/src/step.ts` (re-runs same `step()` for remote players) | exact (re-use of existing function) |
| `apps/client/src/ui/chat-overlay.ts` | ui | event-driven | NONE in repo (first DOM-overlay UI) | NO ANALOG |
| `apps/client/src/ui/nameplate.ts` | ui | event-driven | NONE | NO ANALOG |
| `apps/client/src/ui/force-reset-overlay.ts` | ui + auth | request-response | (consumer of) `apps/server/src/RebnoRoom.ts` lines 261-279 (force_password_change emit) | role-match |
| `apps/client/src/assets/atlas-loader.ts` | asset | file-I/O | NONE | NO ANALOG (use research patterns) |
| `apps/client/src/assets/room-renderer.ts` | scene | streaming | (consumer of) `apps/server/src/RebnoRoom.ts` lines 137-146 (broadcastRoomLayout emit) | role-match |
| `apps/client/src/assets/room-verify.ts` | guard | n/a | `tools/scripts/lint-room-layout.mjs` lines 110-119 (Ed25519 verify pattern) | exact (verbatim verify) |
| `apps/client/test/cli-08.e2e.test.ts` | test (e2e) | n/a | `apps/server/test/authority.integ.test.ts` (joinClient pattern) + `scripts/soak-staging.mjs` (multi-client driving) | role-match (Playwright wrapper around colyseus client pattern) |
| `apps/client/src/__test__/prediction.test.ts` | test (unit) | n/a | `apps/server/test/tick-accumulator.test.ts` (pure-step + accumulator unit) | exact |
| `apps/client/src/__test__/extrapolation.test.ts` | test (unit) | n/a | `apps/server/test/tick-accumulator.test.ts` | exact |

### New files (tools/asset-pipeline/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `tools/asset-pipeline/package.json` | config | n/a | `tools/asset-catalog/package.json`, `tools/extract-gmd/package.json` | exact |
| `tools/asset-pipeline/tsconfig.json` | config | n/a | `tools/asset-catalog/tsconfig.json` | exact |
| `tools/asset-pipeline/vitest.config.ts` | config | n/a | `tools/asset-catalog/vitest.config.ts` | exact |
| `tools/asset-pipeline/cli.ts` | cli dispatcher | request-response | `tools/asset-catalog/cli.ts`, `tools/extract-gmd/cli.ts` | exact |
| `tools/asset-pipeline/src/bootstrap.ts` | utility | file-I/O | `tools/extract-gmd/cli.ts` (sharp BMP decode pattern) | role-match |
| `tools/asset-pipeline/src/build.ts` | utility | file-I/O + transform | NONE (atlas pack new; sharp is reused) | partial — pack algo new |
| `tools/asset-pipeline/src/manifest.ts` | utility | transform | `tools/asset-catalog/cli.ts` `runCatalog` (deterministic JSON emit) | role-match |
| `tools/asset-pipeline/test/bootstrap.test.ts` | test (unit) | n/a | `tools/asset-catalog` test layout | exact |
| `tools/asset-pipeline/test/build.test.ts` | test (unit) | n/a | `tools/asset-catalog` test layout | exact |

### New files (lints + verify)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `tools/scripts/lint-asset-pipeline.mjs` | lint | n/a | `tools/scripts/lint-room-layout.mjs` (manifest schema + integrity hash check) | exact |
| `tools/scripts/lint-vite-env.mjs` | lint | n/a | `tools/scripts/lint-deploy-stack.mjs` (regex-based env config drift guard) | exact |
| `scripts/verify-phase-6.mjs` | composite gate | n/a | `scripts/verify-phase-5.mjs`, `scripts/verify-phase-4.mjs` | exact |
| `scripts/verify-phase-6.test.mjs` | test (unit) | n/a | `scripts/verify-phase-5.test.mjs` | exact |

### Modified files

| File | Role | Data Flow | Modification Source | Notes |
|------|------|-----------|---------------------|-------|
| `apps/server/src/RebnoRoom.ts` | scene/room | streaming | D-07 `onJoin` home-portal spawn + D-08 `input_axes` broadcast + D-09 event-driven `input` handler | Edit existing class methods (lines 244-280, 373-418) |
| `apps/server/src/onMessageHandlers.ts` | net handlers | request-response | D-09 — `c2s.input` schema accepts new event-driven shape `{seq, axes:{x,y}, buttons_down, buttons_up, monotonic_at_ms}` | Edit `cInputSchema` consumer at lines 92-122 |
| `packages/protocol/src/state.ts` | state schema | streaming | D-08 — add `@type('number') axis_x_held: number = 0; @type('number') axis_y_held: number = 0` to `PlayerState` (or single `input_axes` Schema-compound) | Edit class at lines 9-24 |
| `packages/protocol/src/intents.ts` | wire schema | request-response | D-09 — replace `cInputSchema` body shape | Edit lines 33-44 |
| `packages/protocol/src/version.ts` | constant | n/a | D-08 — `PROTOCOL_VERSION = 2` | Bump from `1 as const` to `2 as const` |
| `apps/server/src/index.ts` | boot/middleware | request-response | D-18 — add `app.use(express.static(...))` AFTER auth `/api/auth/*` routes and BEFORE `colyseus.listen` | Insert between line 261 (`app.use(express.json());`) and the registry boot |
| `.github/workflows/deploy-staging.yml` | CI | n/a | D-18 — add `pnpm --filter @rebno/client build:staging` step BEFORE `docker buildx build` (line 67-76) + Playwright smoke after deploy | Edit (insert two new steps) |
| `06-HUMAN-UAT.md` | doc artifact | n/a | `.planning/phases/05-deploy/05-HUMAN-UAT.md` | Mirror Phase 5 UAT pattern (D-19) |

---

## Pattern Assignments

### `apps/client/package.json` (workspace member)

**Analog:** `apps/server/package.json` (lines 1-57) — second `apps/*` workspace member. Phase 6 client is the third `@rebno/*` workspace package.

**Imports / shape pattern** (lines 1-17):
```jsonc
{
  "name": "@rebno/client",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Phase 6 CLI-01..09 + AST-01 — Vite 8 + TypeScript + Phaser 3.90 client. Consumes @rebno/{protocol,game-logic} via workspace:*.",
  "scripts": {
    "dev": "vite",
    "build:staging": "vite build --mode staging",
    "build:prod": "vite build --mode production",
    "preview": "vite preview",
    "test:unit": "vitest run --exclude 'test/**/*.e2e.test.ts'",
    "test:e2e": "playwright test",
    "typecheck": "tsc --noEmit"
  }
}
```

**Workspace dep pattern** (apps/server/package.json lines 29-31):
```json
"dependencies": {
  "@rebno/game-logic": "workspace:*",
  "@rebno/protocol": "workspace:*"
}
```

`@rebno/db` is server-only; **DO NOT** add it to client. Phaser 3.90 + `@colyseus/sdk` + `better-auth` (browser SDK via `createAuthClient`) + `msgpackr` + `zod` are runtime deps; `@playwright/test` + `vitest` + `@types/node` + `typescript` go to devDependencies.

---

### `apps/client/tsconfig.json`

**Analog:** `tools/asset-catalog/tsconfig.json` (lines 1-15) — most concise reference shape. **Server's tsconfig is also acceptable** but client needs `"lib": ["DOM", "ES2022"]` instead of node-only.

```jsonc
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",       // OR "ESNext" — Vite handles bundling, planner picks
    "moduleResolution": "NodeNext",
    "strict": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],  // CLIENT-SPECIFIC
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}
```

`strict: true` + `noUncheckedIndexedAccess: true` + `exactOptionalPropertyTypes: true` is the project-wide TS strictness baseline; do NOT relax for client code.

---

### `apps/client/vitest.config.ts`

**Analog:** `apps/server/vitest.config.ts` (lines 1-13) and `tools/asset-catalog/vitest.config.ts` (lines 1-13) — both use the same shape.

```ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // Single worker keeps fs.watch / DOM teardown deterministic across files.
    pool: 'forks',
    maxWorkers: 1,
    include: ['src/__test__/**/*.test.ts', 'test/**/*.test.ts'],
    testTimeout: 30000,
    environment: 'jsdom',  // CLIENT-ONLY — DOM globals for Phaser scenes / overlay tests
  },
});
```

Server uses default node env; client must opt into `jsdom` (Wave 0 task in `06-VALIDATION.md` §Wave 0 line 53). **Exclude e2e** so unit run is < 30 s (VALIDATION row "Quick run command").

---

### `apps/client/src/main.ts` (bootstrap)

**Analog:** `apps/server/src/index.ts` lines 51-78 — `boot()` async fn returning a `BootedClient`-style handle, with env load → DI wiring → start sequence.

**Pattern to copy** (server's `boot()` shape, adapted):
```ts
// [impl->REQ-CLI-01] [impl->REQ-CLI-02]
// apps/client boot — load env via import.meta.env → mount auth client → start Phaser game.
// CONTEXT D-18 — single domain per env eliminates CORS; cookies SameSite=Strict.

import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene.js';
import { LoginScene } from './scenes/LoginScene.js';
import { GameScene } from './scenes/GameScene.js';

const env = {
  WSS_URL: import.meta.env.VITE_WSS_URL as string,
  HTTP_BASE: import.meta.env.VITE_HTTP_BASE as string,
  ROOM_SIGNING_PUBKEY: import.meta.env.VITE_ROOM_SIGNING_PUBKEY as string,
  STAGING_MODE: import.meta.env.VITE_STAGING_MODE === '1',
};

new Phaser.Game({
  type: Phaser.AUTO,
  parent: 'game-root',
  scale: {
    mode: Phaser.Scale.FIT,
    autoRound: true,  // D-12 HiDPI integer-scale snap
  },
  render: { pixelArt: true, antialias: false },
  scene: [BootScene, LoginScene, GameScene],
});
```

**Reference for CSP / strict env contract:** `apps/server/src/env.ts` (zod-validated env) — client mirrors but consumes `import.meta.env` not `process.env`.

---

### `apps/client/src/net/colyseus-client.ts` (joinOrCreate wrapper)

**Analog:** `apps/server/test/authority.integ.test.ts` lines 36-54 + `scripts/soak-staging.mjs` lines 74-105. **This is the canonical client SDK usage already exercised by Phase 4 tests + Phase 5 soak — copy it.**

**Imports + joinOrCreate pattern** (authority.integ.test.ts:36-54):
```ts
import { Client } from '@colyseus/sdk';
import { PROTOCOL_VERSION } from '@rebno/protocol';

async function joinClient(wssUrl: string, sessionToken: string, invite?: string) {
  const client = new Client(wssUrl);
  const room = await client.joinOrCreate('rebno', {
    protocol_version: PROTOCOL_VERSION,
    session_token: sessionToken,
    ...(invite ? { invite } : {}),  // D-04 staging invite token (Phase 5 D-04)
  });
  return { client, room };
}
```

**Listening pattern** (soak-staging.mjs:85-105):
```ts
room.onMessage('s2c', (raw: Uint8Array) => {
  const evt = decodeS2C(raw);  // msgpackr-encoded — uses @rebno/protocol decodeS2C
  // dispatch to scene
});
room.onStateChange((state) => { /* prediction reconciliation */ });
room.onLeave((code) => {
  if (code !== 1000) { /* trigger reconnect banner D-06 */ }
});
```

**Send pattern** (soak-staging.mjs:113-126):
```ts
room.send('input', { type: 'input', seq, axes: {x, y}, buttons_down: 0, buttons_up: 0, monotonic_at_ms: performance.now() });
room.send('chat_send', { type: 'chat_send', text });
```

**Critical:** `room.send(type, payload)` uses Colyseus `onMessage(type, ...)` channels — NOT a generic `c2s` channel. The deprecated `c2s` channel is dropped server-side per `apps/server/src/onMessageHandlers.ts` lines 241-246.

---

### `apps/client/src/net/auth-client.ts` (Better-Auth client)

**Analog:** `apps/server/src/index.ts` lines 156-260 (the **server-side** Better-Auth wiring) + `apps/server/src/auth.ts` (Better-Auth instance config). The client consumes the same surface.

**Endpoint contract** (from server's `app.post('/api/auth/sign-in/email', ...)` at index.ts:177):
- `POST /api/auth/sign-in/email` body `{email|username, password}` → `{session_token}` (legacy migration is server-side; client just sends username + password)
- `GET /api/auth/get-session` (or `/api/auth/me`) → `{user, session}` or 401
- `POST /api/auth/sign-out`
- `POST /api/auth/change-password` (D-03 force-reset overlay)

**Cookie semantics** (server auth.ts lines 18-62 + index.ts lines 122-154):
- Same-origin per D-18 → `SameSite=Strict` cookies survive
- Server expects `credentials: 'include'` on fetch
- Bearer-token alternate: server's `bearer()` plugin accepts `Authorization: Bearer <session_token>` for the WS auth path (auth.ts line 58)

**Pattern to copy** — use Better-Auth's official `createAuthClient` (per CONTEXT.md canonical-refs):
```ts
import { createAuthClient } from 'better-auth/react'; // OR /client for vanilla
export const authClient = createAuthClient({
  baseURL: import.meta.env.VITE_HTTP_BASE,  // e.g. https://staging.rebno.decidel.com
});
```

Plus session-token bridge for Colyseus: after `authClient.signIn.email(...)` completes, extract `session.token` from `authClient.getSession()` and pass to `joinOrCreate({session_token})`.

---

### `apps/client/src/net/protocol-version-check.ts` (mirror of server validateAuthFrame)

**Analog:** `apps/server/src/RebnoRoom.ts` lines 178-188 (the server-side reject path).

**Server side** (`RebnoRoom.ts:181-186`):
```ts
if (v.error === 'PROTOCOL_VERSION_MISMATCH') {
  throw new ServerError(
    4400,
    `PROTOCOL_VERSION_MISMATCH expected=${v.expected} got=${v.got}`,
  );
}
```

**Client mirror** — listen for the close code 4400 on `joinOrCreate` rejection:
```ts
try {
  await client.joinOrCreate('rebno', { protocol_version: PROTOCOL_VERSION, session_token });
} catch (e: any) {
  // Colyseus surfaces ServerError as { code, message }
  if (e?.code === 4400 && /PROTOCOL_VERSION_MISMATCH/.test(e.message ?? '')) {
    showError('Client out of date — please reload to download the latest version.');
    return;
  }
  throw e;
}
```

The constant `PROTOCOL_VERSION` ships from `@rebno/protocol` (see `packages/protocol/src/version.ts` line 10) — bumped to `2` in Phase 6 D-08.

---

### `apps/client/src/prediction/predictor.ts` (client-side step loop)

**Analog:** `apps/server/src/RebnoRoom.ts` lines 373-418 (`tickLoop` + `toWorldState` + `applyToColyseusState`). The server is the *exact* reference implementation — client wraps the same `step()` for prediction.

**Tick loop pattern** (RebnoRoom.ts:373-382):
```ts
private tickLoop(realDt: number): void {
  const { newAcc, ticks } = advanceAccumulator(this.accumulator, realDt);
  this.accumulator = newAcc;
  for (let i = 0; i < ticks; i++) {
    const inputs = new Map(this.inputBuffer);
    this.inputBuffer.clear();
    const next = step(this.toWorldState(), inputs, TICK_MS);
    this.applyToColyseusState(next);
  }
}
```

**Reusable accumulator** (RebnoRoom.ts:55-67):
```ts
export function advanceAccumulator(
  acc: number, realDt: number, tickMs: number = TICK_MS,
): { newAcc: number; ticks: number } {
  let next = acc + realDt;
  let ticks = 0;
  while (next >= tickMs) { next -= tickMs; ticks++; }
  return { newAcc: next, ticks };
}
```

**Re-export** `advanceAccumulator` from `apps/server/src/RebnoRoom.ts` is **not** workspace-public; planner can either:
- (a) copy the function verbatim into `apps/client/src/prediction/predictor.ts` (small, pure)
- (b) hoist it into `packages/game-logic/src/index.ts` (purest fix; both server + client import from there) — **preferred**

`step()` itself (`packages/game-logic/src/step.ts`) is already workspace-published and lint-purity-enforced (`tools/scripts/lint-game-logic-purity.mjs`) — safe to call in browser.

**TICK_MS constant** (RebnoRoom.ts:40): `export const TICK_MS = 50; // 20 Hz`. Mirror exactly on the client; the prediction loop runs at the same fixed timestep so server/client step output is bit-identical for the same inputs.

---

### `apps/client/src/prediction/extrapolator.ts` (D-11 remote extrapolation)

**Analog:** `packages/game-logic/src/step.ts` (lines 14-84) — re-run the SAME function for remote players using their broadcast `input_axes` (D-08). Same purity guarantees apply.

**Pattern:**
```ts
import { step, type WorldState, type InputFrame } from '@rebno/game-logic';

// When snapshot for remote player goes stale (>200 ms), advance their sim
// locally using their last broadcast input_axes (PlayerState.input_axes_x/y).
function extrapolate(remoteSnap: WorldState, remoteAxes: { x: -1|0|1; y: -1|0|1 }, dt_ms: number): WorldState {
  const inputs = new Map<string, InputFrame>();
  for (const [aid] of remoteSnap.players) {
    inputs.set(aid, { seq: 0, axis_x: remoteAxes.x, axis_y: remoteAxes.y, jump: false, action_btns: 0 });
  }
  return step(remoteSnap, inputs, dt_ms);
}
```

**Important:** D-11 caps extrapolation at 250 ms — track elapsed-since-snapshot and freeze sprite past cap.

---

### `apps/client/src/assets/room-verify.ts` (Ed25519 signature verify)

**Analog:** `tools/scripts/lint-room-layout.mjs` lines 109-119 — exact verify pattern.

**Server-side emit shape** (`apps/server/src/RebnoRoom.ts` lines 137-146):
```ts
const evt: S2C = {
  type: 'room_layout',
  room_id,
  layout_rev: layout.layout_rev,
  layout_bytes: layout.layoutBytes,
  manifest_sig: layout.manifest_sig,
};
this.broadcast('s2c', encodeS2C(evt));
```

**Verify pattern** (lint-room-layout.mjs:111-119, adapt to browser `crypto.subtle`):
```ts
// Browser equivalent — use SubtleCrypto Ed25519 (Chrome 113+; CLI-08 targets Chrome desktop)
const pubKey = await crypto.subtle.importKey(
  'spki',
  pemToSpki(import.meta.env.VITE_ROOM_SIGNING_PUBKEY),
  { name: 'Ed25519' },
  false,
  ['verify'],
);
const sha256 = await crypto.subtle.digest('SHA-256', layoutBytes);
const payload = concatBytes(textEncode(roomId), textEncode(layoutRev), new Uint8Array(sha256));
const ok = await crypto.subtle.verify('Ed25519', pubKey, manifestSig, payload);
if (!ok) throw new Error('room_layout signature INVALID');
```

The Node-side payload construction at `lint-room-layout.mjs:111-117` is the **canonical concatenation** — copy byte-for-byte:
```js
const payload = Buffer.concat([
  Buffer.from(entry, 'utf-8'),                                              // room_id
  Buffer.from(rev, 'utf-8'),                                                // layout_rev
  createHash('sha256').update(Buffer.from(json, 'utf-8')).digest(),         // sha256(layoutBytes)
]);
```

Pubkey extraction ritual (Phase 5 D-19): `apps/client/.env.staging` carries `VITE_ROOM_SIGNING_PUBKEY=<PEM-or-SPKI-base64>` extracted via `fly ssh` against the running staging app at build time.

---

### `apps/client/test/cli-08.e2e.test.ts` (Playwright two-client smoke)

**Analog:** `apps/server/test/authority.integ.test.ts` lines 36-105 (multi-client `joinClient` pattern) + `scripts/soak-staging.mjs` lines 195-217 (alice/bob driving loop). Playwright wraps these in a browser context.

**Pattern shape:**
```ts
// [int->REQ-CLI-08]
import { test, expect } from '@playwright/test';

test('CLI-08 — two clients see each other move + chat round-trip', async ({ browser }) => {
  const ctxA = await browser.newContext();
  const ctxB = await browser.newContext();
  const pageA = await ctxA.newPage();
  const pageB = await ctxB.newPage();

  await pageA.goto('/');
  await pageA.fill('[name=username]', process.env.UAT_ACCOUNT_A!);
  await pageA.fill('[name=password]', process.env.UAT_PASSWORD_A!);
  await pageA.click('[type=submit]');
  // ... same for B
  await Promise.all([
    pageA.waitForFunction(() => (window as any).rebno?.scene?.key === 'GameScene'),
    pageB.waitForFunction(() => (window as any).rebno?.scene?.key === 'GameScene'),
  ]);

  // Drive WASD on A
  await pageA.keyboard.down('d');
  await pageA.waitForTimeout(500);
  await pageA.keyboard.up('d');

  // Assert B sees A's nameplate at the new position
  const bSeesA = await pageB.evaluate(() => (window as any).rebno?.remotePlayers?.length > 0);
  expect(bSeesA).toBe(true);

  // Chat round-trip
  await pageA.keyboard.press('Enter');
  await pageA.keyboard.type('hello from A');
  await pageA.keyboard.press('Enter');
  await expect(pageB.locator('text=hello from A')).toBeVisible({ timeout: 5_000 });
});
```

**Account seeding ritual** (D-19): `pnpm migrate:legacy-accounts` against staging seeds 2 accts; Playwright reads from `process.env.UAT_ACCOUNT_A` / `_B`. The mechanism mirrors `apps/server/scripts/migrate-legacy-accounts.ts`.

---

### `tools/asset-pipeline/cli.ts` (CLI dispatcher)

**Analog:** `tools/asset-catalog/cli.ts` (lines 1-90) — exact shape, including exit codes 0/1/2 contract.

**Imports + dispatcher pattern** (lines 1-25):
```ts
#!/usr/bin/env node
// tools/asset-pipeline/cli.ts
// CLI dispatcher for `asset-pipeline`. Subcommands: bootstrap | build | help.
//
// Exit code matrix (Phase 1 D-18 contract — mirror tools/asset-catalog/cli.ts):
//   0  success
//   1  functional failure
//   2  usage error

import { fileURLToPath } from 'node:url';
import { runBootstrap } from './src/bootstrap.js';
import { runBuild } from './src/build.js';

function printUsage(): void {
  process.stderr.write('Usage:\n');
  process.stderr.write('  asset-pipeline bootstrap <extracted-dir> <source-out-dir>\n');
  process.stderr.write('  asset-pipeline build <source-dir> <atlas-out-dir>\n');
}

export async function main(argv: string[]): Promise<number> {
  const [, , cmd, ...rest] = argv;
  if (!cmd) { printUsage(); return 2; }
  switch (cmd) {
    case 'bootstrap': { /* args check; runBootstrap; return 0/1 */ }
    case 'build': { /* args check; runBuild; return 0/1 */ }
    case 'help': case '--help': { printUsage(); return 0; }
    default: { printUsage(); return 2; }
  }
}

const isMain = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) main(process.argv).then((c) => process.exit(c));
```

**Sharp BMP→PNG decode** (analog: `tools/extract-gmd/package.json` already declares `sharp: 0.34.5`):
```ts
import sharp from 'sharp';
const img = sharp(bmpPath, { failOn: 'error' });
const { data, info } = await img.png({ compressionLevel: 9 }).toBuffer({ resolveWithObject: true });
```

Pin sharp to the same version as `tools/extract-gmd` (0.34.5) for determinism (Phase 1 D-15 reproducibility-first).

---

### `tools/asset-pipeline/package.json`

**Analog:** `tools/extract-gmd/package.json` (lines 1-25) + `tools/asset-catalog/package.json` (lines 1-23). Identical shape.

```jsonc
{
  "name": "asset-pipeline",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Phase 6 AST-01 — bootstrap + build asset atlases for apps/client. Decodes extracted/client-5-8/sprites/ via sharp, emits Aseprite-export-compatible PNG strips + JSON sidecars + multi-sprite atlas + pipeline-manifest.json. STANDALONE — Phase 1 D-17 boundary.",
  "bin": { "asset-pipeline": "./cli.ts" },
  "scripts": {
    "bootstrap": "tsx cli.ts bootstrap",
    "build": "tsx cli.ts build",
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "sharp": "0.34.5"
  },
  "devDependencies": {
    "typescript": "5.6.3",
    "tsx": "4.21.0",
    "vitest": "4.1.5",
    "@types/node": "25.6.0"
  }
}
```

**Critical:** NO `"@rebno/*": "workspace:*"` deps — this tool lives outside the workspace per `pnpm-workspace.yaml` exclusion.

---

### `tools/scripts/lint-asset-pipeline.mjs`

**Analog:** `tools/scripts/lint-room-layout.mjs` (lines 1-131). Same shape: Node script, regex/JSON validation against committed tree, exit 0/1.

**Pattern (manifest schema + atlas-integrity hash check):**
```js
#!/usr/bin/env node
// tools/scripts/lint-asset-pipeline.mjs
// Source: 06-CONTEXT.md D-13/D-14/D-16 — drift guard for tools/asset-pipeline outputs.
// Walks tools/asset-pipeline/output/pipeline-manifest.json, validates schema,
// re-hashes referenced assets in apps/client/dist/ (or assets/source/), confirms sha256 match.
//
// Usage: node tools/scripts/lint-asset-pipeline.mjs
// Exit: 0 clean, 1 violation.

import { readFileSync, existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';

const MANIFEST = 'tools/asset-pipeline/output/pipeline-manifest.json';
if (!existsSync(MANIFEST)) {
  console.log('lint-asset-pipeline: manifest absent — acceptable for fresh checkout');
  process.exit(0);
}

let violations = 0;
const fail = (m) => { process.stderr.write(`lint-asset-pipeline: ${m}\n`); violations++; };

const m = JSON.parse(readFileSync(MANIFEST, 'utf-8'));
// Required keys per D-14 schema
for (const k of ['version', 'sprites', 'atlas_ref', 'background_ref']) {
  if (!(k in m)) fail(`missing key '${k}'`);
}
// Per-sprite integrity
for (const [id, meta] of Object.entries(m.sprites ?? {})) {
  for (const k of ['frame_count', 'origin', 'sha256', 'atlas_ref']) {
    if (!(k in meta)) fail(`sprites.${id}: missing key '${k}'`);
  }
  // Re-hash actual file
  // ...
}

if (violations > 0) process.exit(1);
console.log('lint-asset-pipeline: OK');
```

---

### `tools/scripts/lint-vite-env.mjs`

**Analog:** `tools/scripts/lint-deploy-stack.mjs` (lines 1-110) — regex-based config drift guard with `must(cond, msg)` helper.

**Pattern:**
```js
#!/usr/bin/env node
// tools/scripts/lint-vite-env.mjs
// Source: 06-CONTEXT.md D-18 + Phase 5 D-19 carryover.
// Asserts apps/client/.env.staging and .env.prod carry required VITE_* keys
// AND do NOT carry secrets (Better-Auth secret, room signing PRIVATE key, etc.).
//
// Usage: node tools/scripts/lint-vite-env.mjs
// Exit: 0 in-sync, 1 drift detected.

import { readFileSync } from 'node:fs';

const errors = [];
const must = (cond, msg) => { if (!cond) errors.push(msg); };

const REQUIRED_KEYS = ['VITE_WSS_URL', 'VITE_HTTP_BASE', 'VITE_ROOM_SIGNING_PUBKEY'];
const FORBIDDEN_PATTERNS = [
  /BETTER_AUTH_SECRET/,
  /ROOM_SIGNING_PRIVATE_KEY/,
  /STAGING_INVITE_TOKEN/,         // server-only; client passes via URL/UI not bundle
  /AKIA[0-9A-Z]{16}/,             // AWS access key
];

for (const f of ['apps/client/.env.staging', 'apps/client/.env.prod']) {
  const body = readFileSync(f, 'utf-8');
  for (const k of REQUIRED_KEYS) must(new RegExp(`^${k}=`, 'm').test(body), `${f}: missing ${k}`);
  for (const re of FORBIDDEN_PATTERNS) must(!re.test(body), `${f}: forbidden secret pattern ${re}`);
}
// Staging-specific
const staging = readFileSync('apps/client/.env.staging', 'utf-8');
must(/VITE_STAGING_MODE\s*=\s*1/.test(staging), 'apps/client/.env.staging: must set VITE_STAGING_MODE=1');

if (errors.length) { for (const e of errors) console.error('lint-vite-env:', e); process.exit(1); }
console.log('lint-vite-env: OK');
```

---

### `scripts/verify-phase-6.mjs` (composite gate)

**Analog:** `scripts/verify-phase-5.mjs` (lines 1-61) and `scripts/verify-phase-4.mjs` (lines 1-75). Identical shape — sequential `spawnSync` orchestrator, first non-zero exit fails, `trace:check` last.

**Pattern (mirror exactly, adjust steps):**
```js
#!/usr/bin/env node
// scripts/verify-phase-6.mjs
// [doc->REQ-CLI-08]
// Source: Phase 6 CLI-01..09 + AST-01 composite verify gate.

import { spawnSync } from 'node:child_process';
const isWindows = process.platform === 'win32';

const steps = [
  ['Phase 5 carry-over: verify-phase-5',     'pnpm', ['verify:phase-5']],
  ['Workspace: typecheck',                   'pnpm', ['-r', 'typecheck']],
  ['Lint: vite-env',                         'pnpm', ['lint:vite-env']],
  ['Lint: asset-pipeline',                   'pnpm', ['lint:asset-pipeline']],
  ['Asset-pipeline: build',                  'pnpm', ['asset-pipeline:build']],
  ['Client: build (staging)',                'pnpm', ['--filter', '@rebno/client', 'build:staging']],
  ['Workspace: test',                        'pnpm', ['-r', 'test']],
  ['Client: e2e (cli-08 smoke, local server)', 'pnpm', ['--filter', '@rebno/client', 'test:e2e']],
  ['Traceable-reqs: check',                  'pnpm', ['trace:check']],
];

let failed = null;
for (const [label, cmd, args] of steps) {
  process.stdout.write(`\n=== ${label} ===\n>>> ${cmd} ${args.join(' ')}\n`);
  const r = spawnSync(cmd, args, { encoding: 'utf-8', shell: isWindows, stdio: 'inherit' });
  if (r.status !== 0) { failed = { label, cmd, args, status: r.status }; break; }
}

if (failed) {
  process.stderr.write(`\nverify-phase-6 FAILED at step '${failed.label}'\n`);
  process.exit(1);
}
process.stdout.write(`\nverify-phase-6: OK (${steps.length} steps green)\n`);
process.exit(0);
```

**Order rationale carries over** from Phase 4/5: cheap lints first, test suite second-last, `trace:check` anchored last per CLAUDE.md hard rule #12.

---

### `apps/server/src/index.ts` (D-18 static mount — modify in place)

**Analog:** Self — current Express middleware ordering (lines 99-260).

**Current ordering** (index.ts:99-260):
1. `app.use(makeIpAllowlist(process.env))` (line 104) — outer Fly proxy IP gate
2. `app.use(makeStagingInvite(process.env))` (line 108) — staging token gate
3. `app.use(createNodeMatchmakingMiddleware())` (line 115) — Colyseus HTTP matchmaking, MUST be before `express.json()`
4. CORS gate on `/api/auth/*` (lines 126-154)
5. Legacy login pre-middleware on `/api/auth/sign-in/email` (lines 177-255) — `express.json()` mounted INLINE for this single route
6. `app.all('/api/auth/*', toNodeHandler(auth))` (line 259) — Better-Auth catch-all
7. `app.use(express.json())` (line 261) — global body parser, AFTER Better-Auth
8. `/health` handler (line 286)

**Insert point for D-18 static** — between (7) `express.json()` and (8) `/health`:

```ts
// [impl->REQ-CLI-01] Phase 6 D-18 — same-Fly-app hosting; serve apps/client/dist
// AFTER /api/auth/* and BEFORE colyseus.listen (matchmaking middleware already
// mounted upstream so it claims /matchmake/* first). This preserves all the
// upstream guards (IP allowlist, staging-invite, CORS-on-auth) and keeps the
// WS upgrade path (handled by httpServer.upgrade event in WebSocketTransport)
// reachable independently — Express never sees the WS handshake.
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const CLIENT_DIST = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  '../public',  // OR '../dist/client/' — planner picks per D-18
);
app.use(express.static(CLIENT_DIST, {
  fallthrough: true,
  immutable: true,
  maxAge: '1y',  // Vite hashed assets are content-addressed
}));
// SPA fallback — index.html for any non-API non-static route
app.get('*', (req, res, next) => {
  if (req.path.startsWith('/api/') || req.path.startsWith('/matchmake/') || req.path === '/health') return next();
  res.sendFile(path.join(CLIENT_DIST, 'index.html'));
});
```

**CSP headers** (D-CSP from CONTEXT) — add via Express helmet-style middleware before static:
```ts
app.use((_req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:",
  );
  next();
});
```

---

### `apps/server/src/RebnoRoom.ts` (D-07 + D-08 + D-09 patches)

**Analog:** Self — modify `onJoin` (lines 244-280), `tickLoop` (lines 373-382), `applyToColyseusState` (lines 407-418).

**D-07 home-portal spawn** — modify `onJoin` (lines 244-280):
- Current: hardcodes `player.x = 100; player.y = 100;` (lines 252-253)
- New: read home portal coords from registry's loaded layout (`spawn_points[0]` where `kind === 'default'`); ignore `characters.x/y` entirely on a NEW Better-Auth session
- Signal "new session vs reconnect" by checking whether `client.sessionId` was preserved across `allowReconnection` (existing behavior at line 295) — the grace-window rejoin reuses sessionId, so any onJoin where sessionId is FRESH = new session = home-portal spawn

**D-08 input_axes broadcast** — modify `applyToColyseusState` (lines 407-418):
```ts
private applyToColyseusState(next: WorldState): void {
  this.state.rev = next.rev;
  for (const [, p] of this.state.players) {
    const sim = next.players.get(p.account_id);
    if (!sim) continue;
    p.x = sim.x; p.y = sim.y; p.vx = sim.vx; p.vy = sim.vy;
    p.last_input_seq = sim.last_input_seq;
    // D-08: broadcast last-known held axes for client extrapolation
    const heldInput = this.heldInputs.get(p.account_id);
    if (heldInput) { p.axis_x_held = heldInput.x; p.axis_y_held = heldInput.y; }
  }
}
```
Add `private heldInputs = new Map<string, { x: -1|0|1; y: -1|0|1 }>();` field.

**D-09 event-driven input** — modify `tickLoop` and the `c2s.input` handler in `onMessageHandlers.ts`:
- New input handler stores axes into `heldInputs` (not the per-tick `inputBuffer`)
- `tickLoop` reads from `heldInputs` each tick (not from `inputBuffer.clear()` after consume)

---

### `packages/protocol/src/state.ts` (D-08 schema bump)

**Analog:** Self — current `PlayerState` class (lines 9-24).

**Current shape** (state.ts:9-24):
```ts
export class PlayerState extends Schema {
  @type('string') account_id: string = '';
  @type('string') name: string = '';
  @type('number') room_id: number = 0;
  @type('number') x: number = 0;
  @type('number') y: number = 0;
  @type('number') vx: number = 0;
  @type('number') vy: number = 0;
  @type('number') sprite_id: number = 0;
  @type('number') last_input_seq: number = 0;
  @type('boolean') muted_until_password_change: boolean = false;
}
```

**New** (D-08 — add 2 fields, OR a nested `InputAxes` Schema; planner picks):
```ts
@type('number') axis_x_held: number = 0;  // -1 | 0 | 1, last broadcast input axis
@type('number') axis_y_held: number = 0;
```

**Same file, same line:** also bump `packages/protocol/src/version.ts` line 10 from `1` to `2`.

---

### `packages/protocol/src/intents.ts` (D-09 input shape change)

**Analog:** Self — current `cInputSchema` (lines 33-44).

**Current** (intents.ts:33-44):
```ts
export const cInputSchema = z.object({
  type: z.literal('input'),
  seq: z.number().int().min(0),
  dt_ms: z.number().int().min(0).max(1000),
  axis_x: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
  axis_y: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
  jump: z.boolean(),
  action_btns: z.number().int().min(0).max(0xffff),
}).strict();
```

**New** (D-09 event-driven shape):
```ts
export const cInputSchema = z.object({
  type: z.literal('input'),
  seq: z.number().int().min(0),
  axes: z.object({
    x: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
    y: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
  }),
  buttons_down: z.number().int().min(0).max(0xffff),
  buttons_up: z.number().int().min(0).max(0xffff),
  monotonic_at_ms: z.number().nonnegative(),
}).strict();
```

`.strict()` is **mandatory** per project-wide convention (intents.ts:20-24) — wire-fabricated identity fields are rejected at parse time.

---

## Shared Patterns (cross-cutting)

### S-01: Tag every artifact with traceable-reqs

**Source:** Project-wide convention (`CLAUDE.md` §"Tagging contract"); seen on every Phase-4/5 file.

**Apply to:** Every new TS file, MD doc, test file, and lint script.

**Concrete examples:**
```ts
// [impl->REQ-CLI-04] [impl->REQ-CLI-06]
// at top of apps/client/src/prediction/predictor.ts
```
```ts
// [int->REQ-CLI-08]
// at top of apps/client/test/cli-08.e2e.test.ts
```
```js
// [doc->REQ-CLI-08]
// at top of scripts/verify-phase-6.mjs
```
```md
[doc->REQ-AST-01]
// at top of 06-HUMAN-UAT.md
```

`required_stages` for each Phase-6 REQ-* lives in `traceable-reqs.toml`; planner sets `impl`/`unit`/`int` per req at plan time per CLAUDE.md.

### S-02: zod `.strict()` on every wire schema

**Source:** `packages/protocol/src/intents.ts` lines 20-24, applied to every c2s schema.

**Apply to:** Any new wire surface in Phase 6 (D-08/D-09 amendments). Forged identity fields (`account_id`, `pid`, `sender_account_id`, etc.) MUST be rejected at parse time — never allow `.strip()` or `.passthrough()`.

### S-03: Authority enforcement pattern (server tags identity from auth, NEVER from wire)

**Source:** `apps/server/src/onMessageHandlers.ts` lines 92-122 — `cInputSchema` does not include `account_id`; the handler tags from `client.auth.account_id` populated by `onAuth`.

**Apply to:** Phase 6 D-09 input handler patch. Even though the wire shape changes, the identity-tagging contract MUST NOT.

### S-04: Cheap-path-first ordering (rate-limit before zod parse)

**Source:** `apps/server/src/onMessageHandlers.ts` lines 56-84 (`rateLimitOrDrop`) called BEFORE `cInputSchema.safeParse(raw)` (line 101).

**Apply to:** Any new server handler in Phase 6 (e.g., if D-09 adds a heartbeat-coupled "still-pressed" reaffirmation, run rate-limit gate before parse).

### S-05: Composite verify-phase-N script with `trace:check` last

**Source:** `scripts/verify-phase-4.mjs` lines 30-53 + `scripts/verify-phase-5.mjs` lines 30-39.

**Apply to:** `scripts/verify-phase-6.mjs`. Order: prior-phase carry-over → typecheck → cheap regex lints → drizzle/build/asset-pipeline gates → workspace test → e2e → `trace:check`.

### S-06: Lint script shape (`tools/scripts/lint-*.mjs`)

**Source:** `tools/scripts/lint-deploy-stack.mjs` (regex-based) + `tools/scripts/lint-room-layout.mjs` (filesystem walk + crypto verify) + `tools/scripts/lint-game-logic-purity.mjs` (regex denylist).

**Apply to:** `tools/scripts/lint-asset-pipeline.mjs` and `tools/scripts/lint-vite-env.mjs`. Standard contract: `node`-runnable .mjs, `must(cond, msg)` helper, `errors.length` decides exit, `console.log('<name>: OK')` on success.

### S-07: Vitest config — `pool: 'forks', maxWorkers: 1`

**Source:** `apps/server/vitest.config.ts` lines 1-13 + `tools/asset-catalog/vitest.config.ts` lines 1-13. Determinism rationale: serialised fs/auth/SIGTERM tests.

**Apply to:** `apps/client/vitest.config.ts`, `tools/asset-pipeline/vitest.config.ts`. Add `environment: 'jsdom'` for client only.

### S-08: GitHub Actions workflow shape — pnpm + Node 22 + SHA-pinned actions

**Source:** `.github/workflows/deploy-staging.yml` lines 33-94. Pattern:
```yaml
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
  with: { version: 10 }
- uses: actions/setup-node@v4
  with: { node-version: 22, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
```
**SHA-pin** non-first-party actions (e.g., `superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be` at line 60) — enforced by `lint-deploy-stack.mjs` Rule 21+22.

**Apply to:** Phase 6 extension of `deploy-staging.yml` (D-18) — new client-build step + Playwright smoke step both use this shape.

### S-09: Express middleware ordering (read this BEFORE editing index.ts)

**Source:** `apps/server/src/index.ts` lines 99-261. Mandatory order:

1. **IP allowlist** (D-04 outer gate)
2. **Staging invite** (D-04 token gate, before matchmaking middleware — Pitfall 9)
3. **Colyseus matchmaking middleware** — MUST be before `express.json()` (it reads its own body stream)
4. **CORS gate on `/api/auth/*`** (WR-06 — origin-header check)
5. **Legacy login pre-middleware on `/api/auth/sign-in/email`** with INLINE `express.json()`
6. **Better-Auth catch-all** `app.all('/api/auth/*', toNodeHandler(auth))`
7. **Global `app.use(express.json())`** — AFTER Better-Auth
8. **D-18 static mount** (NEW for Phase 6) — after json, before health
9. **Health handler** `/health`
10. **(Implicit) WS upgrade** — Colyseus's `WebSocketTransport` listens on `httpServer.upgrade` event independently of Express

**Anti-pattern:** Mounting `express.static()` before Better-Auth would cause static-route shadowing of `/api/auth/*` paths if Vite ever generates a literally-named static asset there. The order above prevents that.

### S-10: Better-Auth + Colyseus token bridge (client must consume)

**Source:** `apps/server/src/auth.ts` lines 56-58 (`bearer()` plugin) + `apps/server/src/RebnoRoom.ts` lines 219-228 (server-side bearer token verify).

**Server-side flow** (RebnoRoom.ts:219-228):
```ts
const session = await this.auth.api.getSession({
  headers: new Headers({ Authorization: `Bearer ${session_token}` }),
});
if (!session?.user) throw new ServerError(4401, 'invalid_session');
```

**Client-side bridge** (Phase 6 must implement):
1. Login: `authClient.signIn.email({email, password})` → cookie set; `authClient.getSession()` returns `{session: {token}}`
2. Open Colyseus room: `client.joinOrCreate('rebno', { protocol_version: PROTOCOL_VERSION, session_token: session.token, invite: stagingInvite })`
3. Server's `RebnoRoom.onAuth` extracts `session_token` from `cAuthSchema`, calls `auth.api.getSession({ headers: { Authorization: 'Bearer ' + token }})` to validate

**Force-reset path** (D-03): server emits `s2c.force_password_change` (RebnoRoom.ts lines 273-279) on `onJoin` if `auth.force_reset === true`. Client listens for that S2C event and opens the change-password overlay; calls `authClient.changePassword({oldPassword, newPassword})` (Better-Auth client SDK); on 200, server's `account.password` is rehashed and `user.force_reset` cleared (Phase 4 D-08); state-diff broadcasts `muted_until_password_change = false` (`packages/protocol/src/state.ts` line 23) and overlay closes.

### S-11: msgpackr encode/decode boundary normalisation

**Source:** `packages/protocol/src/events.ts` lines 56-78 — `decodeS2C` normalises Node `Buffer` to plain `Uint8Array` so the wire shape is identical Node + browser.

**Apply to:** Phase 6 client decoding. Use `decodeS2C(rawUint8Array)` from `@rebno/protocol`. Do NOT instantiate a new msgpackr `Unpackr` — re-use the singleton.

---

## No Analog Found

These files have no close match in the codebase; planner should use RESEARCH.md patterns + external library docs (CONTEXT canonical-refs).

| File | Role | Data Flow | Reason | Substitute Reference |
|------|------|-----------|--------|----------------------|
| `apps/client/vite.config.ts` | config | n/a | First Vite app in repo | Vite 8 docs (CONTEXT canonical-refs); env-mode pattern via `--mode staging|production`; Phaser as external requires no special handling — just default Vite + TS template |
| `apps/client/playwright.config.ts` | e2e config | n/a | First Playwright config | Playwright `@playwright/test` docs; multi-context pattern in CONTEXT D-19; `webServer` field for local apps/server boot during offline runs |
| `apps/client/index.html` | static shell | n/a | First Vite scaffold | Vite default scaffold; `<div id="game-root"></div>` + `<script type="module" src="/src/main.ts"></script>` |
| `apps/client/src/scenes/BootScene.ts` | scene | request-response | First Phaser scene; no in-repo precedent | Phaser 3.90 Scene lifecycle docs; CONTEXT D-02 (autologin → 500ms spinner → transition) |
| `apps/client/src/prediction/reconciler.ts` | prediction | streaming | D-10 threshold-gated lerp/snap is novel; no analog | Source/Overwatch netcode reference (CONTEXT specifics §D-10); Phaser `Tweens` for the ~100ms lerp; tile size 44×40 → threshold candidate ~22 px |
| `apps/client/src/ui/chat-overlay.ts` | ui | event-driven | First DOM-overlay UI in repo | CONTEXT D-04 Minecraft-pattern spec; HTML+CSS via DOM (NOT Phaser canvas text) so input field steals focus from Phaser keyboard plugin |
| `apps/client/src/ui/nameplate.ts` | ui | event-driven | First always-on label widget | CONTEXT D-05 — Phaser canvas text vs DOM overlay (planner picks); see `Phaser.GameObjects.Text` docs |
| `apps/client/src/assets/atlas-loader.ts` | asset | file-I/O | First Phaser atlas loader; depends on D-16 hybrid manifest | Phaser `this.load.atlas('mvp', atlasJsonPath, atlasPngPath)` pattern; manifest format from `tools/asset-pipeline` (D-14/D-16) — fetch `pipeline-manifest.json` first, resolve atlas paths, then `this.load.atlas` |
| `tools/asset-pipeline/src/build.ts` (atlas pack) | utility | transform | Atlas-packing algorithm is new | `free-tex-packer` / `maxrects-packer` (CONTEXT external refs); 512×512 target with 1024×1024 fallback (D-17) |

---

## Metadata

- **Analog search scope:** `apps/server/`, `apps/server/test/`, `packages/protocol/`, `packages/game-logic/`, `packages/db/`, `tools/asset-catalog/`, `tools/extract-gmd/`, `tools/scripts/`, `scripts/`, `.github/workflows/`
- **Files scanned (read in full or targeted):** ~25 source/config files
- **Pattern extraction date:** 2026-05-09
- **Stale codebase docs noted:** `.planning/codebase/STRUCTURE.md` and `.planning/codebase/CONVENTIONS.md` are dated 2026-05-01 and predate Phase 4 — they document the legacy archive only. The actual `apps/`, `packages/`, `tools/`, and `scripts/` trees were inspected directly via Glob + targeted Read.

---

## PATTERN MAPPING COMPLETE
