---
phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
plan: 12
type: doc-only
status: complete
input_for: 06-15-PLAN.md
source: .planning/debug/reconnect-blank-render.md
created: 2026-05-11
---

# Phase 6 Plan 12 — Reconnect / Cookie Auto-Login Blank-Render: Debug Findings

[doc->REQ-CLI-09] [doc->REQ-CLI-04]

> **Diagnose-only consolidation.** No source files were edited by this plan (06-12). This document is the
> input contract for **plan 06-15** (the fix plan). 06-15's executor reads §2 for root cause, §4 for fix
> shape, §5 for the secondary defect, and §6 for the required regression test checklist. The full
> diagnostic trail is preserved at `.planning/debug/reconnect-blank-render.md`.

---

## §0 Source + status

- **Original diagnostic:** `.planning/debug/reconnect-blank-render.md` — `status: root_caused`, produced
  by a `/gsd-debug` session on 2026-05-10. The global doc contains the full hypothesis differential table
  and all verbatim evidence anchors.
- **Operator UAT trigger:** `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md`
  Test 1 + Test 5 / **Finding #2** ("Reconnect / cookie auto-login renders blank GameScene"). Video evidence
  in `uat-test-1-2.mp4`.
- **This plan modifies zero source files.** All edits — BootScene, LoginScene, GameScene, and InputDispatcher —
  are deferred to plan 06-15.

---

## §1 Symptom

On first login via the LoginScene form submit path, GameScene renders correctly: chat HUD hint, self avatar,
remote avatars + nameplates, room background. On either of these reconnect / re-entry paths the canvas goes
blank — `#0A0E1A` background only, no HUD content, no remotes, no self avatar:

1. **Tab reload after fresh login (cookie auto-login fast-path).** After a successful login, pressing F5 (or
   closing and reopening the tab while the Better-Auth cookie is still valid) re-enters BootScene → LoginScene
   fast-path → GameScene. `data-game-ready` is never set; `window.rebno.remotePlayers === []`; no WebSocket
   appears in DevTools Network; the chat hint fades to opacity 0 after ~5 s and disappears.

2. **WS kill via `__rebno.room.connection.close()` with past-grace fallthrough to reload.** DevTools console
   call kills the socket; the `Reconnecting…` banner appears. Waiting > 11 s lets the grace window expire;
   SM transitions to `Disconnected — click to retry`; clicking the banner calls `window.location.reload()` —
   which then hits path #1 above. The within-grace SDK auto-reconnect path avoids the reload and theoretically
   works, but there is a secondary defect on that path (see §5).

---

## §2 Root cause

**The Better-Auth `session_token` is dropped between BootScene → LoginScene (fast-path) → GameScene.** The
server's Colyseus WS `onAuth` handler requires `session_token` as a `Bearer` header (no cookie auth on the
WS perimeter — per Phase 4 D-03 / Phase 6 ADR 0007). GameScene.connect short-circuits silently when the
token is absent, so no Colyseus room is ever joined.

### Seven verbatim evidence anchors

**1. GameScene.connect early-return on missing token (`apps/client/src/scenes/GameScene.ts:191–192`):**

```ts
private async connect(wssUrl: string): Promise<void> {
  if (!this.sessionToken) return;
```

The early-return is silent — no warning, no console error — so the operator sees a blank canvas with no
diagnostic signal.

**2. Server requires Bearer token only (`apps/server/src/RebnoRoom.ts:267–269`):**

```ts
const session = await this.auth.api.getSession({
  headers: new Headers({ Authorization: `Bearer ${session_token}` }),
});
```

Cookie-based WS auth is not wired. The HTTP perimeter uses cookie auth (Better-Auth same-origin), but
the Colyseus handshake is bearer-only. This server contract is correct — do NOT change it.

**3. BootScene fetches session but throws away `session.token` (`apps/client/src/scenes/BootScene.ts:78–95`):**

```ts
const session = await getSession().catch(() => null);
…
if (session) {
  this.scene.start('LoginScene', {
    fastPath: true,
    username: session.user.username,
  });
} else {
  this.scene.start('LoginScene', { fastPath: false });
}
```

`getSession()` (`apps/client/src/auth/client.ts:84–108`) returns a shape that **does** include `token`
(`SessionShape.token`), but BootScene only forwards `username`.

**4. LoginScene fast-path also forwards only `username` (`apps/client/src/scenes/LoginScene.ts:106–109`):**

```ts
this.fastPathTimer = this.time.delayedCall(500, () => {
  spinner.destroy();
  this.scene.start('GameScene', { username });
});
```

No `sessionToken` field. Compare to the form-submit path (`LoginScene.ts:173–177`) which **does** pass
`result.session_token`:

```ts
this.scene.start('GameScene', {
  sessionToken: result.session_token,
  username: result.user.username,
  mustForceReset: result.must_force_reset,
});
```

**5. sessionStorage reconnect-token cache is never reached (`apps/client/src/net/colyseus-client.ts:82–99`):**

