# Phase 4: Server Rebuild (MVP) - Research

**Researched:** 2026-05-06
**Domain:** Authoritative real-time multiplayer game server (Node 22 + Colyseus 0.17 + Better-Auth + better-sqlite3 + Litestream)
**Confidence:** HIGH (all 14 phase requirements anchored to Phase 3 artifacts and a 25-decision CONTEXT.md; verified Colyseus 0.17 API surface via Context7; verified Better-Auth custom-hash hook via Context7; verified Litestream WAL/synchronous semantics via Context7; verified all 12 npm versions via `npm view` 2026-05-06)

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

The CONTEXT.md gathered 2026-05-05 ships **25 implementation decisions (D-01..D-25)** all locked. Verbatim summary by group; full text is at `.planning/phases/04-server-rebuild-mvp/04-CONTEXT.md`:

**Wire / state model split (D-01..D-04)**
- **D-01 — Colyseus state-diff via @colyseus/schema 4.0.x.** Room schema = `RoomState { rev, 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 }` is in the schema from Phase 4 even when MVP-room ships zero platforms (SRV-14 protocol shape exercised before Phase 7).
- **D-02 — Intents + one-shot events via msgpackr.** C2S: `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: `s2c.chat_msg`, `s2c.room_layout`, `s2c.error`, `s2c.force_password_change`, `s2c.input_ack`. Chat history is a per-room rolling in-memory buffer (size 100), NOT in `RoomState`; on `onJoin`, server bursts last-N as `s2c.chat_msg`.
- **D-03 — `PROTOCOL_VERSION` (uint16) is the first field of `c2s.auth` ONLY.** Mismatch → `s2c.error { code: 'PROTOCOL_VERSION_MISMATCH' }` then close. Subsequent messages do not repeat. Bump = redeploy server before client.
- **D-04 — zod validation at the room boundary.** Every C2S payload validated by zod in the Colyseus `onMessage` handler BEFORE reaching `packages/game-logic`. Schemas live in `packages/protocol/src/intents.ts` and re-export to client. Failure → drop + `pino.warn`. Sustained excess (>10/min) → temporary mute via D-22 rate limiter.

**Auth + Colyseus integration (D-05..D-08)**
- **D-05 — HTTP login → WS upgrade with session token.** Better-Auth 1.6.9 mounts at Express `/api/auth/*`. `POST /api/auth/sign-in/email` returns `{ session_token, expires_at, must_force_reset }`.
- **D-06 — Colyseus `onAuth(client, options, request)` reads `options.token`,** calls Better-Auth `getSession(token)`, returns `{ account_id, username, role }` or throws `ServerError(4401, 'invalid_session')`. `onJoin` reads the auth payload as `client.auth`.
- **D-07 — Frictionless UX.** 30-day sliding session refreshed on every WS heartbeat-ack. Reconnect within 10 s grace = Colyseus `allowReconnection`. >10 s = silent re-auth from cookie. Force-reset users: `s2c.force_password_change` immediately after `onJoin`; in-room overlay; no disconnect-reconnect.
- **D-08 — `legacy_credentials_staging` hook.** Better-Auth schema generated via `@better-auth/cli generate --output packages/db/auth-tables.ts` and merged with the Drizzle baseline. Custom Better-Auth `password.verify` hook intercepts sign-in: if `accounts` row absent but `legacy_credentials_staging` has the username, validate against `legacy_hash` per `algorithm`, on success argon2id-rehash + INSERT INTO accounts + DELETE legacy row in one Drizzle transaction. `force_reset=1` rows wire to D-07.

**Room layout wire format + hot-reload (D-09..D-13) — KEY USER STEER**
- **D-09 — Canonical layout = content-addressed JSON on server FS.** `apps/server/rooms/<room_id>/<layout_rev>.json` + `<layout_rev>.sig`. Format derived at build-time via `tools/room-converter/`; runtime input is the JSON, not the raw GM5 binary.
- **D-10 — Hot-reload via `fs.watch` (USER REQUIREMENT).** `RoomRegistry.scan()` at boot starts `fs.watch('apps/server/rooms', { recursive: true })`. Change-event (debounced 200 ms) → re-read both files → re-verify Ed25519 → zod validate → broadcast `s2c.room_layout` to room members. **Server does not restart.** Per-room player state preserved across reload — only layout swaps. Deleted file forces clients to lobby with `s2c.error { code: 'ROOM_REMOVED' }`.
- **D-11 — Ed25519 signed manifests.** Wire frame `s2c.room_layout { room_id, layout_rev, layout_bytes (msgpackr), manifest_sig (Ed25519(room_id || layout_rev || sha256(layout_bytes))) }`. Client verifies against pinned pubkey shipped via `VITE_ROOM_SIGNING_PUBKEY` (Phase 6). Private key on Fly Volume `/data/keys/room_signing.ed25519`, generated at first deploy.
- **D-12 — Single MVP room `mvp-lobby`.** Smallest navigable room from `extracted/client-5-8/rooms/`; final pick during planning. Client bundle ships ZERO static layout data — SRV-13 acceptance.
- **D-13 — Hot-edit safety.** `pnpm room:edit <room_id>` interactive validate-then-write cycle: read rev → `$EDITOR` → zod validate → atomic-rename `<rev+1>.json.tmp` + `<rev+1>.sig.tmp`. Phase 7 PAR-03 reuses this contract verbatim.

**Persistence write cadence (D-14..D-17)**
- **D-14 — Event-driven, NOT on-tick.** Persist on: auth success, room transition, graceful disconnect, 30 s checkpoint timer (jittered ±5 s), SIGTERM flush. No per-tick writes.
- **D-15 — SQLite pragmas:** `journal_mode=WAL`, `synchronous=NORMAL` (Litestream prerequisite + 3× write throughput), `foreign_keys=ON`, `busy_timeout=5000`.
- **D-16 — SIGTERM grace (SRV-08).** (a) stop accepting connections, (b) emit `s2c.error { code: 'SERVER_DRAINING', reconnect_after_ms: 30000 }`, (c) Drizzle txn flush characters, (d) `db.close()` (fsyncs WAL), (e) 2 s for Litestream to flush, (f) exit. 25 s window (Fly default 30 s − 5 s safety).
- **D-17 — One-shot legacy account migration.** `pnpm migrate:legacy-accounts` reads `legacy/servers/enlyzeam-current/localList.txt` once during initial deploy, classifies algorithms, writes `legacy_credentials_staging` rows. NOT on every boot.

**Monorepo + tooling (D-18..D-25)**
- **D-18 — pnpm workspaces.** `apps/*` + `packages/*`. Initial: `apps/server/`, `packages/protocol/`, `packages/game-logic/`, `packages/db/` (Phase 3 03-06 created tools/db-schema; Phase 4 PROMOTES to packages/db — see assumption A1 below). Existing `tools/*` (extract-gmd, asset-catalog, protocol-doc, save-format-doc) NOT in workspace; `tools/room-converter` is also standalone. Workspace-protocol = `workspace:*`. `tsc --build` references.
- **D-19 — `packages/protocol` exports.** (a) Colyseus Schema classes, (b) zod intents, (c) msgpackr s2c types + helper `encode/decode<T>()`, (d) `PROTOCOL_VERSION`, (e) build-time copy of `tools/protocol-doc/output/protocol.ts` into `packages/protocol/src/legacy-opcodes.ts` (NOT runtime import). Drift-guarded by `lint-protocol-sync.mjs`.
- **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`, splitmix64 advance. Float math (NOT fixed-point — V8 IEEE 754 deterministic per-platform; verified by golden-trajectory tests). `dt_ms = 50` constant (20 Hz).
- **D-21 — `apps/server` boot sequence.** load env → load/generate Ed25519 key → open SQLite + drizzle migrate → mount Better-Auth Express → start Colyseus on `ws.Server` (`/colyseus`) → register `RebnoRoom` → `RoomRegistry.scan()` + `fs.watch` → install SIGTERM → start tick → `/health` → log `ready`.
- **D-22 — Token-bucket rate limiter (SRV-07).** Per `(account_id, msg_type)`. Rates/burst: `input` 25/35, `chat_send` 2/5, `room_join` 1/2, `heartbeat` 2/4, `auth` 0.1/3. Sustained excess (>10 s of drops) → 60 s mute + `s2c.error { code: 'RATE_LIMITED' }`.
- **D-23 — pino structured logging.** Every Colyseus event, every auth event, every persistence write, every rate-limit drop, every SIGTERM step. `pino-pretty` for dev. Sensitive fields redacted via `redact`.
- **D-24 — vitest test surface.** `packages/game-logic` golden trajectories + replay; `packages/protocol` round-trip + zod; `apps/server` integration (in-memory SQLite + real Better-Auth + real argon2 + tmp-dir hot-reload + SIGTERM fork-and-kill). Coverage targets: >80% game-logic + protocol; ~60% apps/server.
- **D-25 — Lint forcing-functions:** `lint-protocol-sync.mjs`, `lint-game-logic-purity.mjs` (forbids `Date.`, `Math.random`, `process.`, `fs.`, network APIs in `packages/game-logic/src/**`), `lint-room-layout.mjs` (zod + sig verify), `lint-better-auth-schema-sync.mjs`. All in `pnpm verify:phase-4`.

### Claude's Discretion

User explicitly 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."** D-01..D-25 reflect that delegation. The planner has freedom WITHIN each decision (e.g., specific zod schema field-by-field, exact splitmix64 implementation, exact `rate_limit_dropped` log shape) but MUST NOT contradict any D-* row. If research surfaces a conflict, override is permitted per CONTEXT.md "Override any decision in planning if research or codebase reading surfaces a conflict" — see this RESEARCH.md `## Recommended CONTEXT.md Overrides` for the one I found.

### Deferred Ideas (OUT OF SCOPE)

- Full chat surface (whispers, channels, ignore/block, profanity, history rolling buffer) → Phase 7 PAR-04.
- `.bnu` per-user transactional character migration → Phase 7 PAR-05.
- Modernized admin web UI → Phase 7 PAR-07. (Phase 4 stubs intent shapes in `apps/server/src/admin-stubs.ts`.)
- Multi-region Fly / sharding / Postgres → v2 OPS-01..03.
- Asset pipeline → Phase 6 (AST-01) / Phase 7 (AST-02..04).
- `apps/client` scaffold + Vite + Phaser → Phase 6.
- Fly.io Dockerfile hardening + fly.toml + Litestream sidecar config + RESTORE.md + GitHub Actions + `/health` Fly 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.
- Fixed-point math → rejected per D-20.

</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| **SRV-01** | `packages/protocol` ships ~6 MVP message types + binary codec; `PROTOCOL_VERSION` enforced on packet 1 | §Standard Stack `@colyseus/schema 4.0.23` + `msgpackr 1.11.10` + `zod 3.x`; §Code Examples "PROTOCOL_VERSION handshake"; §Architecture Pattern "Two-channel wire" |
| **SRV-02** | `packages/game-logic` pure deterministic `step(state, inputs, dt) → state`; movement + collision + room model; runnable Node + browser | §Architecture Pattern "Pure deterministic step()"; §Don't Hand-Roll "RNG"; §Code Examples "splitmix64"; §Common Pitfalls "Float math determinism" |
| **SRV-03** | `apps/server` runs Node 22 + Colyseus 0.17 with one Room class implementing the MVP slice | §Standard Stack `colyseus 0.17.10`; §Code Examples "RebnoRoom skeleton"; §Architecture Pattern "Single Room class"  |
| **SRV-04** | Server is authoritative — clients send inputs/intents, server emits state | §Architecture Pattern "Server authority enforcement"; §Code Examples "intent validation pipeline"; PITFALLS B1 reference |
| **SRV-05** | 20 Hz fixed-timestep tick loop with accumulator pattern | §Architecture Pattern "Fixed-tick accumulator"; §Code Examples "accumulator loop"; §Common Pitfalls "setSimulationInterval drift"  |
| **SRV-06** | Heartbeat (15 s) / reconnect grace (10 s) | §Code Examples "allowReconnection"; §Standard Stack Colyseus built-in patchRate/pingInterval; PITFALL "Fly idle timeout" |
| **SRV-07** | Per-account-per-message-type token-bucket rate limiting | §Don't Hand-Roll "rate limiting library candidates"; §Architecture Pattern "Token-bucket map keyed (account_id, msg_type)"; §Code Examples |
| **SRV-08** | SIGTERM grace flushes to SQLite (WAL + atomic); kill -9 mid-tick recoverable | §Standard Stack `better-sqlite3 12.9.0` + Litestream; §Architecture Pattern "SIGTERM grace handler"; §Common Pitfalls "synchronous=NORMAL vs FULL" |
| **SRV-09** | Better-Auth + argon2id from packet 1 | §Code Examples "Better-Auth custom argon2id hook" (verified Context7); §Standard Stack `better-auth 1.6.9` + `argon2 0.44.0` (or `@node-rs/argon2`) |
| **SRV-10** | Legacy account import accepts (username, hash, algorithm); silent re-hash on first valid login | §Architecture Pattern "Legacy credentials staging"; §Code Examples "verify hook with staging fallback" |
| **SRV-11** | Plaintext / bcrypt-weak entries trigger force password-change | §Architecture Pattern "Force-reset path via D-07 overlay" |
| **SRV-12** | Ctrl+E clipboard admin NOT ported — anti-port documented | §Code Examples "admin-stubs.ts"; CLAUDE.md hard rule #3 |
| **SRV-13** | Room layouts server-authoritative; ≥1 MVP room over wire; client ships zero static layout; integrity-verified | §Architecture Pattern "RoomRegistry + Ed25519 signed manifests"; §Code Examples "fs.watch hot-reload"; §Don't Hand-Roll "Ed25519 (use Node crypto)" |
| **SRV-14** | Moving-platform positions deterministic via game-logic step(); broadcast in same state-diff envelope; clients interpolate, never extrapolate | §Architecture Pattern "PlatformState in MapSchema from day 1"; §Code Examples "step() advances platforms"; §Common Pitfalls "interpolation buffer" |
</phase_requirements>

## Summary

Phase 4 stands up the Node 22 + TypeScript + Colyseus 0.17.10 authoritative server for the movement+chat MVP slice. The phase consumes Phase 3 artifacts (opcode table, save schemas, persistence ADR, parity checklist mvp:true rows) and produces three workspace packages (`packages/protocol`, `packages/game-logic`, `packages/db`), one app (`apps/server`), one new tool (`tools/room-converter`), one new ADR (0004 room hot-reload), four lint forcing-functions, and a `verify:phase-4` composite gate.

The CONTEXT.md is exceptionally complete — 25 locked decisions cover wire format, state model, auth integration, room hot-reload contract, persistence cadence, monorepo structure, test surface, and lint surface. **Research did not surface any blockers**, but did surface (a) one factual error in CONTEXT.md to override (`packages/db` does not exist yet — Phase 3 created `tools/db-schema`, not a workspace package) and (b) version drift for several pinned libraries (`@colyseus/schema 4.0.23` not 4.0.21; `msgpackr 2.0.1` not 1.11.10; `zod 4.4.3` available but `zod 3.x` is what STACK.md pins; `pino 10.3.1` not 9.x; `vitest 4.1.5` already in `tools/db-schema`). All differences are minor; neither blocks planning.

**Primary recommendation:** Proceed to planning. Adopt the 25 decisions verbatim except for ONE override: `packages/db` is a NEW Phase 4 deliverable (created BY this phase from Phase 3's `tools/db-schema/src/tables.ts`), not an EXTENSION of an existing workspace package. CONTEXT.md D-18 already alludes to this ("Phase 3 plan 03-06 already created this — Phase 4 EXTENDS rather than recreates") but the on-disk reality is that `tools/db-schema` is a non-workspace tool — Phase 4 must promote/copy the table definitions into `packages/db/`. The seven Drizzle table definitions in `tools/db-schema/src/tables.ts` are reusable verbatim.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| WS transport | Node server (Colyseus 0.17 + ws) | — | All real-time game traffic on a single WS endpoint owned by Colyseus |
| HTTP auth (sign-in/sign-up/sessions) | Node server (Better-Auth on Express) | — | Better-Auth controls cookies + sessions BEFORE WS upgrade (D-05) |
| Authoritative simulation | `packages/game-logic` step() in Node | Same code in browser (Phase 6 prediction) | Determinism is the whole point — same TS module both halves |
| State diff sync | `@colyseus/schema` in Node → colyseus.js client | — | Built into Colyseus; not for chat (D-02 split) |
| One-shot events / intents | msgpackr-encoded over Colyseus `onMessage` / `client.send` | — | Anything outside the schema (chat, errors, room_layout, force_password_change) |
| Room layout authority | `apps/server/rooms/` filesystem + RoomRegistry + Ed25519 sig | Tigris bucket via Litestream replicates the SQLite snapshot of state (room files themselves stay on Fly Volume) | Layouts are filesystem JSON, hot-reloadable; no DB row |
| Player state persistence | better-sqlite3 (Drizzle) + Fly Volume `/data/rebno.db` | Litestream → Tigris (Phase 5 ships sidecar) | Synchronous DB driver matches Colyseus tick loop; per-event writes per D-14 |
| Rate limiting | In-process token-bucket Map keyed `(account_id, msg_type)` | — | <50 CCU on one machine — no Redis at MVP |
| Crash recovery | SQLite WAL atomic writes survive `kill -9`; SIGTERM grace flushes | Litestream replays WAL frames if volume itself is lost | Two-tier durability: WAL for kill -9; Litestream for volume loss |
| Admin actions | NOT IN PHASE 4 — TODO stubs only | Phase 7 PAR-07 web UI | SRV-12 anti-port; intent shapes pre-stubbed |

## Standard Stack

### Core (verified npm versions, 2026-05-06)

| Library | Pinned Version | Latest Available | Purpose | Why Standard |
|---------|---------------|------------------|---------|--------------|
| Node.js | 22 LTS | 22 LTS | Runtime | Active LTS through 2027-04; STACK.md lock |
| TypeScript | 5.6+ | 5.6.3 (existing in tools/db-schema) | Source language | Strict mode required (CLAUDE.md Conventions) |
| `colyseus` | 0.17.10 | 0.17.10 | Authoritative game server framework | STACK.md lock; Room/state-sync/reconnect/matchmaker [VERIFIED: npm 2026-05-06] |
| `@colyseus/schema` | **4.0.23** (was 4.0.21 in CONTEXT.md) | 4.0.23 | Binary state delta sync | Decorator schema → automatic delta packets [VERIFIED: npm view @colyseus/schema version → 4.0.23] |
| `better-auth` | 1.6.9 | 1.6.9 | Auth framework | Custom `password.{hash,verify}` hook supports argon2id [VERIFIED: Context7 /better-auth/better-auth] |
| `argon2` | 0.44.0 | 0.44.0 | Password hashing (Argon2id) | OWASP 2026 #1; native binding [VERIFIED: npm 2026-05-06]. **Alternative: `@node-rs/argon2` 2.0.2** — Better-Auth docs use this in their custom-hook example. Either works; `argon2` 0.44.0 is what STACK.md pinned. Recommend sticking with `argon2` 0.44.0 unless Phase 5 musl-Alpine build surfaces issues, then swap to `@node-rs/argon2`. |
| `better-sqlite3` | 12.9.0 | 12.9.0 | DB driver (synchronous in-process) | Synchronous matches Colyseus tick loop [VERIFIED: npm 2026-05-06] |
| `drizzle-orm` | 0.45.2 | 0.45.2 | TypeScript SQL builder | Already in `tools/db-schema`; Better-Auth Drizzle adapter native [VERIFIED: npm 2026-05-06] |
| `drizzle-kit` | 0.31.10 | (in `tools/db-schema`) | Migration runner | Generates SQL; Phase 4 runs migrations on boot |
| `msgpackr` | **2.0.1** (was 1.11.10 in STACK.md) | 2.0.1 | Schemaless binary serialization | C2S intents + S2C events outside Schema [VERIFIED: npm view msgpackr version → 2.0.1]. Major version bump since STACK.md — review changelog at planning time. Recommend pinning to `^1.11.10` for now to match STACK.md unless 2.x has a compelling fix; defer. |
| `ws` | 8.20.0 | 8.20.0 | Raw WebSocket lib | Transitive via Colyseus; pinned by Colyseus version range [VERIFIED: npm 2026-05-06] |
| `zod` | 3.x | **4.4.3 latest** | Runtime validation | STACK.md pins 3.x; zod 4.x exists but 3.x is the stable line. Recommend `zod ^3.23` to align with STACK.md. [VERIFIED: npm 2026-05-06] |
| `pino` | 9.x (per STACK.md) | **10.3.1 latest** | Structured JSON logging | `redact` config for sensitive fields [VERIFIED: npm 2026-05-06]. STACK.md pinned 9.x; 10.x available but no urgency. Recommend `pino ^9` for STACK.md alignment. |
| `vitest` | 3.x (STACK.md) | **4.1.5 latest** (in `tools/db-schema`) | Test runner | `tools/db-schema` already uses 4.1.5 — recommend Phase 4 also use 4.1.5 for consistency, NOT 3.x. |
| `pnpm` | 10.x | 10.x | Workspace + install | STACK.md lock |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `@better-auth/cli` | latest | Schema generator | One-shot at planning time: `pnpm exec @better-auth/cli generate --output packages/db/auth-tables.ts` per D-08 |
| `tsx` | 4.x | Dev-time TS runner | `tsx watch` for `apps/server` dev mode |
| `Litestream` | 0.3.13 | SQLite WAL replicator | Phase 5 sidecar (DEP-03); Phase 4 only sets up the SQLite WAL pragmas it consumes |
| Node `crypto` (built-in) | — | Ed25519 sign/verify | D-11 manifest signing — **no external lib needed** (Node 22 has native ed25519 in `crypto.sign`/`crypto.verify`) |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `argon2` 0.44.0 | `@node-rs/argon2` 2.0.2 | Better-Auth docs use the latter; both produce $argon2id$ strings. Pure-Rust binding may build faster on Alpine (Phase 5 forward concern). Recommend `argon2` 0.44.0 for Phase 4 to match STACK.md pin; switch only if Phase 5 native build fails. |
| `msgpackr` 1.11.10 | `msgpackr` 2.0.1 | Major bump available; STACK.md predates it. Stick with `^1.11.10` until a Phase-4 issue forces upgrade. |
| In-process token bucket | `rate-limiter-flexible` (npm) | The in-process map is ~30 lines and avoids a dep; D-22 is unambiguous. Skip the lib. |
| Custom Ed25519 lib | Node `crypto.sign('ed25519', ...)` / `crypto.verify` | Native since Node 16; zero deps needed. Use it. |
| Hand-rolled WS heartbeat | Colyseus built-in `pingInterval` + `pingMaxRetries` | Colyseus emits ping/pong on the same ws automatically; tune to 15 s per SRV-06. |

**Installation (Phase 4 root + workspace adds, after planning locks final versions):**
```bash
# Root pnpm-workspace.yaml: add 'apps/*', 'packages/*' (KEEP tools/* OUT per D-18)
pnpm install -w -D typescript@5.6.3 tsx@4.21.0

# packages/db (PROMOTE from tools/db-schema — see Override below)
pnpm --filter @rebno/db add drizzle-orm@0.45.2
pnpm --filter @rebno/db add -D drizzle-kit@0.31.10 vitest@4.1.5

# packages/protocol
pnpm --filter @rebno/protocol add @colyseus/schema@4.0.23 zod@^3.23 msgpackr@1.11.10

# packages/game-logic
pnpm --filter @rebno/game-logic add -D vitest@4.1.5

# apps/server
pnpm --filter @rebno/server add colyseus@0.17.10 ws@8.20.0
pnpm --filter @rebno/server add better-auth@1.6.9 argon2@0.44.0
pnpm --filter @rebno/server add better-sqlite3@12.9.0 drizzle-orm@0.45.2 pino@^9
pnpm --filter @rebno/server add msgpackr@1.11.10 zod@^3.23
pnpm --filter @rebno/server add express@^4 cookie-parser@^1
pnpm --filter @rebno/server add -D vitest@4.1.5 @types/better-sqlite3 @types/node @types/express
```

**Version verification:** All pinned versions above verified by `npm view <pkg> version` on 2026-05-06 [VERIFIED: npm registry 2026-05-06]. STACK.md was researched 2026-05-01; only `@colyseus/schema` (4.0.21 → 4.0.23 patch bump) drifted in HEAD-of-line for the Colyseus stack. CONTEXT.md inherited the older 4.0.21 pin — bump to 4.0.23 at planning.

## Architecture Patterns

### System Architecture Diagram

```
                                                    ┌──────────────────────────┐
                                                    │  apps/server/rooms/      │
                                                    │  <room>/<rev>.{json,sig} │  ← tools/room-converter writes
                                                    │  (Fly Volume /data)      │     these (build-time)
                                                    └────────────┬─────────────┘
                                                                 │ fs.watch (debounced 200ms)
                                                                 ▼
   Browser  ──HTTP /api/auth/*──▶  ┌────────────────────────────────────────────────┐
   (Phase 6)                       │  apps/server (Node 22)                         │
            ──WSS /colyseus?token─▶│                                                │
                                   │  ┌─────────────┐    ┌──────────────────────┐  │
                                   │  │  Express    │    │  Colyseus 0.17.10    │  │
                                   │  │ Better-Auth │───▶│  onAuth (getSession) │  │
                                   │  │  /api/auth  │    │  RebnoRoom (1 class) │  │
                                   │  └──────┬──────┘    │   ├─ onJoin           │  │
                                   │         │           │   ├─ onMessage        │  │  (zod validate, then…)
                                   │         │           │   │   ├─ chat_send    │──┼──▶ chat in-memory ring buffer
                                   │         │           │   │   ├─ input        │──┼──▶ packages/game-logic.step()
                                   │         │           │   │   ├─ room_join    │  │       (pure deterministic)
                                   │         │           │   │   └─ heartbeat    │  │
                                   │         │           │   ├─ tick (20 Hz acc) │──┼──▶ broadcasts state diff
                                   │         │           │   │                   │  │     via @colyseus/schema
                                   │         │           │   ├─ allowReconnection│  │
                                   │         │           │   └─ onLeave          │  │
                                   │         │           └──────────┬───────────┘  │
                                   │         ▼                      │              │
                                   │  ┌─────────────┐                │              │
                                   │  │ Drizzle ORM │◀───────────────┘              │
                                   │  │ better-sql3 │  per-event writes (D-14)      │
                                   │  └──────┬──────┘                               │
                                   └─────────┼───────────────────────────────────────┘
                                             │
                                  ┌──────────▼─────────┐
                                  │ /data/rebno.db     │  ← SQLite WAL on Fly Volume
                                  │ (WAL mode,         │
                                  │  synchronous=NORMAL)│
                                  └──────────┬─────────┘
                                             │ Litestream sidecar (Phase 5)
                                             ▼
                                  ┌────────────────────┐
                                  │ Tigris S3 bucket   │  ← RPO < 1s
                                  └────────────────────┘
```

### Component Responsibilities

| File / Module | Responsibility |
|---------------|----------------|
| `apps/server/src/index.ts` | Boot sequence per D-21: env → keys → db → migrations → Express → Better-Auth → Colyseus → RoomRegistry.scan() → SIGTERM hook → tick |
| `apps/server/src/RebnoRoom.ts` | Single Colyseus Room class. `onAuth/onJoin/onMessage/onLeave/onDispose`. Owns the state schema instance, the in-memory chat buffer, the rate-limiter map, and the simulation tick |
| `apps/server/src/RoomRegistry.ts` | Layout cache + `fs.watch` driver. Hot-reload broadcasts `s2c.room_layout` per D-10 |
| `apps/server/src/auth.ts` | Better-Auth instance with custom `password.{hash, verify}` hook (argon2id + legacy-staging fallback per D-08/D-10/D-11) |
| `apps/server/src/db.ts` | better-sqlite3 + Drizzle wiring; exports `db` singleton; opens with WAL pragmas per D-15 |
| `apps/server/src/sigterm.ts` | D-16 grace handler |
| `apps/server/src/rate-limit.ts` | Token-bucket Map keyed `(account_id, msg_type)` per D-22 |
| `apps/server/src/admin-stubs.ts` | SRV-12 anti-port — typed intent shapes from `docs/extracted-server/admin-anti-port.md` as TODO-NotImplemented stubs |
| `apps/server/scripts/migrate-legacy-accounts.ts` | D-17 one-shot CLI; consumes `tools/save-format-doc/output/save-formats.ts` `localList.txt` parser |
| `apps/server/rooms/mvp-lobby/000.json` + `.sig` | First canonical room (D-12) |
| `packages/protocol/src/state.ts` | Colyseus Schema classes: PlayerState, PlatformState, RoomState |
| `packages/protocol/src/intents.ts` | zod schemas for c2s.* (re-exported to client) |
| `packages/protocol/src/events.ts` | msgpackr S2C event types + `encode<T>/decode<T>` helpers |
| `packages/protocol/src/version.ts` | `PROTOCOL_VERSION` constant (uint16) |
| `packages/protocol/src/legacy-opcodes.ts` | Build-time copy of `tools/protocol-doc/output/protocol.ts` (drift-guarded by lint) |
| `packages/protocol/scripts/sync-from-tools-protocol-doc.mjs` | `prebuild` script that copies legacy opcodes per D-19 |
| `packages/game-logic/src/step.ts` | Pure `step(state, inputs, dt) → state` per D-20 |
| `packages/game-logic/src/rng.ts` | splitmix64 RNG (deterministic per state.rng_state) |
| `packages/game-logic/test/golden.test.ts` | Trajectory + replay tests per D-24 |
| `packages/db/src/tables.ts` | Drizzle schema (PROMOTED from `tools/db-schema/src/tables.ts`) — see Override |
| `packages/db/src/auth-tables.ts` | Better-Auth-generated tables (D-08); regenerated by `pnpm db:auth:gen`; lint-drift-guarded |
| `packages/db/src/index.ts` | Re-exports `tables` + `authTables` for `apps/server` consumption |
| `tools/room-converter/cli.ts` | NEW Phase 4 tool: extracted GM5 rooms → canonical layout JSON + signed manifest |
| `docs/adr/0004-room-hot-reload.md` | Locks the D-09..D-13 contract |
| `scripts/verify-phase-4.mjs` | Composite gate (mirrors `verify-phase-3.mjs`) |
| 4× `lint-*.mjs` | D-25 forcing functions |

### Pattern 1: Two-channel wire (state-diff + msgpackr events)

**What:** Continuous world state synchronizes via `@colyseus/schema` automatic delta encoder; everything outside the world (chat lines, room layouts, errors, force-reset signals, ack frames) goes through `msgpackr`-encoded one-shot events on Colyseus's `client.send`/`onMessage` channels.

**When to use:** Any time the data is not part of the live world that needs interpolation. Chat is bursty + ephemeral; layouts are large + infrequent; errors are typed + one-shot. Putting them in the Schema would (a) bloat every state diff and (b) force history into the schema (which is a bad fit — D-02).

**Example:**
```typescript
// Source: Context7 /colyseus/docs (verified 2026-05-06)
// packages/protocol/src/state.ts
import { Schema, MapSchema, type } from "@colyseus/schema";

export class PlayerState extends Schema {
    @type("string") account_id: string;
    @type("string") name: string;
    @type("number") room_id: number;
    @type("number") x: number;
    @type("number") y: number;
    @type("number") vx: number;
    @type("number") vy: number;
    @type("number") sprite_id: number;
    @type("number") last_input_seq: number;
}

export class PlatformState extends Schema {
    @type("string") id: string;
    @type("number") x: number;
    @type("number") y: number;
    @type("number") vx: number;
    @type("number") vy: number;
}

export class RoomState extends Schema {
    @type("number") rev: number = 0;
    @type({ map: PlayerState }) players = new MapSchema<PlayerState>();
    @type({ map: PlatformState }) platforms = new MapSchema<PlatformState>();
    // NOTE: chat history NOT here. SRV-14 platforms in schema from day 1.
}
```

```typescript
// packages/protocol/src/events.ts
import { Packr, Unpackr } from "msgpackr";
const packr = new Packr({ structuredClone: false });
const unpackr = new Unpackr({ structuredClone: false });

export type S2C =
    | { type: "chat_msg"; sender_account_id: string; sender_name: string; text: string; ts: number }
    | { type: "room_layout"; room_id: string; layout_rev: string; layout_bytes: Uint8Array; manifest_sig: Uint8Array }
    | { type: "error"; code: string; msg?: string; [k: string]: unknown }
    | { type: "force_password_change"; reason: string }
    | { type: "input_ack"; seq: number };

export const encodeS2C = (m: S2C): Buffer => packr.pack(m);
export const decodeS2C = (b: Uint8Array): S2C => unpackr.unpack(b) as S2C;
```

### Pattern 2: Pure deterministic `step()`

**What:** A pure function `step(state, inputs, dt) → state` that advances the world by exactly `dt_ms` (always 50). No I/O, no global state, no `Date.now()`, no `Math.random()`. RNG state lives IN `state.rng_state` and advances via splitmix64. Same TS module runs in Node (server tick) AND browser (Phase 6 client prediction wraps it) AND vitest (golden trajectory tests).

**When to use:** Every server tick (driven by the accumulator loop). Every client-prediction frame (Phase 6, CLI-04). Every replay-determinism test.

**Example:**
```typescript
// packages/game-logic/src/rng.ts
// splitmix64 — deterministic 64-bit PRNG. Source: https://prng.di.unimi.it/splitmix64.c
export function splitmix64(seed: bigint): { value: number; next: bigint } {
    let z = (seed + 0x9e3779b97f4a7c15n) & 0xffffffffffffffffn;
    z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & 0xffffffffffffffffn;
    z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & 0xffffffffffffffffn;
    z = (z ^ (z >> 31n)) & 0xffffffffffffffffn;
    return { value: Number(z & 0xffffffffn) / 0xffffffff, next: seed + 0x9e3779b97f4a7c15n };
}
```

```typescript
// packages/game-logic/src/step.ts
export interface InputFrame { seq: number; axis_x: -1 | 0 | 1; axis_y: -1 | 0 | 1; jump: boolean; action_btns: number }
export interface WorldState {
    rev: number;
    rng_state: bigint;
    players: ReadonlyMap<string, { x: number; y: number; vx: number; vy: number; last_input_seq: number; sprite_id: number; room_id: number }>;
    platforms: ReadonlyMap<string, { x: number; y: number; vx: number; vy: number; cycle_phase: number }>;
    room_layout: { collision_polys: Array<{ x: number; y: number }[]>; room_size: { w: number; h: number } };
}

export function step(state: WorldState, inputs: ReadonlyMap<string, InputFrame>, dt_ms: number): WorldState {
    // pure: build new maps; never mutate `state`. dt_ms always 50.
    // 1. advance platforms via deterministic cycle (cycle_phase += dt)
    // 2. apply input intents to player vx/vy (clamped to MAX_SPEED)
    // 3. integrate position by (vx*dt, vy*dt)
    // 4. resolve collision against room_layout.collision_polys (segment-vs-poly)
    // 5. clamp to room_size
    // 6. echo last_input_seq for client reconciliation
    // 7. return NEW state object with rev+1
    throw new Error("planner implements");
}
```

### Pattern 3: 20 Hz fixed-timestep accumulator

**What:** A wall-clock-independent tick loop that calls `step()` an integer number of times per real-world frame, regardless of scheduler jitter. `setSimulationInterval` from Colyseus already provides a deltaTime-driven callback; the accumulator pattern wraps that to guarantee `step()` is only ever called with `dt_ms === 50`.

**When to use:** Every Phase 4 server. SRV-05 explicitly requires the accumulator pattern.

**Example:**
```typescript
// apps/server/src/RebnoRoom.ts (excerpt)
import { Room, Client } from "colyseus";
import { step } from "@rebno/game-logic";

const TICK_MS = 50; // 20 Hz

export class RebnoRoom extends Room<RoomState> {
    private accumulator = 0;
    private inputBuffer = new Map<string, InputFrame>();

    onCreate() {
        this.setState(new RoomState());
        // Colyseus calls cb(dt) at ~16.6ms by default; we override to 50ms (per docs)
        this.setSimulationInterval((dt) => this.tick(dt), TICK_MS);
        this.setPatchRate(TICK_MS); // emit state diff at the same rate
    }

    private tick(realDt: number) {
        this.accumulator += realDt;
        while (this.accumulator >= TICK_MS) {
            // Snapshot inputs for this fixed step; clear buffer (latest input wins per account)
            const inputs = new Map(this.inputBuffer);
            this.inputBuffer.clear();
            const next = step(this.toWorldState(), inputs, TICK_MS);
            this.applyToColyseusState(next);
            this.accumulator -= TICK_MS;
        }
    }
}
```

### Pattern 4: HTTP login → WS upgrade with session token (D-05/D-06)

**What:** Better-Auth handles cookie sessions on the Express side; the client opens `wss://server/colyseus?token=<bearer>`, Colyseus's `onAuth` reads `options.token`, calls `auth.api.getSession({ headers })`, and on success returns `{ account_id, username, role }`.

**Example:**
```typescript
// apps/server/src/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { hash, verify } from "argon2"; // or @node-rs/argon2 — both expose hash()/verify()
import { db } from "./db.js";
import { eq } from "drizzle-orm";
import { accounts, legacyCredentialsStaging } from "@rebno/db";

const ARGON2 = {
    memoryCost: 65536, // 64 MiB — OWASP 2026 minimum for Argon2id
    timeCost: 3,
    parallelism: 4,
    type: 2 /* argon2id */,
};

