---
slug: reconnect-blank-render
status: resolved
goal: find_root_cause_only
trigger: phase-06 UAT Finding #2 (D-25)
specialist_hint: typescript
tdd_mode: false
created: 2026-05-10
---

# Reconnect / cookie-auto-login renders blank GameScene — root-cause findings

> **Diagnose-only.** No source files were edited. This document is the input
> for plan **06-15** (the fix plan); 06-15 is responsible for applying the
> fix and writing the regression tests.

## 1. Symptom

On first login (LoginScene form → submit → GameScene), the GameScene renders
correctly: chat HUD hint, self avatar, remote avatars + nameplates, room
background. On either of these reconnect paths the canvas goes blank — only
the `#0A0E1A` background colour, no HUD message history, no remotes, no self
avatar:

1. **Reload the tab** after a fresh login (cookie auto-login fast-path).
2. In DevTools, **kill the WS** (`__rebno.room.connection.close()`) and let
   the SDK either auto-resume within grace or fall through to silent reauth.
   In the past-grace branch the disconnect banner click reloads the tab —
   which then hits the same cookie auto-login path as #1.

## 2. Reproduction (verbatim from operator UAT — `06-HUMAN-UAT.md` Test 1 + Test 5)

1. Log into `https://staging.rebno.decidel.com/` with valid Better-Auth
   credentials. Confirm GameScene shows the rectangle avatar + chat hint.
2. Press F5 (or close + reopen the tab while the cookie is still valid).
3. Expected: re-enter GameScene, see same content.
   Observed: blue/cyan canvas, nothing else; `data-game-ready` never set;
   `window.rebno.remotePlayers === []`; no WebSocket in DevTools Network.

Alternate repro for path #2:

1. Same as #1 step 1.
2. DevTools → Console → `__rebno.room.connection.close()`.
3. Observe `Reconnecting…` banner. Wait > 11 s for grace to expire and SM
   to transition to `Disconnected — click to retry`.
4. Click the banner (`window.location.reload()`).
5. After reload: same blank canvas as path #1.

## 3. Differential diagnosis

The Phase-6 UAT writeup proposed four hypotheses. After evidence-gathering:

| # | Hypothesis | Verdict |
|---|---|---|
| H1 | HUD mount idempotency (DOM nodes leaked / duplicated across reconnect) | **Rejected.** ChatHUD has `if (this.mounted) return;` guard at `apps/client/src/ui/ChatHUD.ts:81`; on a page reload the entire JS context is fresh, so leaks are impossible by construction. The chat hint *does* mount but fades to opacity 0 after 5 s (line 127–129) — this explains why the operator describes "no chat HUD" without seeing a duplicate node. |
| H2 | Phaser scene `init` / `create` not running second time | **Rejected.** Phaser scene re-entry semantics confirmed by reading `BootScene.create()` → `this.scene.start('LoginScene', {...})` → `LoginScene.create()` → `this.scene.start('GameScene', {...})` chain. Each `scene.start` produces a fresh `init` + `create`. Verified by `data-game-ready` attribute toggling logic on each `markGameReady()` call and by the `firstStateSeen` field defaulting to `false` on a new instance. |
| H3 | Phaser `DOMElement` re-create on second mount | **Rejected.** GameScene never uses `DOMElement` for the HUD. Both `ChatHUD` and `ReconnectBanner` mount **outside** the Phaser canvas tree, under `#dom-overlay` (`apps/client/index.html:18`). Confirmed by `ChatHUD.ts:80–134` and `reconnect-banner.ts:58–68`. |
| H4 | BootScene → GameScene transition only fires on the LoginScene path, not on the cookie auto-login path | **CONFIRMED — but as a different mechanism than first framed.** The transition does fire (LoginScene fast-path renders for ~500 ms, then `scene.start('GameScene', { username })`), but the data carried across the transition is incomplete. See §4. |

A fifth hypothesis surfaced during investigation (the actual root cause): **the
Better-Auth `session_token` is not threaded through the cookie auto-login chain**.

## 4. Root cause

**The cookie auto-login path drops the Better-Auth `session_token` between
BootScene → LoginScene (fast-path) → GameScene.** The server's WS `onAuth`
handler requires `session_token` as a `Bearer` header (no cookie auth on the
WS perimeter), so `GameScene.connect()` short-circuits and no Colyseus room
is ever joined. The DOM HUD still mounts (so the chat hint is visible for
~5 s before fading), the Phaser scene still runs, but the world is empty.

### Evidence trail

1. **GameScene.connect requires `sessionToken`:**

   `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 error — so the operator sees
   a blank canvas with no console signal explaining why.

2. **Server requires the token via `Authorization: Bearer …`:**

   `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 does cookie
   auth (Better-Auth same-origin), but the Colyseus handshake is bearer-only
   per Phase 4 D-03 / Phase 6 ADR 0007.

