# Phase 4: Server Rebuild (MVP) - Context

**Gathered:** 2026-05-05
**Status:** Ready for planning
**Mode:** Claude's discretion across all 4 surfaced gray areas with explicit user steer: "modern best practices + frictionless UX; server-side rooms must support edit-and-reload at runtime without restarting the server."

<domain>
## Phase Boundary

Stand up the Node 22 + TypeScript + Colyseus 0.17.10 authoritative server for the movement+chat MVP slice (CLI-08 dependency) — three first-party workspace packages (`packages/protocol`, `packages/game-logic`, `packages/db`) plus the `apps/server` runtime, consuming Phase 3 artifacts (`tools/protocol-doc/output/protocol.ts`, `tools/save-format-doc/output/save-formats.ts`, `packages/db/tables.ts` Drizzle baseline, `parity-checklist.json` mvp-true rows, ADR 0002 + 0003) without re-deriving them.

In scope:
- **pnpm workspaces bootstrap** — `pnpm-workspace.yaml`, `apps/*` + `packages/*` recipe, `tsc --build` references, root scripts (`pnpm -r build|typecheck|test`, `pnpm verify:phase-4` composite gate).
- **`packages/protocol`** — Colyseus Schema classes (state-diff sync), zod intent schemas (c2s validation), msgpackr-encoded event types (s2c one-shot), `PROTOCOL_VERSION` constant, build-time copy of `tools/protocol-doc/output/protocol.ts` re-exports (drift-guarded by lint).
- **`packages/game-logic`** — pure `step(state, inputs, dt) → state` for movement + collision + room model; runnable in Node + browser; lint-enforced purity (no `Date.now`, no `Math.random`, no I/O); golden-trajectory + input-replay determinism tests.
- **`packages/db`** — Phase 3 Drizzle baseline extended with Better-Auth tables (session/account/verification) generated via `@better-auth/cli`.
- **`apps/server`** — Colyseus boot sequence, single `RebnoRoom` class, 20 Hz fixed-timestep accumulator tick loop, 15 s heartbeat / 10 s reconnection grace, per-account-per-message-type token-bucket rate limiter, SIGTERM grace handler, structured pino JSON logs.
- **Auth integration** — Better-Auth 1.6.9 + argon2id mounted on Express side; HTTP `/api/auth/*` then WS upgrade with session token; legacy_credentials_staging custom provider hook for SRV-10/11; force-reset flow for plaintext/bcrypt-weak users.
- **Room subsystem** — `RoomRegistry` with content-addressed `apps/server/rooms/<room_id>/<layout_rev>.json` layouts, Ed25519-signed manifests, fs.watch-driven **hot-reload mid-session** (no server restart on layout edit), single MVP room (`mvp-lobby`) seeded.
- **`tools/room-converter/`** — new TS Node CLI converting extracted GM5 rooms → canonical layout JSON + signed manifest (build-time, not runtime).
- **MVP message set** — login, room-join, room-leave, move-input, position-state-diff, chat-send/broadcast, heartbeat, room_layout, error, force_password_change. All other opcodes from `parity-checklist.json` are `in-phase-7` and only stubbed for SRV-12 anti-port reference.
- **One-shot CLI** — `pnpm migrate:legacy-accounts` reads `legacy/servers/enlyzeam-current/localList.txt` once during initial deploy and writes `legacy_credentials_staging` rows.
- **Lints** — `lint-protocol-sync.mjs`, `lint-game-logic-purity.mjs`, `lint-room-layout.mjs`, all wired into `pnpm verify:phase-4`.

Out of scope (belongs in later phases):
- `apps/client` — Phase 6.
- Vite + Phaser scaffolding — Phase 6.
- Asset pipeline (BMP→PNG, MIDI→OGG, font→WOFF2) — Phase 6 (AST-01) / Phase 7 (AST-02..04).
- Fly.io deploy, Litestream sidecar config, GitHub Actions, fly.toml, RESTORE.md — Phase 5 (DEP-01..08).
- Modernized admin web UI — Phase 7 PAR-07.
- Full chat surface (whispers, channels, rate-limit-extra, ignore/block, profanity, history rolling buffer) — Phase 7 PAR-04.
- `.bnu` character migration — Phase 7 PAR-05 (Phase 4 only ports the auth + position slice).
- Full room set + pixel-diff verification — Phase 7 PAR-03 (Phase 4 ships exactly one MVP room through the SRV-13 contract).
- Multi-region Fly deployment, Postgres migration, Redis matchmaker — v2 (OPS-01..03).

</domain>

<decisions>
## Implementation Decisions

### Wire / state model split (D-01..D-04)