export const auth = betterAuth({
    database: drizzleAdapter(db, { provider: "sqlite" }),
    emailAndPassword: {
        enabled: true,
        password: {
            // Source: Context7 /better-auth/better-auth — Custom Argon2 Password Hashing
            hash: async (password: string) => hash(password, ARGON2),
            verify: async ({ hash: h, password }) => {
                // 1. Try standard argon2 verify against accounts.passwordHash
                if (h.startsWith("$argon2")) return verify(h, password); // happy path
                // 2. Legacy-staging fallback (D-08, SRV-10/11)
                // — caller passes the legacy_credentials_staging.legacy_hash here when
                //   accounts row is absent. Algorithm dispatch in caller (signIn hook).
                return false;
            },
        },
    },
    // Better-Auth handles session cookie + getSession({ headers }) — D-05/D-06
});
```

```typescript
// apps/server/src/RebnoRoom.ts (onAuth excerpt)
import { ServerError } from "colyseus";
import { auth } from "./auth.js";

async onAuth(client: Client, options: { token?: string }, request: any) {
    if (!options?.token) throw new ServerError(4401, "missing_session_token");
    const session = await auth.api.getSession({
        headers: new Headers({ Authorization: `Bearer ${options.token}` })
    });
    if (!session?.user) throw new ServerError(4401, "invalid_session");
    return {
        account_id: session.user.id,
        username: session.user.username ?? session.user.email,
        role: (session.user as any).role ?? "player",
        force_reset: (session.user as any).force_reset ?? false,
    };
}
```

### Pattern 5: Reconnection grace via `allowReconnection`

**What:** Colyseus's built-in mechanism. On unexpected disconnect, call `allowReconnection(client, seconds)` from `onLeave`; the same session token can resume the player slot within the window.

**Example:**
```typescript
// Source: Context7 /colyseus/docs — Implement a Colyseus Room
async onLeave(client: Client, consented: boolean) {
    const player = this.state.players.get(client.sessionId);
    if (!player) return;
    if (consented) {
        this.state.players.delete(client.sessionId);
        return;
    }
    // SRV-06: 10 s reconnect grace
    try {
        await this.allowReconnection(client, 10);
        // Reconnected — keep player in state (no-op here; the client resumes ws)
    } catch {
        // Timeout — flush + remove
        await this.persistCharacter(player);
        this.state.players.delete(client.sessionId);
    }
}
```

### Pattern 6: RoomRegistry + Ed25519 signed manifests + `fs.watch` hot-reload

**What:** D-09..D-13 collapsed into one component. Layouts live as content-addressed JSON on the FS; `fs.watch` notices atomic-rename writes; debounce 200 ms; re-verify Ed25519; broadcast `s2c.room_layout`.

**Example:**
```typescript
// apps/server/src/RoomRegistry.ts
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { createHash, verify } from "node:crypto";
import path from "node:path";
import { layoutSchema } from "@rebno/protocol"; // zod
import { encodeS2C } from "@rebno/protocol";
import pino from "pino";