The cached-reconnect path would try `client.reconnect(cached)` first and could succeed within the 10 s
server-side `allowReconnection` grace — but it never runs because `connect()` early-returns at `!sessionToken`
before `joinRebnoRoom()` is invoked. The reconnect path architecturally does not require the bearer, but the
current code ties Colyseus-reconnect to Better-Auth-presence.

**6. No tests cover the cookie auto-login shape:**

`apps/client/src/__test__/game-scene.test.ts` — every `scene.init` call passes `sessionToken: 't'`. The
`init({ username: 'me' })` shape (cookie path, no token) has zero coverage. The 06-08 e2e suite
(`apps/client/test/cli-08.e2e.test.ts`) drives both clients through form login each run; no
reload-after-login or kill-WS-then-reload assertion exists.

**7. Path #2 reduces to path #1 after grace expiry (`apps/client/src/net/reconnect.ts:100`):**

```ts
() => window.location.reload(),
```

Clicking the disconnect banner reloads → BootScene cookie fast-path → blank.

### Why the other observed symptoms vanish

- **No remote players:** `bindHandlers` is never called → no `players.onAdd` → no
  `playerRenderer.addRemote(...)`.
- **No self avatar:** `playerRenderer.ensureLocal(...)` only fires from `onLocalJoin` (the `players.onAdd`
  callback for the local sessionId) — same chain, never runs.
- **No room background:** `RoomRenderer.render(layout)` only fires from `onRoomLayout` (`GameScene.ts:203–204`),
  which is the `s2c.room_layout` message handler bound inside `bindHandlers` — never reached.
- **HUD chat hint disappearing:** `ChatHUD` mounts in `create()` before the `connect()` early-return (so it
  does appear briefly), but the "Press T or Enter to chat" hint fades to opacity 0 after 5 s
  (`ChatHUD.ts:127–129`). Operator inspection > 5 s after reload sees nothing.

---

## §3 Affected files

| File | Lines | Role in the bug | Edited by 06-15? |
|------|-------|-----------------|-----------------|
| `apps/client/src/scenes/BootScene.ts` | 78–95 | Drops `session.token`; only forwards `username` to LoginScene fast-path | YES |
| `apps/client/src/scenes/LoginScene.ts` | 80–116, esp. 106–109 | Fast-path passes `{ username }` only to GameScene | YES |
| `apps/client/src/scenes/GameScene.ts` | 191–192 | Early-return on missing `sessionToken` short-circuits WS join | YES (Option A keeps it; Option B self-heals before it) |
| `apps/client/src/scenes/GameScene.ts` | 238–252 | `if (!this.inputDispatcher)` guard keeps stale room ref after silent_reauth (§5 secondary) | YES (per CONTEXT addendum line 361) |
| `apps/client/src/auth/client.ts` | 84–108 | `getSession()` already returns `token` — fix can consume it directly | NO (read-only) |
| `apps/client/src/net/colyseus-client.ts` | 82–99 | Cached-reconnect path is reachable without bearer for within-grace branch (informational; fix is upstream) | NO |
| `apps/server/src/RebnoRoom.ts` | 267–269 | Server contract: WS `onAuth` is bearer-only, no cookie fallback — server is correct | NO — DO NOT change |

---

## §4 Primary fix shape

Ship **both** Option A and Option B — they are complementary, not competing. Option A fixes the immediate
data-flow gap; Option B prevents recurrence if scene-init shapes drift in the future.

### Option A (preferred — minimal surface, three edits)

**Edit 1 — `BootScene.ts` (lines 88–95):** Forward `session.token` into `LoginSceneInitData` alongside `username`:

```ts
// Before
this.scene.start('LoginScene', {
  fastPath: true,
  username: session.user.username,
});

// After
this.scene.start('LoginScene', {
  fastPath: true,
  username: session.user.username,
  sessionToken: session.token,   // ← add
});
```

**Edit 2 — `LoginScene.ts` / `LoginSceneInitData` (lines 26–51):** Accept `sessionToken?: string`; store on
the instance:

```ts
interface LoginSceneInitData {
  fastPath?: boolean;
  username?: string;
  sessionToken?: string;   // ← add
}
```

And in `init(data: LoginSceneInitData)`:

```ts
this.sessionToken = data.sessionToken;   // ← store
```

**Edit 3 — `LoginScene.renderFastPath` (lines 106–109):** Pass `{ sessionToken, username }` to
`scene.start('GameScene', …)`:

```ts
// Before
this.scene.start('GameScene', { username });

// After
this.scene.start('GameScene', { sessionToken: this.sessionToken, username });
```

This keeps the existing `GameScene.connect` early-return as a defensive check (correct for a genuinely
unauthenticated navigation) while ensuring the cookie path satisfies it.

### Option B (defence in depth — also ship alongside A)

In `GameScene.create`, if `this.sessionToken` is undefined, call `getSession()` and adopt `session.token`:

```ts
// In GameScene.create, after init() has run:
if (!this.sessionToken) {
  const session = await getSession().catch(() => null);
  if (session?.token) {
    this.sessionToken = session.token;
  }
}
```

This makes GameScene self-healing against future scene-init shape changes. Combine with Option A; do not
replace it.