- **D-01 (state-diff via @colyseus/schema 4.0.21):** Server-authoritative world state syncs via Colyseus's built-in delta encoder. Room schema = `RoomState { rev: number, players: MapSchema<PlayerState>, platforms: MapSchema<PlatformState> }`. `PlayerState { account_id, room_id, x, y, vx, vy, sprite_id, last_input_seq, name }`. `PlatformState { id, x, y, vx, vy }` — present in the schema from Phase 4 even if MVP-room ships zero platforms, so SRV-14 protocol shape is exercised before Phase 7 platform-bearing rooms land. `last_input_seq` echoes the client's most recent acknowledged input frame (input acking for client reconciliation in Phase 6).
- **D-02 (intents + one-shot events via msgpackr 1.11.10):** Anything outside continuous state-diff sync — client→server intent messages and server→client one-shot events — uses msgpackr. C2S types: `c2s.auth { protocol_version, session_token }`, `c2s.input { seq, dt_ms, axis_x, axis_y, jump, action_btns }`, `c2s.chat_send { text }`, `c2s.room_join { room_id }`, `c2s.heartbeat`. S2C types: `s2c.chat_msg { sender_account_id, sender_name, text, ts }`, `s2c.room_layout { room_id, layout_rev, layout_bytes, manifest_sig }`, `s2c.error { code, msg }`, `s2c.force_password_change { reason }`, `s2c.input_ack { seq }` (alternative to embedding in PlayerState if it bloats the schema). Chat history is **not** in `RoomState` — it's a per-room rolling in-memory buffer (size 100); on `onJoin`, server sends last-N as a burst of `s2c.chat_msg`.
- **D-03 (PROTOCOL_VERSION enforcement):** `PROTOCOL_VERSION` (uint16 constant exported from `packages/protocol`) is the first field of `c2s.auth` (the very first message). Mismatch → server emits `s2c.error { code: 'PROTOCOL_VERSION_MISMATCH', msg: 'expected X, got Y' }` then closes the WS. Subsequent messages do not repeat the version (saves bytes). Bumping `PROTOCOL_VERSION` is a Phase 4 deploy-coordination ritual: bump in `packages/protocol`, redeploy server before client.
- **D-04 (zod validation at the room boundary):** Every C2S payload is validated by zod 3.x in the Colyseus `onMessage` handler **before** reaching `packages/game-logic` — never trust the wire (PITFALLS B1). Zod schemas live in `packages/protocol/src/intents.ts` and are re-exported for client consumption (Phase 6 reuses for type-safe send paths). Validation failure → drop + `pino.warn { invalid_intent, account_id, msg_type, zod_issue }`; repeated failures (>10/min) → temporary mute via the rate-limiter (D-22).

### Auth + Colyseus integration (D-05..D-08)