const log = pino();

export class RoomRegistry {
    private layouts = new Map<string, { rev: string; bytes: Uint8Array; sig: Uint8Array }>();
    private debounceTimers = new Map<string, NodeJS.Timeout>();
    private pubKey: ReturnType<typeof import("crypto").createPublicKey>;

    constructor(private dir: string, private pubKeyPem: string, private onChange: (room_id: string, layout: any) => void) {
        // crypto.createPublicKey(pubKeyPem) — Ed25519 pubkey
    }

    async scan() {
        // walk dir, load all latest revs
    }

    startWatching() {
        watch(this.dir, { recursive: true }, (event, filename) => {
            if (!filename) return;
            const room_id = filename.split(path.sep)[0];
            // Debounce 200ms per D-13 (atomic-rename pairs of .json + .sig arrive close together)
            const existing = this.debounceTimers.get(room_id);
            if (existing) clearTimeout(existing);
            this.debounceTimers.set(room_id, setTimeout(() => this.reload(room_id), 200));
        });
    }

    private async reload(room_id: string) {
        // 1. read latest <rev>.json + <rev>.sig
        // 2. verify Ed25519: verify(null, Buffer.concat([Buffer.from(room_id), Buffer.from(rev), sha256(json)]), pubKey, sig)
        // 3. zod validate
        // 4. on success: this.layouts.set(...) + this.onChange(room_id, layout)
        // 5. onChange wired to RebnoRoom which broadcasts s2c.room_layout to room members
    }
}
```

### Pattern 7: Token-bucket rate limiter

**What:** `Map<` `${account_id}|${msg_type}`, `{ tokens: number; lastRefill: number }`> with refill/burst per D-22.

**Example:**
```typescript
// apps/server/src/rate-limit.ts
const RATES: Record<string, { rate: number; burst: number }> = {
    input: { rate: 25, burst: 35 },
    chat_send: { rate: 2, burst: 5 },
    room_join: { rate: 1, burst: 2 },
    heartbeat: { rate: 2, burst: 4 },
    auth: { rate: 0.1, burst: 3 },
};