3. **BootScene fetches a session but throws away the 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. **Reconnection-token cache (sessionStorage) survives reload but is
   never reached** because `connect()` early-returns before
   `joinRebnoRoom()` is called. `apps/client/src/net/colyseus-client.ts:82–99`
   would happily try `client.reconnect(cached)` first and succeed within
   the 10 s server-side `allowReconnection` grace — but that code path
   never runs without a `sessionToken` to gate the call (current
   structure ties Colyseus-reconnect to Better-Auth-presence, even though
   the reconnect path itself doesn't require the bearer).

6. **No tests cover the cookie auto-login path.** Grepping
   `apps/client/src/__test__/game-scene.test.ts` shows every `scene.init`
   call in the GameScene unit suite passes `sessionToken: 't'`. The
   `init({ username: 'me' })` shape (cookie path) has zero coverage. The
   06-08 e2e suite (`apps/client/test/cli-08.e2e.test.ts`) drives both
   clients through the form login each run; there is no
   reload-after-login or kill-WS-then-reload assertion.

7. **Path #2 (DevTools WS kill) reduces to path #1 once the operator
   clicks "Disconnected — click to retry".** `apps/client/src/net/reconnect.ts:100`:
   ```ts
   () => window.location.reload(),
   ```
   The reload re-enters BootScene → cookie fast-path → blank.

   Within the grace window (the SDK's own auto-reconnect path) the same
   Room object is preserved and state callbacks survive — that path is
   theoretically fine, but in practice Colyseus 0.17 internally retries
   `maxRetries` times (`apps/client/node_modules/@colyseus/sdk/build/Room.mjs:334–349`),
   then fires `onLeave(FAILED_TO_RECONNECT)` → SM → `silent_reauth` →
   `connect(env.wssUrl)`. Because `sessionToken` IS still in memory in
   the same-tab case, this branch *should* succeed. **However** there is
   a secondary defect on the silent-reauth path (see §6) that may also
   manifest as broken movement after reauth — flagged but out of scope
   for this finding.

### Why the other UAT-noted symptoms vanish too

- **No remote players:** `bindHandlers` is never called → no
  `players.onAdd` → no `playerRenderer.addRemote(...)` calls.
- **No self avatar:** `playerRenderer.ensureLocal(...)` only fires from
  `onLocalJoin`, which is 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 — `room.onMessage('s2c', …)` is
  bound inside `bindHandlers`, never reached.
- **HUD chat hint disappearing:** `ChatHUD` *does* mount (it sits in
  `create()` before the `connect()` early-return), but its
  "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.

## 5. Affected files (read-only summary — no edits applied)

| File | Lines | Role in the bug |
|---|---|---|
| `apps/client/src/scenes/BootScene.ts` | 78–95 | Drops `session.token`; only forwards `username` to LoginScene fast-path |
| `apps/client/src/scenes/LoginScene.ts` | 80–116 | Fast-path renderer; passes `{ username }` only to GameScene |
| `apps/client/src/scenes/GameScene.ts` | 191–192 | Early-return on missing `sessionToken` short-circuits the WS join |
| `apps/client/src/auth/client.ts` | 84–108 | `getSession()` already returns `token` — fix can consume it directly |
| `apps/client/src/net/colyseus-client.ts` | 82–99 | Cached-reconnect path is reachable without the bearer for the within-grace branch (informational; fix is upstream) |
| `apps/server/src/RebnoRoom.ts` | 267–269 | Server contract: WS `onAuth` is bearer-only, no cookie fallback (do **not** change here) |

## 6. Secondary defect surfaced during investigation (out of scope for 06-15 — flag for backlog)

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 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. Suggest a
follow-up plan after 06-15 ships; not strictly required to close
Finding #2.

## 7. Recommended fix direction (for 06-15 plan author — NOT applied here)

**Primary fix:** thread the Better-Auth `session_token` through the cookie
auto-login chain. The cleanest shape is to pass it from BootScene through
LoginScene's `LoginSceneInitData` and on into `GameSceneInitData`. Two
sketches in priority order:

### Option A (preferred — minimal surface, three edits)

1. `BootScene.create` (line 88–95): forward `session.token` into
   `LoginSceneInitData` alongside `username`.
2. `LoginScene.init` / `LoginSceneInitData` (line 26–51): accept
   `sessionToken?: string`; store on the instance.
3. `LoginScene.renderFastPath` (line 106–109): pass `{ sessionToken,
   username }` to `scene.start('GameScene', …)`.

This keeps the existing `GameScene.connect` early-return as a defensive
check (covers the genuinely unauthenticated case) but ensures the
cookie path satisfies it.

### Option B (defence in depth — also worth considering)

In `GameScene.create`, if `this.sessionToken` is undefined, call
`getSession()` and adopt `session.token`. This makes GameScene
self-healing against future scene-init shape changes. Combine with
Option A — they're complementary, not competing.

### Test coverage to lock the regression (06-15 owns these)

1. **Unit** (`apps/client/src/__test__/game-scene.test.ts`):
   add a case where `scene.init({ username: 'me' })` (no
   `sessionToken`) is followed by `getSession()` returning a token,
   assert `joinRebnoRoom` *is* called with the token. (Mocks
   `getSession` per Option B; or asserts the BootScene→LoginScene→
   GameScene init-data pipeline carries the token end to end per
   Option A.)

2. **Unit** (`apps/client/src/__test__/login-scene.test.ts`): assert
   that `LoginScene` fast-path forwards `sessionToken` into
   `scene.start('GameScene', …)`.

3. **e2e** (`apps/client/test/cli-08.e2e.test.ts` or new
   `cookie-reload.e2e.test.ts`):
   - log in with the form;
   - assert `[data-game-ready="true"]` on the canvas;
   - reload the page;
   - **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** (kill-WS path):
   - log in;
   - call `__rebno.room.connection.close()` from
     `page.evaluate`;
   - wait for the SDK to auto-reconnect (within grace);
   - assert `[data-game-ready="true"]` is **still** truthy and a
     follow-up chat send round-trips.

## 8. Changes intentionally NOT made

- No source files edited.
- No commits.
- No follow-up to the secondary `InputDispatcher` stale-room defect (§6) —
  documented for a future session.
- No changes to the server bearer-vs-cookie posture (server contract is
  correct; the bug is purely client-side data flow).

## 9. References (paths, 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`
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` (Finding #2 source)
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md` (D-25 sequencing)
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-DISCUSSION-LOG.md` (D-25 / Wave 9 06-12 placeholder for this debug session)