- **D-05 (HTTP login → WS upgrade with session token):** Better-Auth 1.6.9 mounts at `apps/server` Express side at `/api/auth/*` (sign-in, sign-up, sign-out, change-password, me). `POST /api/auth/sign-in/email { username, password }` returns `{ session_token, expires_at, must_force_reset }` and sets the cookie session (`httpOnly`, `Secure`, `SameSite=Lax`). Client stores `session_token` in memory (not localStorage — frictionless reconnect from cookie even if memory cleared).
- **D-06 (Colyseus `onAuth` reads session token):** Client opens `wss://server/colyseus?token=<bearer>`. Colyseus `onAuth(client, options, request)` reads `options.token` (Colyseus convention), calls Better-Auth `getSession(token)` against the SQLite-backed Better-Auth session store, and returns `{ account_id, username, role }` on success or throws `ServerError(4401, 'invalid_session')` on invalid/expired. `onJoin` receives the auth payload as `client.auth` — never re-derive identity from client-supplied data again.
- **D-07 (frictionless UX):** Sessions valid 30 days sliding-window, refreshed on every WS heartbeat-ack (extends `expires_at`). Reconnect within 10 s grace (SRV-06) reuses the same session token via Colyseus `allowReconnection`; > 10 s reconnect re-authenticates silently if the cookie still authorises (no login screen flash). Force-reset users (legacy plaintext / bcrypt-weak): server emits `s2c.force_password_change { reason }` immediately after `onJoin`; the room transitions client to a password-change overlay; user posts new pw to `/api/auth/change-password`; server clears `accounts.force_reset`; normal flow resumes without a disconnect-reconnect cycle.
- **D-08 (legacy_credentials_staging hook):** Better-Auth schema generated via `pnpm exec @better-auth/cli generate --output packages/db/auth-tables.ts` and merged into the Drizzle baseline (`accounts` table from Phase 3 D-13 IS the user table; Better-Auth's `session`, `account`, `verification` tables added alongside). Custom Better-Auth `password` provider hook intercepts sign-in: if `accounts` row absent but `legacy_credentials_staging` has the username, validate the supplied password against `legacy_hash` using `algorithm` (`plaintext` | `bcrypt-weak` | `bcrypt`), and on success — argon2id-rehash + `INSERT INTO accounts` + `DELETE FROM legacy_credentials_staging WHERE username=?` in one Drizzle transaction (SRV-10/11). `force_reset=1` rows set `accounts.force_reset=1` so D-07 fires the password-change overlay on first WS join.

### Room layout wire format + hot-reload (D-09..D-13) — KEY USER STEER

- **D-09 (canonical layout = content-addressed JSON on server FS):** Layouts live at `apps/server/rooms/<room_id>/<layout_rev>.json` + companion `<layout_rev>.sig`. JSON shape is the canonical room representation (`tile_grid`, `collision_polys`, `spawn_points`, `platform_defs`, `scripted_triggers`, `room_size`, `tile_atlas_ref`, `bg_atlas_ref`). Format is **derived from extracted GM5 rooms** at build-time via `tools/room-converter/` (new Phase 4 tool); the runtime input is the converted JSON, not the raw GM5 binary — keeps cold-start independent of `extracted/` and lets runtime hot-reload work without re-running the converter.
- **D-10 (hot-reload via fs.watch — explicit user requirement):** `RoomRegistry` calls `fs.watch('apps/server/rooms', { recursive: true })` at boot. On change-event for any `<layout_rev>.{json,sig}` file (debounced 200 ms to ride out atomic-rename writes): re-read both files, re-verify Ed25519 signature, validate JSON via zod, and on success — invalidate the cached layout and broadcast `s2c.room_layout` with the new rev to every connected client currently in that room. Clients re-load mid-session; **server does not restart**. Per-room state (player positions in `RoomState`) is preserved across the reload — only the layout swaps. New room files dropped into the directory create a new room id; deleted room files force any clients in that room into the lobby with an `s2c.error { code: 'ROOM_REMOVED' }` event. This is the explicit user steer.
- **D-11 (Ed25519 signed manifests):** Wire frame `s2c.room_layout { room_id: string, layout_rev: string, layout_bytes: Uint8Array (msgpackr-encoded canonical JSON), manifest_sig: Uint8Array (Ed25519(room_id || layout_rev || sha256(layout_bytes))) }`. Client verifies sig against the server's pinned Ed25519 public key (Phase 6 ships pubkey via `import.meta.env.VITE_ROOM_SIGNING_PUBKEY`, NOT in source). Server holds the private key on the Fly Volume at `/data/keys/room_signing.ed25519` — generated at first deploy if missing, loaded at boot, never logged. A tampered intermediate cannot inject a forged room (PITFALLS B1, SRV-13 acceptance).
- **D-12 (single MVP room):** `mvp-lobby` — small flat tile floor + walls + spawn point, plus one optional moving-platform stub for SRV-14 protocol-shape exercising even though MVP demonstrably ships fine without it. Source = smallest navigable room from `extracted/client-5-8/rooms/`; final pick happens during planning when `tools/room-converter` discovers room dimensions. Bundle ships zero static layout data on the client (per SRV-13 acceptance: client-side bundle has zero room layout bytes; everything arrives over the wire on `room_join`).
- **D-13 (hot-edit safety):** `tools/room-converter` exposes `pnpm room:edit <room_id>` — interactive validate-then-write cycle: read current rev, hand the JSON to the user's `$EDITOR`, validate edits via zod, write `<rev+1>.json.tmp` + `<rev+1>.sig.tmp`, atomic-rename both, increment `latest` symlink. fs.watch debounces 200 ms to avoid mid-write reads. Invalid layouts rejected at write-time without affecting the live room. Phase 7 PAR-03 (full room set) reuses this exact contract — no protocol changes, no signing changes.

### Persistence write cadence (D-14..D-17)

- **D-14 (event-driven, NOT on-tick):** Server holds player + room state in-memory; persists on these events only:
  - `accounts.last_login_at` — on successful auth (one row, one write)
  - `characters` (`x`, `y`, `room_id`, `last_saved_at`) — on room-transition + on graceful-disconnect + on every 30 s checkpoint timer (per-account, jittered ±5 s to spread fsync load) + on SIGTERM flush
  - `inventory_items` — on inventory-change events (none in MVP scope; baseline table only)
  - `audit_log` — on admin actions (none in Phase 4; Phase 7 PAR-07 writes here)
  - `sessions` (`heartbeat_at`) — on Colyseus connection + on each heartbeat-ack (every 15 s) + on disconnect
  - `message_board_*` — Phase 7 only
  No per-tick writes. Position drift between checkpoints is acceptable (≤ 30 s) because Litestream replicates the WAL frames as they're written, and a `kill -9` recovery resumes from the last checkpoint with the character record itself intact (SRV-08).
- **D-15 (SQLite pragmas):** Open with `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`. WAL mode is a Litestream prerequisite (DEP-03). `synchronous=NORMAL` (not `FULL`) accepts a tiny crash-window between fsync calls for ~3× write throughput; acceptable because Litestream is the durability tier, not per-write fsync.
- **D-16 (SIGTERM grace handler — SRV-08):** On `SIGTERM`, server (a) stops accepting new connections, (b) sends `s2c.error { code: 'SERVER_DRAINING', reconnect_after_ms: 30000 }` to active clients, (c) writes all in-memory player state to `characters` in a single Drizzle transaction, (d) `db.close()` (which fsyncs WAL), (e) gives the Litestream sidecar 2 s to flush its current WAL frame to Tigris, (f) process exit. 25 s window total (Fly's default 30 s SIGKILL grace minus 5 s safety). `kill -9` recovery: WAL mode + atomic writes survive; on next boot, server reads `characters.last_saved_at` and resumes from there.
- **D-17 (legacy account migration — one-shot):** `legacy_credentials_staging` is populated by `pnpm migrate:legacy-accounts` (in `apps/server/scripts/migrate-legacy-accounts.ts`), run **once** during initial deploy. Reads `legacy/servers/enlyzeam-current/localList.txt`, classifies each entry's algorithm (`plaintext` if no hash markers; future: `bcrypt-weak` if `$2a$` low-cost), writes staging rows. NOT run on every boot. Phase 5 RESTORE.md will document the read-once-then-purge protocol (Phase 3 D-04). Plaintext rows leave the seed snapshot in `legacy/servers/` and the staging table holds them only until first successful login per row, after which the staging row is deleted (D-08).

### Monorepo + tooling (Claude's discretion, modernize/streamline)

- **D-18 (pnpm workspaces layout):** `pnpm-workspace.yaml` lists `apps/*`, `packages/*`. Initial layout:
  - `apps/server/`
  - `packages/protocol/`
  - `packages/game-logic/`
  - `packages/db/` (Phase 3 plan 03-06 already created this — Phase 4 EXTENDS rather than recreates; Better-Auth tables added per D-08)
  - `tools/room-converter/` (new Phase 4 tool, standalone Node CLI per Phase 1 D-17)
  - Existing tools (`tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc`) remain standalone — NOT pulled into the workspace (preserves Phase 1 D-17 / Phase 2 D-15 boundary). Workspace discovery in `pnpm-workspace.yaml` excludes `tools/*`.
  All workspace packages use `workspace:*` protocol; `tsc --build` references for incremental compilation; root scripts: `pnpm -r build`, `pnpm -r typecheck`, `pnpm -r test`, `pnpm verify:phase-4` (composite gate per D-25).
- **D-19 (`packages/protocol` exports):** `(a)` Colyseus Schema classes (PlayerState, PlatformState, RoomState), `(b)` zod intent schemas (c2s.*), `(c)` msgpackr-encoded event types (s2c.*) with helper `encode/decode<T>()`, `(d)` `PROTOCOL_VERSION` constant, `(e)` re-export of opcode constants from `tools/protocol-doc/output/protocol.ts` via build-time copy (`packages/protocol/scripts/sync-from-tools-protocol-doc.mjs` runs as a `prebuild` script, copies into `packages/protocol/src/legacy-opcodes.ts` — NOT a runtime import, preserving Phase 1 D-17 standalone-tools rule). Drift guard: `lint-protocol-sync.mjs` exits non-zero if the sync output is stale.
- **D-20 (`packages/game-logic/step()` API):** Pure `step(state: WorldState, inputs: ReadonlyMap<account_id, InputFrame>, dt_ms: number): WorldState`. No I/O, no `Date.now()`, no `Math.random()` (RNG seed in `state.rng_state`, advanced via splitmix64 for deterministic per-step output). Float math (not fixed-point — Chrome and Node 22 V8 both use IEEE 754, deterministic per-platform; verified by golden-trajectory tests in vitest). `dt_ms` always `50` (20 Hz fixed-step); accumulator pattern in the server tick loop ensures `step()` is called integer-times-per-frame regardless of scheduler jitter (SRV-05). Input-replay test: given seed + recorded inputs + initial state, `step` produces byte-identical state across Node and headless-Chromium (Phase 6 wires the prediction harness onto this same module — pure JS, runs identically).
- **D-21 (`apps/server` boot sequence):** load env → load Ed25519 key (or generate + persist) → open SQLite + run pending Drizzle migrations → mount Better-Auth Express routes → start Colyseus on `ws.Server` (path `/colyseus`) → register `RebnoRoom` handler → `RoomRegistry.scan()` (load layout files from disk, verify all signatures, start fs.watch) → install SIGTERM handler → start tick loop → `app.get('/health')` returns `{status:'ok', ws_ready:true, rooms_loaded:N}` → log `ready` (Fly health check consumes `/health`).
- **D-22 (token-bucket rate limiter — SRV-07):** Per-account-per-message-type, keyed `(account_id, msg_type)`. Refill rates (per second, burst capacity): `input` = 25, burst 35 (slack above 20 Hz tick rate); `chat_send` = 2, burst 5; `room_join` = 1, burst 2; `heartbeat` = 2, burst 4; `auth` = 0.1, burst 3 (anti-bruteforce; Better-Auth has its own anti-bruteforce too). Excess → drop + `pino.warn { rate_limit_dropped, account_id, msg_type, current_tokens }`; sustained excess (> 10 s of continuous drops) → 60 s temporary mute per `(account_id, msg_type)` with `s2c.error { code: 'RATE_LIMITED', msg_type, mute_seconds }` notification.
- **D-23 (pino structured logging):** Every Colyseus room event, every auth event, every persistence write, every rate-limit drop, every SIGTERM step. Local dev pipes through `pino-pretty`. Phase 5 ships JSON to Fly log viewer (DEP-06 forward-prep). Sensitive fields (passwords, session tokens) redacted via `pino` `redact` config.
- **D-24 (vitest test surface):**
  - `packages/game-logic` — golden-trajectory determinism (seed + inputs → expected end state, byte-identical across runs), input-replay regression suite, edge-case collision (corner cases, simultaneous inputs).
  - `packages/protocol` — round-trip msgpackr encode/decode, zod schema validation acceptance + rejection, Colyseus Schema ser/de.
  - `apps/server` — auth flow integration (in-memory SQLite, real Better-Auth, real argon2id), legacy_credentials_staging hook (plaintext + bcrypt-weak paths, force-reset path), room hot-reload integration (tmp dir + fs.watch + write file → assert broadcast), rate-limit integration, SIGTERM grace integration (forked process + `kill -SIGTERM` → assert characters table fully flushed before exit).
  - Coverage targets: > 80% line on `packages/game-logic` + `packages/protocol` (deterministic = testable); ~ 60% on `apps/server` (integration-test driven, not unit).
- **D-25 (lint forcing-functions):** Parallel to Phase 2 / Phase 3 D-22 pattern.
  - `lint-protocol-sync.mjs` — D-19 drift guard.
  - `lint-game-logic-purity.mjs` — greps `packages/game-logic/src/**/*.ts` for `Date.`, `Math.random`, `process.`, `fs.`, network APIs; exits non-zero on any (allowlist for `process.env` reads at boundary modules only — game-logic itself reads zero env).
  - `lint-room-layout.mjs` — validates every `apps/server/rooms/<room_id>/<layout_rev>.json` against the zod layout schema, verifies a `.sig` companion exists, verifies signature against the server's pinned pubkey.
  - `lint-better-auth-schema-sync.mjs` — D-08 drift guard (regenerated auth-tables.ts must match committed copy).
  All hooked into `pnpm verify:phase-4` (composite gate, parallel Phase 3 plan 03-09).

### Carried forward from Phase 3 (locked, not re-decided)

Every Phase 3 D-* decision stands. Phase 4 explicitly consumes:
- `tools/protocol-doc/output/protocol.ts` (Phase 3 D-05, D-06) → `packages/protocol` via build-time copy (D-19).
- `tools/save-format-doc/output/save-formats.ts` (Phase 3 D-09, D-10) → `apps/server/scripts/migrate-legacy-accounts.ts` for `localList.txt` parsing (D-17).
- `packages/db/tables.ts` Drizzle baseline (Phase 3 D-13) → SQLite schema, EXTENDED with Better-Auth tables (D-08).
- `docs/extracted-server/parity-checklist.json` `mvp:true` rows (Phase 3 D-18) → MVP message scope cherry-pick.
- `docs/extracted-server/admin-anti-port.md` modernized command surface (Phase 3 D-20) → SRV-12 anti-port stub TODO comments in `apps/server/src/admin-stubs.ts`.
- ADR `docs/adr/0002-persistence-layer.md` (Phase 3 D-11) → SQLite + Litestream (no re-decision).
- ADR `docs/adr/0003-canonical-snapshot.md` (Phase 3 D-01..D-04) → `legacy/servers/enlyzeam-current/` is SRV-10 import source.
- Legacy-credentials-staging schema (Phase 3 D-04) → SRV-10/11 implementation + read-once-then-purge.

### Claude's Discretion

User delegated all 4 surfaced gray areas (wire/state split, auth+Colyseus, room hot-reload contract, persistence cadence) to Claude with the steer "modern best practices + frictionless UX; rooms must be edit-and-reload at runtime without restarting the server." Decisions D-01..D-25 reflect best judgment grounded in:
- `.planning/research/STACK.md` (Colyseus 0.17.10 + @colyseus/schema 4.0.21 + Better-Auth 1.6.9 + argon2 0.44.0 + better-sqlite3 12.9.0 + Drizzle 0.45.2 + msgpackr 1.11.10 — version pins)
- `.planning/research/PITFALLS.md` §B1 (server-authoritative discipline) + §B-* (Colyseus pitfalls) + §A4 (39dll wire = call order)
- ADR 0001 (Phaser 3.90 — Phase 6 boundary) + ADR 0002 (SQLite + Litestream) + ADR 0003 (enlyzeam-current canonical)
- Phase 1 D-15 (deterministic output) + D-17 (tools/ standalone, no workspaces)
- Phase 2 D-04 (mvp tagging), D-09 (subsystem MDs), D-15 (deterministic catalog tooling)
- Phase 3 D-04 (staging-table read-once-then-purge), D-05/D-06 (protocol artifacts), D-09 (save-format artifacts), D-13 (Drizzle baseline), D-18 (parity checklist mvp scope), D-20 (admin-anti-port modernized surface), D-22 (lint forcing functions)
- CLAUDE.md hard rules: #1 (server-authoritative), #2 (no plaintext password port), #3 (no Ctrl+E admin port), #6 (extract → document → rewrite)
- OWASP 2026 password-storage cheat sheet (argon2id #1)

Override any decision in planning if research or codebase reading surfaces a conflict.

### Folded Todos

None — `gsd-sdk query todo.match-phase 4` returned 0 matches.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents (gsd-phase-researcher, gsd-planner, gsd-pattern-mapper, gsd-executor) MUST read these before planning or implementing.**

### Project planning
- `.planning/PROJECT.md` — vision, constraints (Stage 4 NEXT, server-authoritative, modernize auth, server-side rooms per SRV-13/14)
- `.planning/REQUIREMENTS.md` §"Server Rebuild — MVP (Stage 4)" — SRV-01..SRV-14 acceptance text
- `.planning/ROADMAP.md` §"Phase 4: Server Rebuild (MVP)" — goal + 9 success criteria
- `.planning/STATE.md` — current position (Phase 04, ready_to_plan, 14/14 Phase-1+2 plans complete + 12/12 Phase 3 plans complete)
- `.planning/research/STACK.md` — Node 22, Colyseus 0.17.10, @colyseus/schema 4.0.21, Better-Auth 1.6.9, argon2 0.44.0, better-sqlite3 12.9.0, Drizzle 0.45.2, msgpackr 1.11.10, Vite 8.0.10, vitest 3.x, pino 9.x, zod 3.x, pnpm 10.x — version pins + alternatives matrix + Fly.io specifics
- `.planning/research/FEATURES.md` §"MVP-Critical (Stage 6 gate)" — feeds movement+chat scope cherry-pick from `parity-checklist.json` mvp:true rows
- `.planning/research/PITFALLS.md` §A4 (39dll wire = GML call order — Phase 4 consumes the already-derived schema, doesn't re-derive), §B1 (server-authoritative discipline — D-01/D-04/D-09/D-11), §C5 (`.bnu` migration — Phase 7 PAR-05 only; Phase 4 ships only the auth+position slice)
- `.planning/codebase/CONCERNS.md` §"Plaintext player credentials checked into the archive (CRITICAL)" — drives D-08/D-17 staging-table read-once-then-purge
- `.planning/codebase/STACK.md` — brownfield archive context; `.gitignore` already covers `legacy/`
- `.planning/codebase/ARCHITECTURE.md` — three-pipeline topology; where `apps/server` sits

### Phase 1 + 2 + 3 outputs (consumed as input)
- `tools/protocol-doc/output/protocol.ts` (Phase 3 D-05, plan 03-02) — opcode table → `packages/protocol/src/legacy-opcodes.ts` via build-time copy (D-19)
- `tools/save-format-doc/output/save-formats.ts` (Phase 3 D-09, plan 03-03) — `.bno`/`.bnb`/`.bnu` parsers → `apps/server/scripts/migrate-legacy-accounts.ts` consumes (D-17)
- `packages/db/tables.ts` + `packages/db/migrations/0001_baseline.sql` (Phase 3 plan 03-06) — Drizzle 8-table baseline (extend with Better-Auth tables per D-08; do NOT recreate)
- `docs/extracted-server/parity-checklist.json` (Phase 3 plan 03-05) — `mvp:true` rows define the message set Phase 4 implements; `in-phase-7` rows are SRV-12 anti-port stubs
- `docs/extracted-server/admin-anti-port.md` (Phase 3 D-20) — modernized intent shapes for SRV-12 stub TODO comments
- `docs/extracted-server/protocol.md` + `docs/extracted-server/save-formats.md` — narrative companion docs for human review
- `docs/extracted-server/SUBSYSTEM-MAP.json` (Phase 3 D-15) — script-roster cross-ref
- `docs/extracted-engine/asset-catalog/index.json` (Phase 2 plan 02-04) — referenced by `tools/room-converter` for tile/sprite ID validation
- `extracted/server-5-4/` — read-only, only via the Phase 3 derived artifacts (don't re-grep raw GML in Phase 4)
- `extracted/client-5-8/rooms/` — `tools/room-converter` reads to convert canonical rooms (D-09); pick smallest navigable room for `mvp-lobby` (D-12)

### ADRs (locked decisions)
- `docs/adr/0001-client-engine.md` — Phaser 3.90 locked (Phase 6 only; doesn't affect Phase 4 server)
- `docs/adr/0002-persistence-layer.md` (Phase 3 D-11) — SQLite + Litestream → Tigris locked
- `docs/adr/0003-canonical-snapshot.md` (Phase 3 D-01..D-04) — `legacy/servers/enlyzeam-current/` is the import source
- `docs/adr/0004-room-hot-reload.md` — to be created in Phase 4 planning, per the user's explicit hot-reload steer + D-09..D-13

### Reverse-engineering wiki (decomp/wiki/)
- `decomp/wiki/00-overview.md` — entry point
- `decomp/wiki/08-39dll-networking.md` — context for what Phase 3 already abstracted away
- `decomp/wiki/16-bno-bnb-notes.md` — Phase 3 D-08 errata-corrected (file_text_*); referenced by D-17 migrate-legacy-accounts

### External implementation references
- Colyseus 0.17 docs (`docs.colyseus.io`) — Schema 4.x, transport options, `onAuth`/`onJoin`/`onMessage`/`allowReconnection`, `ServerError` codes
- Colyseus + better-sqlite3 driver pattern — `docs.colyseus.io/server/database/`
- Better-Auth docs (`better-auth.com`) — Express integration, custom `password` provider hook for legacy-credentials-staging (D-08), session refresh
- argon2 npm (`@node-rs/argon2` or `argon2` 0.44.0) — Argon2id parameters per OWASP 2026
- better-sqlite3 docs (`github.com/WiseLibs/better-sqlite3`) — pragma config (D-15), WAL mode, `Database.close()` semantics
- Drizzle ORM docs (`orm.drizzle.team`) — Better-Auth adapter integration, transactions, migrations runner
- Litestream docs (`litestream.io`) — WAL frame replication semantics (D-15 RPO context — Phase 5 ships actual replicator config)
- msgpackr (`github.com/kriszyp/msgpackr`) — Encoder/Decoder reuse for performance
- Ed25519 in Node (`crypto.sign`/`crypto.verify` with `ed25519`) — D-11 manifest signing (no external lib needed)
- pino docs (`getpino.io`) — `redact` config for sensitive-field scrubbing (D-23)

### What NOT to read in Phase 4 (per CLAUDE.md hard rule #6)
- `legacy/servers/{enlyzeam-archive,local-current}/` — non-canonical per ADR 0003
- `legacy/source-archive/` — older Master/Client revisions, reference only
- `legacy/unity-project/`, `legacy/maya-project/` — abandoned remake attempts; not authoritative
- Modern decompilers (UTMT, Altar.NET) — incompatible with GM 5.3a (PITFALLS A1)

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets

- **`tools/extract-gmd/src/types.ts`** (Phase 1) — canonical extraction types (`Script`, `GmObject`, `Event`, `Room`, etc.); `tools/room-converter` imports for type-safe room reads.
- **`tools/asset-catalog/`** (Phase 2) — established the `pnpm catalog:server` invocation pattern; `tools/room-converter` reuses the deterministic-output discipline (sorted keys, 2-space, LF, no timestamps; Phase 1 D-15).
- **`tools/protocol-doc/output/protocol.{ts,json}`** (Phase 3 plan 03-02) — opcode table; **build-time-copied** into `packages/protocol/src/legacy-opcodes.ts` (D-19), not runtime-imported (preserves Phase 1 D-17 standalone-tools rule).
- **`tools/save-format-doc/output/save-formats.ts`** (Phase 3 plan 03-03) — `.bno`/`.bnb`/`.bnu` parsers; `apps/server/scripts/migrate-legacy-accounts.ts` imports the `localList.txt` parser specifically.
- **`packages/db/tables.ts`** + `packages/db/migrations/0001_baseline.sql` (Phase 3 plan 03-06) — **already a workspace package** in nascent form; Phase 4 EXTENDS with Better-Auth-generated tables (D-08), does NOT recreate. First proof-of-concept of the workspace boundary.
- **`docs/extracted-server/parity-checklist.json`** (Phase 3 plan 03-05) — `mvp:true` filter is the canonical MVP message set; cherry-pick this list rather than enumerating opcodes by hand.
- **`docs/extracted-server/admin-anti-port.md`** (Phase 3 D-20) — modernized intent shapes copied verbatim into `apps/server/src/admin-stubs.ts` as TODO-marker constants for SRV-12.
- **`scripts/verify-phase-3.mjs`** (Phase 3 plan 03-09) — composite-gate template; `pnpm verify:phase-4` mirrors this shape.
- **`extracted/client-5-8/rooms/`** (Phase 1) — source for `tools/room-converter` discovery + `mvp-lobby` selection (D-12).

### Established Patterns

- **TS-everywhere** (PROJECT.md). Tools live at `tools/<tool-name>/`, standalone Node CLIs (Phase 1 D-17). Apps + packages live in pnpm workspace (Phase 4 introduces, per Phase 1 D-17 deferral).
- **Headless, CI-runnable** (Phase 1 D-01) — no Windows-only tooling on the daily-dev critical path.
- **Reproducibility-first** (Phase 1 D-15) — deterministic JSON, source-order enumeration, no timestamps in artifacts. Phase 4 game-logic determinism extends this to runtime simulation (D-20).
- **Thin-wrapper authoring** (Phase 1 D-19, Phase 2 D-11) — docs link wikis + autogen blocks. Phase 4 narrative docs (e.g., `apps/server/README.md`) link wiki + ADRs rather than duplicating.
- **Lint-as-forcing-function** (Phase 2 D-13, Phase 3 D-22) — every artifact has a `lint-*.mjs` that exits non-zero on drift. D-25 adds 4 more.
- **Mvp tagging** (Phase 2 D-04, Phase 3 D-18) — `mvp: yes|no` is grep-able; Phase 4 cherry-picks `mvp:true` from `parity-checklist.json` rather than hand-enumerating.
- **Anti-port reference** (Phase 2 D-02, Phase 3 D-15/D-20) — Phase 4 SRV-12 implements the documented modernized intents as TODO stubs in `apps/server/src/admin-stubs.ts`, body-throws-NotImplemented; Phase 7 PAR-07 fills them in.

### Integration Points

- **Inputs:**
  - Phase 1 outputs (`tools/extract-gmd/src/types.ts`, `extracted/client-5-8/rooms/`)
  - Phase 3 outputs (`tools/protocol-doc/output/protocol.ts`, `tools/save-format-doc/output/save-formats.ts`, `packages/db/tables.ts`, `docs/extracted-server/parity-checklist.json`, `docs/extracted-server/admin-anti-port.md`, ADR 0002, ADR 0003)
  - `legacy/servers/enlyzeam-current/localList.txt` — one-shot legacy account import source (D-17)
  - `.planning/research/STACK.md` — version pins
  - `.planning/research/PITFALLS.md` §B1 — server-authoritative discipline
- **Outputs:**
  - `pnpm-workspace.yaml`, root `package.json` scripts (`-r build|typecheck|test`, `verify:phase-4`)
  - `apps/server/` — Colyseus server, Better-Auth Express, RoomRegistry, tick loop, SIGTERM handler, scripts/
  - `apps/server/rooms/mvp-lobby/000.json` + `000.sig` — first canonical room
  - `packages/protocol/` — Schema classes, zod intents, msgpackr events, PROTOCOL_VERSION, legacy-opcodes (build-time copy)
  - `packages/game-logic/` — pure step() + golden trajectories
  - `packages/db/auth-tables.ts` — Better-Auth-generated tables added to existing baseline
  - `tools/room-converter/` — extracted GM5 rooms → canonical JSON + signed manifest
  - `docs/adr/0004-room-hot-reload.md` — the hot-reload contract
  - 4 lint-*.mjs forcing functions
  - `scripts/verify-phase-4.mjs` composite gate
- **Downstream consumers:**
  - Phase 5 deploy — reads `apps/server/Dockerfile` (Phase 4 ships a working dev Dockerfile; Phase 5 hardens for Fly), reads `apps/server/scripts/migrate-legacy-accounts.ts` for RESTORE.md (DEP-07), consumes `/health` endpoint (DEP-05), consumes pino JSON output (DEP-06)
  - Phase 6 client — imports `packages/protocol` (Schema classes for state-diff sync, zod intents for type-safe send paths, PROTOCOL_VERSION constant), imports `packages/game-logic` (wraps `step()` for client-side prediction, CLI-04), consumes `s2c.room_layout` wire frame for tile rendering (CLI-07/AST-01), consumes `/api/auth/*` for login screen (CLI-03)
  - Phase 7 PAR-07 — implements admin web UI on the modernized intent shapes already stubbed in `apps/server/src/admin-stubs.ts`; `audit_log` table is pre-seeded
  - Phase 7 PAR-03 — uses `tools/room-converter` to populate the full room set; reuses D-09..D-13 contract with no protocol changes
  - Phase 7 PAR-04 — extends chat (whispers, channels, ignore/block, profanity, history rolling buffer) on top of D-02 base
  - Phase 7 PAR-05 — extends `apps/server/scripts/migrate-legacy-accounts.ts` to per-user transactional `.bnu` character migration

</code_context>

<specifics>
## Specific Ideas

- **Hot-reload-without-restart is the user's explicit Phase 4 requirement** (D-10). Architecture supports it via fs.watch on `apps/server/rooms/`, atomic-rename writes from `tools/room-converter`, debounced re-read + Ed25519 verify + zod validate + broadcast `s2c.room_layout` to room members. Per-room player state is preserved across the swap. This must be locked in ADR 0004 (`docs/adr/0004-room-hot-reload.md`) so Phase 7 PAR-03 cannot regress it.
- **Frictionless reconnect.** D-07: 30-day sliding session refreshed on every WS heartbeat-ack; reconnect within 10 s grace reuses session token via Colyseus `allowReconnection`; > 10 s reconnect re-authenticates silently from cookie. No login screen flash on tab-reopen mid-session. Force-reset users get an in-room overlay, not a disconnect-reconnect.
- **PROTOCOL_VERSION on byte 0 of c2s.auth, not every message** (D-03). Saves bytes; deploy ritual is "bump version → redeploy server before client".
- **Single canonical JSON for protocol artifacts.** D-05/D-06 from Phase 3 already enforce this on the source side; Phase 4 D-19 enforces it on the consumer side via `lint-protocol-sync.mjs` drift guard.
- **Game-logic purity is lint-enforced** (D-25). `lint-game-logic-purity.mjs` greps for `Date.`, `Math.random`, `process.`, `fs.`, network APIs and exits non-zero. Determinism is not a test; it's a structural property.
- **Audit log seeded empty in Phase 4** (Phase 3 D-13 carryover). Phase 7 PAR-07 writes here; no migration needed when admin UI ships.
- **No per-tick database writes** (D-14). Position drift between 30-second checkpoints is acceptable because Litestream handles WAL frame durability and a `kill -9` recovery resumes from `characters.last_saved_at` (≤ 30 s lost). On-tick writes would saturate the SQLite single-writer lock at any concurrent player count.
- **Existing standalone tools stay standalone.** `tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc` are NOT pulled into the workspace (D-18). `pnpm-workspace.yaml` excludes `tools/*`. Preserves Phase 1 D-17 / Phase 2 D-15 boundary; their outputs flow into the workspace via build-time copy (D-19).
- **`packages/db` already exists** (Phase 3 plan 03-06). Phase 4 EXTENDS it (Better-Auth tables per D-08), does NOT recreate. First package in the workspace is already in place.
- **Ed25519 keypair lives on the Fly Volume**, not in source (D-11). Generated at first deploy if missing. Public key shipped to client via env var (`VITE_ROOM_SIGNING_PUBKEY`) at Phase 6 build time. Private key never logged, never in git.

</specifics>

<deferred>
## Deferred Ideas

- **Full chat surface** (whispers, channels, ignore/block, profanity, history rolling buffer) — Phase 7 PAR-04. Phase 4 ships only `chat_send`/`chat_msg` broadcast + a 100-message in-memory rolling buffer.
- **`.bnu` per-user transactional character migration** — Phase 7 PAR-05. Phase 4 ships only the account-list import (D-17).
- **Modernized admin web UI** — Phase 7 PAR-07. Phase 4 stubs the documented intent shapes in `apps/server/src/admin-stubs.ts`.
- **Multi-region Fly deployment / sharding / Postgres migration** — v2 (OPS-01..03). Phase 4 single-machine SQLite remains correct.
- **Asset pipeline** (BMP→PNG, MIDI→OGG, fonts→WOFF2) — Phase 6 (AST-01) / Phase 7 (AST-02..04). Phase 4 ships room layouts only; tile atlases referenced by ID, materialized in Phase 6.
- **Apps/client scaffold + Vite + Phaser** — Phase 6. Phase 4 server ships independently.
- **Fly.io Dockerfile hardening + fly.toml + Litestream sidecar config + RESTORE.md + GitHub Actions + /health Fly check tuning** — Phase 5 (DEP-01..08). Phase 4 ships a working dev Dockerfile + `/health` endpoint as Phase 5 prerequisites only.
- **Wireshark validation of derived protocol** — optional, Phase 6 dogfood (PITFALLS A4 — captures are post-hoc validation only).
- **Postgres migration / LiteFS replicas** — v2 only; current architecture stays single-machine SQLite.
- **Public username changes / account recovery beyond force-reset / OAuth providers / passkeys** — Phase 7 PAR-06 + v2.
- **Fixed-point math** — rejected per D-20. Float math is deterministic per-platform on V8; if Phase 6 cross-browser proves it isn't, revisit then.

### Reviewed Todos (not folded)

None — `gsd-sdk query todo.match-phase 4` returned 0 matches.

</deferred>

---

*Phase: 04-server-rebuild-mvp*
*Context gathered: 2026-05-05*