export class TokenBucket {
    private buckets = new Map<string, { tokens: number; lastRefill: number }>();
    private muteUntil = new Map<string, number>();
    private dropStreaks = new Map<string, { count: number; since: number }>();

    take(account_id: string, msg_type: string, now = performance.now()): { ok: boolean; reason?: string } {
        const key = `${account_id}|${msg_type}`;
        const muted = this.muteUntil.get(key);
        if (muted && now < muted) return { ok: false, reason: "muted" };
        const cfg = RATES[msg_type] ?? RATES.input;
        const b = this.buckets.get(key) ?? { tokens: cfg.burst, lastRefill: now };
        const elapsed_s = (now - b.lastRefill) / 1000;
        b.tokens = Math.min(cfg.burst, b.tokens + elapsed_s * cfg.rate);
        b.lastRefill = now;
        if (b.tokens >= 1) {
            b.tokens -= 1;
            this.buckets.set(key, b);
            this.dropStreaks.delete(key);
            return { ok: true };
        }
        // Drop. Track streak; mute after 10 s of continuous drops.
        const streak = this.dropStreaks.get(key) ?? { count: 0, since: now };
        streak.count++;
        if (now - streak.since > 10_000) {
            this.muteUntil.set(key, now + 60_000);
            this.dropStreaks.delete(key);
            return { ok: false, reason: "rate_limit_mute" };
        }
        this.dropStreaks.set(key, streak);
        this.buckets.set(key, b);
        return { ok: false, reason: "rate_limited" };
    }
}
```

### Pattern 8: SIGTERM grace handler

**What:** D-16 enumerates exact 6-step sequence. better-sqlite3's `db.close()` is synchronous and fsyncs the WAL.

**Example:**
```typescript
// apps/server/src/sigterm.ts
import { db } from "./db.js";