---

## §5 Secondary defect (in scope for 06-15 per CONTEXT addendum line 361)

In the past-grace silent-reauth branch (`GameScene.connect` runs a second time inside the same Phaser
scene), the existing `InputDispatcher` keeps a stale `room` reference:

**`apps/client/src/scenes/GameScene.ts:238–252`:**

```ts
if (!this.inputDispatcher) {
  …
  this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  …
}
```

The `if (!this.inputDispatcher)` guard means the dispatcher is created once on first connect and never
re-pointed at the new `this.room` after a silent reauth. Any movement intent dispatched after reauth will
be `room.send`-ed through the closed / garbage-collected old room object, producing silent send failures
or JS errors.

**Fix sketch for 06-15:**

1. Add a `setRoom(room: Room)` method to `InputDispatcher`.
2. In `GameScene.connect`, on every non-first connect (i.e., when `this.inputDispatcher` already exists),
   call `this.inputDispatcher.setRoom(this.room)` instead of early-returning from the guard.

**Unit-test sketch:**

```ts
// After simulated silent_reauth cycle:
expect(dispatcher.room).toBe(gameScene.room);
// Confirm send goes through the new room, not the stale one
```

---

## §6 Required regression tests

06-15's executor must ship all five of these tests. Do not invent alternatives — use this checklist verbatim.

1. **Unit — GameScene (Option B path)** (`apps/client/src/__test__/game-scene.test.ts`):
   - Call `scene.init({ username: 'me' })` with no `sessionToken`.
   - Mock `getSession` to return `{ token: 'tok-123', user: { username: 'me' } }`.
   - Assert that `joinRebnoRoom` **is** called (no early-return).
   - Confirms Option B self-heal works.

2. **Unit — LoginScene (Option A path)** (`apps/client/src/__test__/login-scene.test.ts`):
   - Call `LoginScene.init({ fastPath: true, username: 'me', sessionToken: 'tok-abc' })`.
   - Trigger the fast-path timer (advance fake clock 500 ms).
   - Assert that `scene.start('GameScene', ...)` was called with `sessionToken: 'tok-abc'`.
   - Confirms Option A data-flow is end-to-end.

3. **e2e — Cookie auto-login reload** (`apps/client/test/cli-08.e2e.test.ts` or new `cookie-reload.e2e.test.ts`):
   - Log in via the form; assert `[data-game-ready="true"]` on the canvas.
   - Reload the page (`page.reload()`).
   - Re-assert `[data-game-ready="true"]` within 5 s of reload.
   - Assert at least one `[data-chat-line]` is renderable (send a message after reload).

4. **e2e — WS kill within grace window** (`apps/client/test/cli-08.e2e.test.ts`):
   - Log in; assert `[data-game-ready="true"]`.
   - `page.evaluate(() => __rebno.room.connection.close())` (kills socket).
   - Wait for SDK to auto-reconnect within grace (poll `[data-game-ready="true"]` up to 12 s).
   - Assert `[data-game-ready="true"]` is still truthy.
   - Assert a follow-up chat send round-trips (`[data-chat-line]` appears within 2 s).

5. **Unit — InputDispatcher stale-room secondary defect** (new file or appended to `game-scene.test.ts`):
   - Simulate a silent_reauth cycle (call `connect()` a second time with a fresh mock room).
   - Assert `dispatcher.room` is the new room object, not the original stale one.

---

## §7 Out of scope

- **Server bearer-vs-cookie posture is correct.** `RebnoRoom.ts:267–269` bearer-only `onAuth` is intentional
  per Phase 4 D-03. Do not add cookie fallback to the server WS perimeter.
- **Future cookie-WS-auth refactor deferred.** A potential future decision to unify HTTP-cookie and WS-bearer
  auth is out of scope for this fix. The cookie auto-login fix is purely a client-side data-flow repair.
- **Reconnect-banner UX timing graded separately.** The "Reconnecting…" / "Disconnected — click to retry"
  banner appearance and grace-window timing is an independent concern (UAT Test 5). It is not gated on this
  fix and is not addressed in 06-15.

---

## §8 References

**Files mentioned in §3 (all relative to repo root):**

- `apps/client/src/scenes/BootScene.ts`
- `apps/client/src/scenes/LoginScene.ts`
- `apps/client/src/scenes/GameScene.ts`
- `apps/client/src/auth/client.ts`
- `apps/client/src/net/colyseus-client.ts`
- `apps/client/src/net/reconnect.ts`
- `apps/client/src/ui/ChatHUD.ts`
- `apps/client/src/ui/reconnect-banner.ts`
- `apps/client/index.html`
- `apps/server/src/RebnoRoom.ts` — **read-only; do not edit**

**Phase + debug artifacts:**

- `.planning/debug/reconnect-blank-render.md` — global debug doc; `status: root_caused`; full hypothesis
  differential table + all evidence
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` — Finding #2 source;
  Test 1 + Test 5 operator steps + `uat-test-1-2.mp4`
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md` — Gap-Closure Addendum
  line 361 (secondary defect scoped into 06-15); D-25 sequencing