export function installSigtermHandler(server: { gameServer: any; rooms: () => any[] }) {
    let draining = false;
    process.on("SIGTERM", async () => {
        if (draining) return;
        draining = true;
        // (a) stop accepting new connections — Colyseus has gracefulShutdown()
        // (b) emit error to all clients
        for (const room of server.rooms()) {
            room.broadcast("error", { code: "SERVER_DRAINING", reconnect_after_ms: 30_000 });
        }
        // (c) flush characters in a single Drizzle txn
        await flushAllCharactersToDb();
        // (d) close db (synchronous fsync on WAL)
        db.close();
        // (e) give Litestream sidecar 2s
        await new Promise((r) => setTimeout(r, 2_000));
        // (f) exit
        process.exit(0);
    });
}
```

### Anti-Patterns to Avoid

- **Anti: Trusting client positions in `onMessage('move')`.** Original BNO did this (parity-checklist `client-trusted-position` rejected). Server validates `delta_t * max_speed + collision` before any state mutation. PITFALLS B1.
- **Anti: Putting chat history in `RoomState`.** Bloats every state diff; D-02 explicitly puts it in a per-room ring buffer outside the schema.
- **Anti: Reading `Math.random()` or `Date.now()` inside `packages/game-logic`.** Determinism breaks; lint-game-logic-purity.mjs catches this (D-25).
- **Anti: Per-tick database writes.** D-14 forbids; saturates SQLite single-writer at any concurrent CCU. Event-driven only.
- **Anti: `synchronous=FULL` SQLite pragma.** Per Litestream docs, `synchronous=NORMAL` is the recommended setting with WAL — `FULL` adds fsync per commit and kills throughput; durability is provided by Litestream replication, not per-write fsync.
- **Anti: Ed25519 npm dep when Node `crypto` already does it.** Node 16+ has `crypto.sign('ed25519', ...)`/`crypto.verify`; zero deps.
- **Anti: `ws.Server` instead of Colyseus's transport.** Colyseus owns the WS upgrade path; mixing breaks `onAuth`.
- **Anti: Faithfully porting opcode 12 (`mod-execute`) for legacy parity.** Permanently rejected per parity-checklist; the wire path itself is gone. CLAUDE.md hard rule #3.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| WS framing + binary delta sync | Custom `writeByte`/`readByte` à la 39dll | `@colyseus/schema 4.0.23` | Decorator schema → automatic delta encoder; the entire reason 39dll docs/wiki/08 took 3 weeks to reverse |
| Auth sessions | Custom cookie + bcrypt + CSRF + reset-password flow | Better-Auth 1.6.9 | Lucia is deprecated; Better-Auth ships sign-in/sign-up/sessions/password-reset/email-verification + custom-hash hook |
| Argon2 hashing | Custom KDF | `argon2` 0.44.0 (or `@node-rs/argon2`) — wired via Better-Auth `password.{hash,verify}` | OWASP 2026 #1; native binding |
| Reconnection grace | Custom session-id mapping + ws-resume | Colyseus `allowReconnection(client, 10)` | Already handles the 10 s grace + ws-resume — D-07/SRV-06 |
| State delta encoding | Custom JSON-Patch / msgpack-delta | `@colyseus/schema` automatic | Same answer as row 1; do not separate |
| Heartbeat | Custom ping/pong frames | Colyseus `pingInterval` + `pingMaxRetries` | Already on the same WS; tune to 15 s for SRV-06 |
| Token bucket | npm `rate-limiter-flexible` | The 30-line in-process Map | <50 CCU, no Redis, D-22 spec is unambiguous |
| Ed25519 signing | npm `tweetnacl` / `@noble/ed25519` | Node `crypto.sign('ed25519', ...)` / `crypto.verify` | Native since Node 16; zero deps |
| Migration runner | Custom SQL-file walker | `drizzle-kit` (already in `tools/db-schema`) | Phase 3 already pinned 0.31.10 |
| WAL replication | Custom backup script | Litestream sidecar (Phase 5 ships) | Designed by Fly team for this exact pattern; ADR 0002 locks |
| zlib codec | Custom deflate | msgpackr (built-in compression option) | Already a dep |
| Determinism RNG | `Math.random()` | splitmix64 in `state.rng_state` | Pure-function discipline (D-20) |
| Express middleware | Custom auth middleware | Better-Auth `auth.handler` mounts on Express directly | One-line mount per Better-Auth docs |
| Typed admin intents | Re-port Ctrl+E clipboard exec | `apps/server/src/admin-stubs.ts` TODO + `docs/extracted-server/admin-anti-port.md` modernized intent shapes | SRV-12 hard rule |

**Key insight:** Phase 4 introduces three integration points (Colyseus, Better-Auth, Drizzle/SQLite) that each subsume a hand-rolled equivalent the original 39dll codebase had to build by hand. The phase value is wiring these together correctly, not reinventing them. **Pattern**: every "I could write this in 50 lines" temptation in this phase has a battle-tested library upstream — except the four narrow custom pieces (token-bucket map, accumulator wrap of setSimulationInterval, splitmix64 RNG, fs.watch debouncer) which are all <50 lines each.

## Runtime State Inventory

> Phase 4 is greenfield in scope: it CREATES new TS modules, NEW SQLite tables, NEW filesystem layouts, NEW workspace packages. It does not rename or migrate existing runtime systems. The only "migration" is the one-shot `pnpm migrate:legacy-accounts` (D-17) which reads `legacy/servers/enlyzeam-current/localList.txt` and populates `legacy_credentials_staging` — that is a NEW write, not a re-write of existing state.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — Phase 4 is the first write to `/data/rebno.db`. Legacy data lives in `legacy/servers/enlyzeam-current/*.bnu/*.bnb/*.txt` (forensic preservation, NEVER read by the runtime except by the one-shot migrate-legacy-accounts CLI). | Code edit: implement `apps/server/scripts/migrate-legacy-accounts.ts` per D-17. NO data migration of `.bnu` (deferred to Phase 7 PAR-05). |
| Live service config | None — no Fly machines, no n8n, no datadog, no Tailscale, no Cloudflare. Phase 4 is purely local-dev shippable. Phase 5 introduces Fly. | None for Phase 4. |
| OS-registered state | None — no Windows tasks, no pm2, no launchd, no systemd. Local dev = `pnpm --filter server dev` via `tsx watch`. | None for Phase 4. |
| Secrets / env vars | NEW: `ROOM_SIGNING_PRIVATE_KEY_PATH` (default `/data/keys/room_signing.ed25519`), `BETTER_AUTH_SECRET`, `DATABASE_URL` (default `/data/rebno.db`). All NEW — no rename of existing keys. Phase 5 will inject via `flyctl secrets set`. | Code edit: document the env-var schema in `apps/server/src/env.ts` (zod-validated). For dev, ship `apps/server/.env.example`. |
| Build artifacts | NEW: workspace `node_modules/.pnpm/`, `packages/*/dist/`, `apps/server/dist/`, `tools/room-converter/dist/`. Stale: NONE — packages are NEW. | None — clean greenfield builds. |

**Special note on `tools/db-schema` → `packages/db` promotion:** Phase 3 plan 03-06 created `tools/db-schema/` with the Drizzle table definitions. Phase 4 needs them as a workspace package (`packages/db/`) for `apps/server` to import via `workspace:*`. Two viable shapes:
1. **Move + adopt** (recommended): Move `tools/db-schema/src/tables.ts` → `packages/db/src/tables.ts`; the `tools/db-schema/` directory becomes empty/deleted. Update `package.json` script `db:emit-check` paths.
2. **Copy + drift-guard**: Keep `tools/db-schema` standalone (preserves Phase 1 D-17 boundary); copy `tables.ts` into `packages/db/` at build time; lint-drift-guards. More overhead.

Recommend (1) — `tools/db-schema` was always a Phase-3 staging area for the Phase-4 promotion (the package.json `description` literally says "consumed by Phase 4 SRV-01..03 via file copy or workspace import. NOT a runtime package"). Promotion is the cleanest answer; planning lays out the migration steps + script-path updates.

## Common Pitfalls

### Pitfall 1: Float math determinism between Node V8 and headless-Chromium V8

**What goes wrong:** Phase 6 client predicts movement by wrapping `packages/game-logic.step()`; if Node and browser produce different float results, server reconciliation looks like packet loss to the client.

**Why it happens:** Different JIT optimizations, different SIMD paths, different transcendental implementations. *Generally* IEEE 754 is deterministic for `+`, `-`, `*`, `/`, but `Math.sin/cos/tan/exp/log` have implementation-defined precision.

**How to avoid:** D-20 already commits to NOT using fixed-point. Mitigation:
1. Avoid transcendental functions in `step()`. Movement + collision needs no `sin`/`cos` — keep input axes as `-1|0|1` and integer/rational platform positions.
2. Lint or grep for `Math.sin`, `Math.cos`, `Math.atan2`, `Math.sqrt` in `packages/game-logic/src/**` and require justification for each (or pre-compute lookup tables in `state.rng_state` style).
3. Golden-trajectory test in vitest runs same module under Node AND under headless-Chromium; CI fails if any drift.

**Warning signs:** Phase 6 client sees frequent server reconciliations even on a perfect connection.

### Pitfall 2: Colyseus `setSimulationInterval` callback cadence drift

**What goes wrong:** Default 16.6 ms callback gets called at varying real intervals depending on event-loop pressure; if `step()` is called once-per-callback with `dt = realDt`, simulation speed varies with server load.

**How to avoid:** D-21 + Pattern 3 — use the accumulator wrap. `setSimulationInterval` callback only ever drives the accumulator; `step()` is called integer-times-per-callback with `dt_ms = 50` constant.

**Warning signs:** Players "warp" or "stutter" when server is under load.

### Pitfall 3: Colyseus `patchRate` mismatch with simulation rate

**What goes wrong:** If `patchRate` (state-diff broadcast cadence) is faster than simulation rate, clients see redundant frames; if slower, client interpolation smooths over server updates.

**How to avoid:** Set `setPatchRate(50)` to match the 20 Hz simulation. Per Colyseus docs, default patchRate is 50 ms — already correct.

### Pitfall 4: Fly proxy WS idle timeout vs Colyseus ping interval (DEP-08, forward-flag)

**What goes wrong:** Fly proxies WS with a 60 s idle-connection cutoff. If Colyseus's `pingInterval` > 60 s, the proxy closes the socket mid-session.

**How to avoid:** Phase 5 DEP-08 explicitly tunes this; Phase 4 needs to set Colyseus `pingInterval = 15_000` (15 s, matches SRV-06 heartbeat). Per STACK.md "Fly proxies WS by default — no special config needed. But the Fly proxy applies a 60 s idle-connection timeout; Colyseus's built-in ping (default 3 s) keeps connections alive easily. Just make sure you don't disable Colyseus's `pingInterval`."

**Warning signs:** Phase 6 client sees spurious disconnects every 60 s.

### Pitfall 5: argon2 native module on Alpine/musl (Phase 5 forward-flag)

**What goes wrong:** Phase 5 DEP-01 builds an Alpine/musl Docker image; `argon2 0.44.0` ships pre-builds for `linux-x64-glibc` but historically had musl rebuild requirements.

**How to avoid:** STACK.md note: "Pre-builds for linux-x64-musl available — important for Fly's default image." Verify in Phase 4 dev Dockerfile. Fallback: switch to `@node-rs/argon2 2.0.2` (pure-Rust binding, ships musl pre-builds reliably).

**Warning signs:** Phase 5 Dockerfile build fails with `argon2.node` ABI errors.

### Pitfall 6: SQLite `synchronous=FULL` vs `NORMAL` durability tradeoff

**What goes wrong:** D-15 picks `synchronous=NORMAL` for ~3× write throughput. `NORMAL` accepts a tiny crash-window between fsync calls; if the OS crashes between `db.exec(...)` and the next checkpoint, that transaction MAY be lost.

**How to avoid:** This is the correct setting per Litestream docs ("For use with WAL, `PRAGMA synchronous = NORMAL` is generally considered safe and provides a good balance"). Litestream + Tigris is the durability tier, not per-write fsync. Document loudly in `apps/server/src/db.ts` so a future maintainer doesn't "fix" it to FULL.

**Warning signs:** Profiler shows fsync latency dominating tick budget — that's how you'd notice if someone flipped to FULL.

### Pitfall 7: `fs.watch` `recursive: true` double-fires on atomic rename

**What goes wrong:** Atomic rename = `unlink` + `rename`; `fs.watch` may fire twice (once for the temp file removal, once for the rename target). Without debounce, `RoomRegistry.reload()` runs twice and may broadcast two `s2c.room_layout` frames.

**How to avoid:** D-13 already mandates 200 ms debounce per room_id key. The 200 ms is enough for the `.json` and `.sig` pair to both arrive.

**Warning signs:** Hot-reload broadcasts twice in quick succession.

### Pitfall 8: `legacy_credentials_staging` race — two simultaneous logins of the same legacy user

**What goes wrong:** User has a row in `legacy_credentials_staging`; two browsers attempt sign-in at the same instant; both pass the verify hook; both try to INSERT into `accounts` and DELETE from staging — second one fails with a unique-constraint violation.

**How to avoid:** Wrap the rehash + insert + delete in a single Drizzle transaction (D-08 already says "in one Drizzle transaction"). better-sqlite3 transactions are serialized — second one waits or fails. Catch the unique-constraint and treat as "already-rehashed; verify against new accounts.passwordHash instead."

### Pitfall 9: Colyseus 0.17 default `pingInterval` is 3 s — heartbeat ack ≠ heartbeat ping

**What goes wrong:** SRV-06 says "15 s heartbeat / 10 s reconnection grace" but Colyseus's transport-level ping is 3 s by default. Two different concepts.

**How to avoid:** Two ping channels:
1. Colyseus transport ping (3 s default) — keeps WS alive against Fly's 60 s timeout. Don't change.
2. Application heartbeat (`c2s.heartbeat` per D-02) — every 15 s; server uses it as the trigger to refresh Better-Auth session sliding window (D-07).

The "15 s heartbeat" in SRV-06 is the application one; Colyseus's transport ping is a separate orthogonal mechanism.

### Pitfall 10: Better-Auth schema regeneration drift

**What goes wrong:** D-08 says regenerate auth tables via `@better-auth/cli generate`. If config changes and someone forgets to regenerate, runtime queries against old columns fail at first sign-in.

**How to avoid:** D-25 `lint-better-auth-schema-sync.mjs` exits non-zero if the regenerated output differs from the committed file. Wire into `pnpm verify:phase-4` (parallel to Phase 3 plan 03-06's drizzle-kit emit-check pattern).

## Code Examples

(See Patterns 1–8 above for the full library of verified code examples. All patterns reference Context7 source URLs.)

### Verified: Better-Auth custom argon2id hook

```typescript
// Source: Context7 /better-auth/better-auth — "Custom Argon2 Password Hashing and Verification Functions"
// Verified 2026-05-06
import { hash, type Options, verify } from "@node-rs/argon2";

const opts: Options = {
    memoryCost: 65536,   // 64 MiB
    timeCost: 3,
    parallelism: 4,
    outputLen: 32,
    algorithm: 2,        // Argon2id
};

export async function hashPassword(password: string) {
    return hash(password, opts);
}
export async function verifyPassword(data: { password: string; hash: string }) {
    return verify(data.hash, data.password, opts);
}
```

### Verified: Colyseus 0.17 Room skeleton

```typescript
// Source: Context7 /colyseus/docs — "Implement a Colyseus Room"
// Verified 2026-05-06
import { Room, Client, AuthContext } from "colyseus";

export class MyRoom extends Room {
    onCreate(options: any) {
        this.setSimulationInterval((dt) => this.update(dt), 1000 / 60);
    }
    async onAuth(client: Client, options: any, context: AuthContext) {
        // validate token; return userdata or throw
    }
    onJoin(client: Client, options: any, auth: any) { /* add to state */ }
    async onDrop(client: Client) { await this.allowReconnection(client, 20); }
    onReconnect(client: Client) { /* mark connected */ }
    onLeave(client: Client) { /* remove from state */ }
    onDispose() {}
    update(dt: number) { /* game loop */ }
}
```

(Adapted to RebnoRoom in Pattern 3 above.)

### Verified: Litestream synchronous=NORMAL guidance

```ini
# Source: Context7 /benbjohnson/litestream — "Critical SQLite Behaviors > Synchronous Mode"
# Verified 2026-05-06
# "For use with WAL, PRAGMA synchronous = NORMAL is generally considered safe
#  and provides a good balance between performance and durability, ensuring
#  that data is written to the WAL file before the transaction is committed."
```

```typescript
// apps/server/src/db.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "@rebno/db";

const sqlite = new Database(process.env.DATABASE_URL ?? "/data/rebno.db");
sqlite.pragma("journal_mode = WAL");          // Litestream prerequisite (D-15)
sqlite.pragma("synchronous = NORMAL");        // Litestream-recommended balance
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("busy_timeout = 5000");

export const db = drizzle(sqlite, { schema });
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Lucia auth | Better-Auth | March 2025 (Lucia officially deprecated) | Phase 4 uses Better-Auth 1.6.9 [VERIFIED: STACK.md + Lucia GitHub deprecation notice] |
| bcrypt | Argon2id (OWASP 2026 #1) | OWASP 2026 cheat sheet | Phase 4 argon2id from packet 1 [CITED: cheatsheetseries.owasp.org/Password_Storage] |
| Custom WS framing | Colyseus + @colyseus/schema | — | Phase 4 cuts ~6-10 weeks vs hand-rolled per STACK.md |
| Postgres for stateful WS server | better-sqlite3 + Litestream | Fly.io 2024 "All in on SQLite + Litestream" blog | Phase 4 SQLite per ADR 0002 |
| `@msgpack/msgpack` | `msgpackr` | 2024 perf benchmarks | Phase 4 msgpackr per STACK.md |
| Drizzle 0.44 | Drizzle 0.45.2 | — | Phase 3 03-06 already pins 0.45.2 |
| Phaser 3.50 / 4 beta | Phaser 3.90 (locked Phase 6) | ADR 0001 | Phase 4 doesn't depend on this; Phase 6 consumes packages/protocol + game-logic |

**Deprecated/outdated for Phase 4:**
- Socket.IO — wrong tool; Colyseus uses raw `ws`
- Plaintext-password storage — CLAUDE.md hard rule #2
- Ctrl+E clipboard admin — CLAUDE.md hard rule #3
- 39dll-style "the call order IS the protocol" — explicitly replaced by typed Schema + zod intents

## Recommended CONTEXT.md Overrides

Per CONTEXT.md "Override any decision in planning if research or codebase reading surfaces a conflict," I am flagging exactly **one factual error** for the planner to fix:

### O-01 — `packages/db` does NOT yet exist; it must be CREATED in Phase 4

**CONTEXT.md says (D-18 + Code Insights):**
> `packages/db/` (Phase 3 plan 03-06 already created this — Phase 4 EXTENDS rather than recreates; Better-Auth tables added per D-08)
>
> `packages/db/tables.ts` + `packages/db/migrations/0001_baseline.sql` (Phase 3 plan 03-06) — already a workspace package in nascent form

**Reality on disk (verified 2026-05-06):**
- `packages/` directory does NOT exist
- `tools/db-schema/` exists with `src/tables.ts` (the Drizzle schema, hand-authored), `migrations/0001_baseline.sql`, its own `package.json` (`name: "db-schema"`, NOT `@rebno/db`, NOT a workspace package), `tests/`, `vitest.config.ts`
- The `tools/db-schema/package.json` `description` field literally reads: *"Phase 3 SDOC-03/04 schema authoring artifact. Drizzle table definitions + 0001_baseline.sql migration consumed by Phase 4 SRV-01..03 via file copy or workspace import. NOT a runtime package; NOT a wiring layer. Phase 4 owns the runner."*
- Root `package.json` has `pnpm` scripts pointing at `tools/db-schema/`, no `pnpm-workspace.yaml` exists yet
- `apps/` directory does NOT exist

**Required override:**
- Phase 4 plan must include creating `pnpm-workspace.yaml` for the first time
- Phase 4 plan must include creating `packages/db/` for the first time (recommend MOVING `tools/db-schema/src/tables.ts` → `packages/db/src/tables.ts`, OR copying with drift-guard — see Runtime State Inventory)
- Update root `package.json` scripts that reference `tools/db-schema/` to reference `packages/db/` (`db:generate`, `db:test`, `db:emit-check`, `lint:schema-sync`, `lint:source-comments`)
- The Drizzle schema and migration are reusable AS-IS — only the package location moves

**Impact if not addressed:** Plan tasks would write `packages/db/auth-tables.ts` next to a non-existent `packages/db/tables.ts`, then `apps/server` `import { accounts } from "@rebno/db"` would fail at typecheck. Discoverable in the first build, but planning that assumes the package exists wastes a node-repair cycle.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Promoting `tools/db-schema` → `packages/db` is the right shape (vs. keeping standalone with build-time copy) | Override O-01 + Runtime State Inventory | Low — both shapes work; promotion is simpler. If user prefers standalone-with-copy, swap in planning |
| A2 | `argon2 0.44.0` will build cleanly on Alpine in Phase 5; we don't need to switch to `@node-rs/argon2` in Phase 4 | Standard Stack + Pitfall 5 | Medium — if Phase 5 musl build fails, Phase 4 needs a same-day rev to swap deps. Mitigation: write `apps/server/src/auth.ts` against the smallest common API surface (`hash(pw, opts)` + `verify(hash, pw)`) so the swap is one-import-line |
| A3 | Float math is sufficient for determinism between Node and headless-Chromium V8 (D-20 explicitly accepts this; not researched cross-browser yet) | Pitfall 1 | Medium — could surface in Phase 6 cross-browser; D-20 already documents the fallback (revisit if so) |
| A4 | Colyseus 0.17.10 + `@colyseus/schema 4.0.23` are compatible (CONTEXT.md said 4.0.21) | Standard Stack | Low — patch bump within same minor; backwards compatible per semver |
| A5 | `pino 9.x` is fine; we don't need `pino 10.3.1` | Standard Stack | Low — STACK.md pinned 9.x; either works |
| A6 | The MVP `mvp-lobby` will be picked from `extracted/client-5-8/rooms/` during planning, not researched here | D-12 | Low — picking the smallest navigable room is mechanical |
| A7 | `tools/save-format-doc/output/save-formats.ts` actually exports a `localList.txt` parser (CONTEXT.md asserts this; not verified by reading the file) | Architecture (legacy migration) | Low — Phase 3 plan 03-03 should have produced it; if absent, Phase 4 plan needs a "write the parser" task. Verify at planning kickoff |
| A8 | Better-Auth's Drizzle adapter generates table definitions that don't conflict with the existing hand-authored `accounts` table column names (`id`, `username`, `passwordHash`, `email`) | D-08, ADR 0002 §"Better-Auth integration" | Medium — ADR 0002 already flags this as "partial overlap; reconciliation in Phase 4". Worst case: Phase 4 plan adds a column-rename migration |
| A9 | `colyseus.js` client is NOT a Phase 4 deliverable (Phase 6 imports it) — but its version must match the server's `colyseus@0.17.10` | Standard Stack | Low — phase 6 problem; flag for planning |
| A10 | Workspace name convention `@rebno/db`, `@rebno/protocol`, `@rebno/game-logic`, `@rebno/server` (vs unprefixed `db`, `protocol`, ...) | Standard Stack §Installation | Low — convention; either works. Recommend `@rebno/*` for clarity |

**If this table is empty:** All claims in this research were verified or cited — no user confirmation needed. **(Table is not empty; A1, A2, A7, A8 should be reviewed at planning.)**

## Open Questions (RESOLVED)

1. **Should `tools/db-schema` be deleted after `packages/db` is created, or kept as a Phase-3 historical artifact?**
   - What we know: Phase 1 D-17 + Phase 3 D-22 establish the pattern of "tools/* are standalone, packages/* are workspace." `tools/db-schema` was an interim authoring artifact that violates this rule (it's not a tool that's standalone; it's a schema staging area).
   - What's unclear: Whether to delete it (clean) vs leave a redirect README (preserves Phase 3 plan 03-06 references).
   - **RESOLVED:** Move the source files; leave `tools/db-schema/README.md` saying "Promoted to `packages/db/` in Phase 4 plan 04-NN; see commit `<sha>`." Preserves Phase 3 history.

2. **Should `c2s.input_ack` be embedded in `PlayerState.last_input_seq` (D-01) or sent as a separate s2c event (D-02)?**
   - What we know: CONTEXT.md D-02 lists both approaches: `s2c.input_ack { seq }` "(alternative to embedding in PlayerState if it bloats the schema)."
   - What's unclear: Whether `last_input_seq` in the schema is bandwidth-cheaper than a per-input ack message.
   - **RESOLVED:** Embed in `PlayerState.last_input_seq` (already in the schema per D-01). Reason: state diff is sent every patch tick anyway; an ack adds no extra bytes when the player is moving. Skip the separate event unless Phase 6 client-prediction reconciliation surfaces an issue.

3. **What is the chat-rate-limit interaction when a force-reset user is in the password-change overlay?**
   - What we know: D-07 says force-reset is in-room overlay; D-22 rate-limits chat at 2/5.
   - What's unclear: Whether the user can still spam chat during the overlay.
   - **RESOLVED:** Plan a "muted_until_password_change" flag in PlayerState; chat dropped silently during overlay. Trivial — flag for planning.

4. **`extracted/client-5-8/rooms/` room format — can `tools/room-converter` consume it directly or does it need an intermediate format?**
   - What we know: Phase 1 D-19 emits one-file-per-resource; rooms are emitted (per EXT-03 success).
   - What's unclear: The schema of the emitted room JSON (would need to inspect a real file at planning).
   - **RESOLVED:** First task in `tools/room-converter` plan = inspect `extracted/client-5-8/rooms/<smallest-room>/room.json` to confirm shape; if it's already the canonical layout schema, the converter is mostly a passthrough.

5. **Should the dev Dockerfile be in `apps/server/Dockerfile` or repo root?**
   - What we know: D-18 mentions `apps/server/Dockerfile`. Phase 5 hardens it for Fly.
   - What's unclear: Multi-stage layout for monorepo (workspace deps need root pnpm install).
   - **RESOLVED:** `apps/server/Dockerfile` with COPY of root + workspace, multi-stage. Plan one task with the Dockerfile blueprint.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Everything | ✓ | (existing in repo node_modules; verify ≥22 LTS at planning kickoff) | — |
| pnpm | Workspace tooling | ✓ | (existing pnpm-lock.yaml in repo root) | — |
| `tools/protocol-doc/output/protocol.ts` | D-19 build-time copy | ✓ | committed to repo | — |
| `tools/save-format-doc/output/save-formats.ts` | D-17 legacy parser | ✓ | committed to repo | — |
| `tools/db-schema/src/tables.ts` | Override O-01 promotion source | ✓ | committed | — |
| `docs/extracted-server/parity-checklist.json` | MVP cherry-pick (D-21 anti-port stubs) | ✓ | committed (verified — 80+ rows, mvp-true filter for movement/chat/login/heartbeat/room-join/room-leave) | — |
| `docs/extracted-server/admin-anti-port.md` | SRV-12 stub source | ✓ | committed | — |
| `docs/adr/0002-persistence-layer.md` | ADR locked | ✓ | committed | — |
| `docs/adr/0003-canonical-snapshot.md` | enlyzeam-current is canonical | ✓ | committed | — |
| `docs/adr/0001-client-engine.md` | Phaser 3.90 (Phase 6 only; informational here) | ✓ | committed | — |
| `legacy/servers/enlyzeam-current/localList.txt` | D-17 migration source | needs verification at planning kickoff (PII not stored in git per CLAUDE.md `.gitignore legacy/`) | — | None — must exist on machine running migrate |
| `extracted/client-5-8/rooms/` | tools/room-converter source | needs verification at planning kickoff (Phase 1 emit) | — | None — Phase 1 must have produced |
| `argon2 0.44.0` Alpine pre-build | Phase 5 forward-flag | unverified for Phase 4 | — | Switch to `@node-rs/argon2 2.0.2` |

**Missing dependencies with no fallback:**
- None — all critical phase-4 inputs exist in repo or are planning-kickoff verifiable.

**Missing dependencies with fallback:**
- `argon2` musl support → `@node-rs/argon2` (one-import-line swap)

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `vitest 4.1.5` (already adopted in `tools/db-schema`; standardize for Phase 4) |
| Config file | `apps/server/vitest.config.ts`, `packages/protocol/vitest.config.ts`, `packages/game-logic/vitest.config.ts` (see Wave 0) |
| Quick run command | `pnpm --filter <package> test` (per-package) or `pnpm -r test` (all) |
| Full suite command | `pnpm verify:phase-4` (composite gate per D-25 — runs `pnpm -r test` + 4 lints + drizzle-kit emit-check + traceable-reqs check) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| SRV-01 | Protocol package round-trips msgpackr; PROTOCOL_VERSION enforced on packet 1 | unit | `pnpm --filter @rebno/protocol test` | ❌ Wave 0 — `packages/protocol/test/codec.test.ts` |
| SRV-02 | step() pure: same inputs + state → byte-identical output across runs | unit | `pnpm --filter @rebno/game-logic test golden` | ❌ Wave 0 — `packages/game-logic/test/golden.test.ts` |
| SRV-02 | step() determinism Node vs browser | integration | `pnpm --filter @rebno/game-logic test:browser` (vitest browser mode or playwright) | ❌ Wave 0 |
| SRV-03 | apps/server boots; Colyseus serves /colyseus; /health responds 200 | integration | `pnpm --filter @rebno/server test boot` | ❌ Wave 0 — `apps/server/test/boot.integ.test.ts` |
| SRV-04 | Fabricated position rejected; chat sender field ignored — server tags from auth | integration | `pnpm --filter @rebno/server test authority` | ❌ Wave 0 — `apps/server/test/authority.integ.test.ts` |
| SRV-05 | 20 Hz fixed-tick: under simulated jitter, step() called integer-times-per-frame with dt=50 | unit | `pnpm --filter @rebno/server test tick` | ❌ Wave 0 — `apps/server/test/tick-accumulator.test.ts` |
| SRV-06 | Heartbeat (15 s) + reconnect grace (10 s) — drop client, re-open within 10 s, state preserved | integration | `pnpm --filter @rebno/server test reconnect` | ❌ Wave 0 — `apps/server/test/reconnect.integ.test.ts` |
| SRV-07 | Token-bucket: 50 inputs/sec → drops; sustained drops → mute; mute lifts at 60 s | unit | `pnpm --filter @rebno/server test rate-limit` | ❌ Wave 0 — `apps/server/test/rate-limit.test.ts` |
| SRV-08 | SIGTERM grace flushes characters; kill -9 mid-tick → on-restart, characters table valid | integration | `pnpm --filter @rebno/server test sigterm` (forks process + sends signal) | ❌ Wave 0 — `apps/server/test/sigterm.integ.test.ts` |
| SRV-09 | argon2id from packet 1 — sign-up writes `$argon2id$...` hash; verify roundtrip | integration | `pnpm --filter @rebno/server test auth` | ❌ Wave 0 — `apps/server/test/auth.integ.test.ts` |
| SRV-10 | Legacy account first-login: plaintext row in staging → silent rehash + drop staging in 1 txn | integration | `pnpm --filter @rebno/server test legacy-login` | ❌ Wave 0 — `apps/server/test/legacy-login.integ.test.ts` |
| SRV-11 | bcrypt-weak/plaintext → force-password-change overlay path | integration | (same file as SRV-10) | ❌ Wave 0 |
| SRV-12 | Admin-stubs throws NotImplemented; no Ctrl+E surface anywhere | unit + grep | `pnpm --filter @rebno/server test admin-stubs` + `pnpm lint:no-clipboard-rce` (grep for `execute_string`/clipboard intents) | ❌ Wave 0 — `apps/server/test/admin-stubs.test.ts`; lint script |
| SRV-13 | RoomRegistry: drop new layout file → fs.watch fires → broadcast received with valid Ed25519 sig; client bundle (Phase 6 — out of scope here, but assert via packages/protocol that `s2c.room_layout` carries layout_bytes; Phase 4 packages have ZERO room JSON in their `dist/`) | integration | `pnpm --filter @rebno/server test room-hot-reload` | ❌ Wave 0 — `apps/server/test/room-hot-reload.integ.test.ts` |
| SRV-14 | PlatformState present in RoomState schema even when zero platforms; step() advances platforms deterministically when present | unit | `pnpm --filter @rebno/protocol test schema-shape` + `pnpm --filter @rebno/game-logic test platform-cycle` | ❌ Wave 0 |
| ADR 0004 | Hot-reload contract committed | doc | `pnpm trace:check` (verifies `[doc->REQ-SRV-13]` tag in ADR) | ❌ Wave 0 — `docs/adr/0004-room-hot-reload.md` |
| Verify gate | All of the above + 4 lints + drizzle emit-check + trace check | composite | `pnpm verify:phase-4` | ❌ Wave 0 — `scripts/verify-phase-4.mjs` |

### Sampling Rate
- **Per task commit:** `pnpm --filter <touched-package> test` + the lint(s) for any artifact touched (e.g., touch a layout file → `lint-room-layout.mjs`)
- **Per wave merge:** `pnpm -r test && pnpm verify:phase-4 --quick` (test surface only, skip docker boot integration)
- **Phase gate:** `pnpm verify:phase-4` full + manual sanity (start server, two ws clients move + chat) before `/gsd-verify-work`

### Wave 0 Gaps
- [ ] `pnpm-workspace.yaml` — declare `apps/*`, `packages/*`
- [ ] `apps/server/package.json` + `vitest.config.ts` + `tsconfig.json`
- [ ] `packages/protocol/package.json` + `vitest.config.ts` + `tsconfig.json`
- [ ] `packages/game-logic/package.json` + `vitest.config.ts` + `tsconfig.json`
- [ ] `packages/db/package.json` + `vitest.config.ts` + `tsconfig.json` (post-promotion)
- [ ] `packages/protocol/test/codec.test.ts` — covers SRV-01
- [ ] `packages/game-logic/test/golden.test.ts` — covers SRV-02
- [ ] `apps/server/test/{boot,authority,tick-accumulator,reconnect,rate-limit,sigterm,auth,legacy-login,admin-stubs,room-hot-reload}.{,integ.}test.ts` — covers SRV-03..SRV-14
- [ ] `apps/server/test/test-utils.ts` — shared helpers: spawnServer fixture, fakeClient (ws + msgpackr), tmpdir for room files, in-memory SQLite
- [ ] Framework install: `pnpm -w add -D vitest@4.1.5` (already in `tools/db-schema`; standardize)
- [ ] Browser-mode test for SRV-02 cross-runtime determinism: either vitest browser-mode (Playwright provider) or Playwright spawned from vitest

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Better-Auth 1.6.9 + argon2id (D-05/D-08); CLAUDE.md hard rule #2 |
| V3 Session Management | yes | Better-Auth-managed sessions; 30-day sliding window (D-07); httpOnly + Secure + SameSite=Lax cookies |
| V4 Access Control | yes | Server-authoritative discipline (CLAUDE.md hard rule #1); Colyseus `client.auth` from `onAuth` is the ONLY identity source after handshake (D-06); `role` claim drives admin-stub authorization (when Phase 7 PAR-07 fills them in) |
| V5 Input Validation | yes | zod at every Colyseus `onMessage` boundary (D-04); zod on layout JSON before fs.watch broadcast (D-10) |
| V6 Cryptography | yes | argon2id (D-05); Ed25519 manifest signing via Node `crypto` (D-11); never hand-roll |
| V7 Error Handling | yes | pino-redact for sensitive fields (D-23); structured s2c.error codes never leak server internals (D-02) |
| V8 Data Protection | yes | SOPS not in scope for Phase 4 (Phase 5 secrets management); legacy_credentials_staging is read-once-then-purge (D-08/D-17) |
| V11 Business Logic | yes | Rate limiting per (account_id, msg_type) (D-22); SRV-04 fabrication test |

### Known Threat Patterns for Node + Colyseus + Better-Auth + SQLite

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Client fabricates position / chat origin | Spoofing + Tampering | Server reads `client.auth.account_id` from D-06; ignores any sender-id field on the wire (parity-checklist `client-trusted-pid`, `client-trusted-position`, `client-trusted-sprite-index`, `client-supplied-chat-origin` all rejected) |
| Bruteforce sign-in | Spoofing | Better-Auth has built-in rate limiting on auth endpoints + D-22 rate-limiter (`auth: 0.1/3`); argon2id memory cost slows offline cracking |
| Plaintext password leak via logs | Information Disclosure | pino redact config covers `*.password`, `*.password_hash`, `*.session_token` (D-23) |
| Forged room layout from compromised CDN/proxy | Tampering | Ed25519 signed manifests verified client-side against pinned pubkey (D-11) |
| WAL corruption from kill -9 mid-tick | Denial of Service | SQLite WAL mode + atomic writes survive (D-15); Litestream replication for volume loss |
| Malicious zod payload (deeply nested / huge string) | Denial of Service | zod schemas enforce maxLength on strings; D-22 rate-limiter prevents spam; Colyseus has built-in WS frame size limit (default 16KB) |
| RCE via faithfully ported Ctrl+E admin | Elevation of Privilege + RCE | Anti-port; opcode 12 wire path permanently removed; admin moved to typed Phase 7 web UI (CLAUDE.md hard rule #3) |
| Session fixation | Spoofing | Better-Auth rotates session token on sign-in by default |
| CSRF on /api/auth/* | Tampering | Better-Auth ships SameSite=Lax cookies + double-submit token by default |
| SQL injection | Tampering | Drizzle ORM parameterized queries throughout; no raw SQL string concat |
| Replay of legacy migration row after reset | Spoofing | D-08 single-transaction rehash + delete; first-login-wins; subsequent attempts find no staging row + no accounts row → standard "user not found" path |
| Path traversal in `apps/server/rooms/` (room_id with `..`) | Tampering | RoomRegistry validates room_id matches `^[a-z0-9-]{1,64}$`; rejects any path-separator char |

## Project Constraints (from CLAUDE.md)

These project-wide directives apply to every Phase 4 plan and task:

1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin. (Drives D-04, D-06, SRV-04 acceptance test.)
2. **No faithful port of plaintext passwords.** argon2id from packet 1. (Drives D-08, SRV-09, SRV-10, SRV-11.)
3. **No faithful port of "run clipboard as superuser" admin.** Replaced by Phase 7 web UI. (Drives SRV-12; admin-stubs.ts; opcode 12 wire-path-deletion.)
4. **`.bno`/`.bnb`/`.bnu` parsing requires extracted GML first.** Phase 4 only consumes already-extracted artifacts; does not re-parse raw legacy files. (Drives Pattern: `migrate-legacy-accounts.ts` imports `tools/save-format-doc` parser, doesn't re-derive.)
5. **39dll wire protocol = call order.** Phase 4 consumes the already-derived opcode table; never re-derives from packet captures. (Drives D-19 build-time copy of `tools/protocol-doc/output/protocol.ts`.)
6. **Extract → document → rewrite, in that order.** Phases 1-3 produced the schemas; Phase 4 consumes them; no new TypeScript before Phase 4 (now unblocked). (Authorizes Phase 4 to write its first runtime TS.)
7. **Modern decompilers cannot read GM 5.3a.** Not relevant to Phase 4 (Phase 1 problem).
8. **Repo stays private through Phase 7.** Phase 4 doesn't push anywhere public. Verify `legacy/` stays gitignored (it does).
9. **Strict TypeScript everywhere; shared types via `packages/protocol`.** (Drives D-19.)
10. **Per-stage tag form** `// [impl->REQ-SRV-03]`, `// [unit->REQ-SRV-03]`, `// [int->REQ-SRV-03]`. Tags live in code comments only. Phase 4 will set `required_stages = ["doc", "impl", "unit", "int"]` for SRV-01..SRV-14 in `traceable-reqs.toml` at plan time. (Drives every plan touching code.)
11. **Atomic commits per plan, REQ-IDs in messages.** (Existing convention.)
12. **`pnpm trace:check` must pass before phase complete.** (Drives `verify-phase-4.mjs` composite gate.)

## Sources

### Primary (HIGH confidence)
- **Context7 `/colyseus/docs`** — Room class, onAuth, onJoin, onMessage handler map, allowReconnection, setSimulationInterval, MapSchema/ArraySchema, ping(), AuthContext (verified 2026-05-06)
- **Context7 `/better-auth/better-auth`** — Custom Argon2 password hash hook, emailAndPassword config, getSession({headers}), drizzle adapter (verified 2026-05-06)
- **Context7 `/benbjohnson/litestream`** — synchronous=NORMAL guidance, journal_mode=WAL prerequisite, replicate command, S3 replication pattern (verified 2026-05-06)
- **npm registry** — `colyseus@0.17.10`, `@colyseus/schema@4.0.23`, `better-auth@1.6.9`, `argon2@0.44.0`, `@node-rs/argon2@2.0.2`, `better-sqlite3@12.9.0`, `drizzle-orm@0.45.2`, `msgpackr@2.0.1`, `zod@4.4.3`, `pino@10.3.1`, `vitest@4.1.5`, `ws@8.20.0` (all verified 2026-05-06)
- **Repo direct read** — `tools/db-schema/{src/tables.ts, package.json, migrations/}`, root `package.json`, `docs/adr/0002-persistence-layer.md`, `docs/extracted-server/parity-checklist.json`, `docs/extracted-server/protocol.md`, `.planning/phases/04-server-rebuild-mvp/04-CONTEXT.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, `.planning/research/STACK.md`, `.planning/codebase/CONCERNS.md`, `.planning/codebase/STRUCTURE.md`, `.planning/config.json`, `CLAUDE.md`

### Secondary (MEDIUM confidence)
- **STACK.md research notes** (2026-05-01) — Lucia deprecation March 2025, Phaser 3.90 vs 4.1 timing, Drizzle vs Prisma 2026 — verified against npm registry HEAD-of-line on 2026-05-06; some patch versions drifted (see `Recommended CONTEXT.md Overrides` and `Standard Stack`)

### Tertiary (LOW confidence)
- None — all phase-4-critical claims have either a Context7 source, an official-docs source, or repo-state verification

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every version verified by `npm view` 2026-05-06; one patch bump from CONTEXT.md (4.0.21 → 4.0.23)
- Architecture: HIGH — CONTEXT.md has 25 explicit decisions; this RESEARCH.md cites them by number and adds Context7-verified code patterns
- Auth integration: HIGH — Context7 verified the exact custom-hash hook signature and onAuth/getSession flow
- Persistence: HIGH — Litestream + better-sqlite3 + Drizzle stack already locked by ADR 0002; pragma guidance verified by Context7
- Hot-reload contract: MEDIUM — Pattern 6 is a synthesis from D-09..D-13 + Node `fs.watch`/`crypto` stdlib; no upstream library does the exact 5-step (fs.watch → debounce → reverify → zod → broadcast) flow as a single call, but each step is well-trodden
- Pitfalls: HIGH — pitfalls 1-10 each cite either Context7, Litestream docs, STACK.md, or CONTEXT.md
- Validation surface: HIGH — vitest 4.1.5 already adopted; Wave 0 gap list is mechanical
- Security domain: HIGH — every threat pattern has a named mitigation tied to a D-* row or library default

**Research date:** 2026-05-06
**Valid until:** 2026-06-06 (30 days — stack is mature; only `@colyseus/schema` patches and `msgpackr` 2.x evolution would shift answers)
