# Phase 06.2: gap-closure D-50..D-53 + reopen D-40/D-41/D-45 (UAT 2026-05-13) - Context

**Gathered:** 2026-05-13
**Status:** Ready for planning
**Derived from:** triage of cycle-3 UAT findings + post-deploy e2e regression
(see 06.2-UAT-FINDINGS-2026-05-13.md). No `/gsd-discuss-phase` ceremonial
run — decisions captured directly from operator findings + my code-level
triage in the same session.

<domain>
## Phase Boundary

Close the 7 cycle-3 findings from 2026-05-13:

- 3 reopened gaps that 06.1 plans claimed closed but were unit-test-only
  closure (visual / behavioral regression remained): **D-40** (floor tiles
  + TSides not rendering on staging), **D-41** (local sprite no-animate),
  **D-45** (nametag Y too low).
- 4 new findings: **D-50** (server ghost players on WS disconnect),
  **D-51** (server allows duplicate-account simultaneous login), **D-52**
  (player position occasionally resets to spawn), **D-53** (legacy
  spawn-in init delay before player run animation).

After Phase 06.2 lands, re-run `06.1-HUMAN-UAT.md` end-to-end on staging.
A successful re-run → archive `06.1-CLI-08-milestone.mp4` and advance to
Phase 7.

**In scope:**

- D-40 — investigate why `RoomRenderer` does not emit `0023-Tile1_000` +
  `0024-TSide1_000` sprites on the deployed staging build despite the
  06.1-05 verify-bail fix landing. Atlas frames exist on staging
  (`/atlas-mvp.json` confirmed by curl); the failure is downstream of
  `s2c.room_layout` decode.
- D-41 — investigate why `PlayerRenderer.onSimulationTickLocal` does not
  produce visible frame advance on the deployed staging build despite
  06.1-04 SpriteStateMachine fractional advance landing.
- D-45 — fix `Nameplate` y offset so the text floats 16 px above sprite
  top (NOT above sprite center).
- D-50 — Colyseus room `onLeave` (or grace-timeout disposal) MUST remove
  the player entry from `state.players` so other clients stop rendering
  the ghost sprite + nameplate.
- D-51 — single-session-per-account enforcement on `onJoin`. When a new
  WS join arrives with an `account_id` already present in `state.players`,
  evict the prior session (the legacy BNO replace-prior-session policy).
- D-52 — instrument the reconciler + server tick to identify the cause of
  occasional snap-back-to-spawn. Possibilities: snapshot divergence
  threshold too tight, server-side cookie-resume teleport, or stale
  `last_input_seq`.
- D-53 — port legacy spawn-in init delay from `extracted/client-5-8/objects/
  0042-player/events/Create.gml` (likely an alarm-N timer) into either
  `PlayerRenderer.spawnLocal` or `GameScene.onLocalAdd` so the local
  sprite holds still for the same duration before animation kicks off.

**Workflow gotchas already fixed in cycle-3 (not Phase 06.2 plans — already
in commits):**

- `prediction.test.ts` cumulative-vx asserts → BNO instant-set asserts
  (commit `bd93e7f`). Local `dist/` was stale; CI rebuilds fresh and the
  test must match `step.ts` not the legacy `dist/`.
- `__rebno` test-infra hooks env-gate (commit `cd47745`). `MODE='staging'`
  was silencing `__rebno.{roomId, cameraScrollX, loadedTextures,
  localPlayerX/Y, localFrame}` writes → 7+ post-deploy Playwright failures.
  Gate removed; hooks always on.
- `login-contrast.e2e.test.ts:56` missing `inviteSuffix` (commit
  `cd47745`). Staging is invite-gated; tests now use the fixture.
- 87 commits unpushed (now pushed). `deploy-staging` is push-triggered.
  This pattern surfaced because Phase 06.1 was the first full cycle that
  closed without an interim push — file under workflow discipline, not a
  plan.

**Out of scope (deferred to Phase 7):**

- All Phase 06.1 deferred items still defer (falls/abyss, ice/movement
  tiles, Hexport, JokerShell, top/left/right borders, multi-floor depth,
  depth-registry consolidation, all rooms beyond mvp-lobby).
- `persistCharacter` FOREIGN KEY failures (already flagged Phase 7 in
  Psyche context).
- RC3 unresolved (e2e tests where `data-game-ready` never sets) — re-test
  AFTER RC1 hooks-gate fix redeploys; many of these likely have RC1 as
  the actual cause and resolve transitively.

</domain>

<decisions>
## Implementation Decisions

### D-40 — floor tiles + TSides not rendering on staging (reopened)

**Observed:** Atlas served from `/atlas-mvp.json` on staging contains both
`0023-Tile1_000` and `0024-TSide1_000` frame keys (curl-verified). UAT
screenshot shows background + sprites + nameplates rendering correctly but
zero floor-tile sprites and zero TSide1 sprites placed. `RoomRenderer.ts:
130-189` iterates `layout.tiles` and emits sprites only when both
`textures.exists(atlasKey)` AND `texture.has(frameName)` pass; if either
fails it `continue`s (silent miss).

**D6.2-01 — spike first.** Before patching `RoomRenderer`, add a
diagnostic plan that publishes `__rebno.tilesIn`, `__rebno.tilesOut`,
`__rebno.atlasHasTile1`, `__rebno.atlasHasTside1` from
`RoomRenderer.renderNew`. Push to staging, hit it, capture values. This
disambiguates three hypotheses:

1. `layout.tiles` empty/undefined on the wire (server-side regression).
2. `tileset_sprite_id` mismatch (e.g. server sends `23` instead of
   `0023-Tile1`).
3. `atlasKey` (`atlas-mvp`) not loaded into `TextureManager` at render
   time (race with `RoomRenderer.render` firing before atlas-loader
   onload).

**D6.2-02 — fix path conditional on spike output.** Likely shapes:

- Hypothesis 1 → server-side bug in `s2c.room_layout` payload (Phase 4
  RoomRegistry). Fix in `apps/server/src/rooms/MvpRoom.ts` (or wherever
  the broadcast originates) to include the full tile array.
- Hypothesis 2 → wire schema normalization in
  `apps/client/src/scenes/GameScene.ts onRoomLayout` to coerce
  `tileset_sprite_id` to the canonical `NNNN-Name` form.
- Hypothesis 3 → defer `RoomRenderer.render` until `scene.load.once
  ('complete', ...)` resolves for `atlas-mvp`.

Each is a small targeted plan; no rollup until the spike picks the
hypothesis.

### D-41 — local sprite no-animate (reopened)

**Observed:** Plan 06.1-04 added `SpriteStateMachine.advanceTick` +
`PlayerRenderer.onSimulationTickLocal`. Unit tests pass (24 files / 183
tests GREEN locally + on CI carry-over). UAT visual confirms the local
sprite frame freezes on `0000-NaviStandD_000` during WASD movement; the
SAME atlas + state machine cycles correctly for REMOTE players.

**Hypothesis:** `onSimulationTickLocal` callsite gate is wrong. Either:

- `GameScene.update` triggers `playerRenderer.onSimulationTickLocal` only
  when `this.prediction` exists and `predictTick` returned an input
  frame; on the deployed build the prediction may bail early (e.g. due
  to D-52 reset behaviour) and the sim-tick handler never runs.
- OR: the `vx`/`vy` passed to `onSimulationTickLocal` is `0, 0` because
  the BNO instant-set model leaves `state.vx/vy === 0` (see
  cycle-3 `prediction.test.ts` fix). `deriveFrame(0, 0, …)` → Stand
  frame. The sim-tick fires every frame but always with zero velocity →
  perma-stand.

**D6.2-03 — root-cause via instrumentation.** Add `__rebno.localTickRate`
(counter of `onSimulationTickLocal` calls / sec) and
`__rebno.localTickVx`, `__rebno.localTickVy` (last-passed velocity
arguments). Push, hit staging, observe.

**D6.2-04 — likely fix (pending spike).** If hypothesis 2 confirmed:
`PlayerRenderer.onSimulationTickLocal` MUST receive the *intended*
displacement axes (axis_x_held × RUN_SPEED, axis_y_held × RUN_SPEED) for
the current input frame — NOT the post-step `state.vx/vy` (always 0
under BNO instant-set). Same model already used for remotes (line 731 of
`GameScene.ts` passes `(p.axis_x_held ?? 0) * RUN_SPEED_PX_PER_TICK`).
Mirror that pattern for local.

### D-45 — nameplate Y too low (reopened)

**Observed:** UAT Test 4 — nametag sits low enough to be visually
overlapped by the player sprite's head. CLAUDE.md cites canonical:
`text_y = sprite.y - 16` (i.e. 16 px above sprite top-edge).

**D6.2-05 — fix.** `apps/client/src/render/Nameplate.ts follow(spriteX,
spriteY, spriteHeight)` MUST anchor the text at `spriteY - 16` (where
`spriteY` is sprite TOP — origin (0.5, 1) means `sprite.y` is the
BOTTOM/feet). Need to subtract `spriteHeight + 16` from `sprite.y` to
land 16 px above the top edge. Audit current Nameplate offset math —
it likely passes `sprite.height` instead of `sprite.displayHeight` or
uses center-of-sprite as anchor.

**D6.2-06 — regression test.** Unit test `nameplate.test.ts` already
exists. Extend with an explicit assertion that `nameplate.y === sprite.y
- sprite.height - 16` for a 48 px tall NaviStandD sprite at `sprite.y =
100` → expect `nameplate.y === 100 - 48 - 16 === 36`.

### D-50 — server ghost players on WS disconnect (new)

**Observed:** `uat_a` continues to see `uat_b` sprite + nameplate after
`uat_b` is fully logged out (not just disconnected). Server's room state
retains the player entry indefinitely.

**D6.2-07 — Colyseus onLeave hard-delete.** Locate the room class for
`mvp-room` (`apps/server/src/rooms/`). Default Colyseus pattern: `onLeave
(client, consented)` is called after grace period expires (Phase 4 D-12
documented a reconnect-grace already exists). MUST `delete this.state.
players[client.sessionId]` (or `.delete(...)` for a `MapSchema`) in
`onLeave` once the grace window passes without reconnect. Verify
Schema's `players` is auto-broadcast on delete (Colyseus default).

**D6.2-08 — regression test.** Integration test:
1. Two clients join.
2. Client B disconnects (close WS).
3. Wait grace + 1s.
4. Client A's mirror of `state.players` MUST NOT contain B's sessionId.

### D-51 — server allows duplicate-account simultaneous login (new)

**Observed:** `uat_a` logged in twice from the same Chrome window
(probably resumed via cookie from a prior login) and the server happily
created two `Client` sessions tied to the same `account_id`. Result: a
ghost `uat_a` (white = remote) visible to the live `uat_a` (cyan = local).

**D6.2-09 — replace-prior-session policy.** On `onJoin`, query
`this.state.players` for any entry where `entry.account_id === incoming.
account_id`. If found, send a `force-reset` packet to the prior session,
disconnect that client, and remove the prior `state.players` entry —
THEN admit the new session. Legacy BNO behavior: prior session is
kicked, no co-existence allowed.

**D6.2-10 — client-side handle.** Client receives `force-reset` →
display "Logged in elsewhere" banner → drop to LoginScene. Reuse the
existing reconnect-banner DOM overlay.

**D6.2-11 — regression test.** Integration test:
1. Client A1 joins as `uat_a`.
2. Client A2 joins as `uat_a` (different WS).
3. A1's WS closes with code 4xxx + reason `replaced-by-new-session`.
4. Server's `state.players` has exactly ONE entry for `uat_a` (A2's
   sessionId).

### D-52 — player position resets to spawn (new — investigate)

**Observed:** Occasionally during normal WASD movement, the local sprite
snaps back to spawn coords. Frequency: "occasionally" — operator did not
quantify. Possibilities:

1. Reconciler snap when server snapshot diverges from client prediction
   by > some threshold.
2. Server-side cookie-resume teleport (Phase 6 D-25 cookie-reload path)
   firing mid-session.
3. Stale `last_input_seq` causing the reconciler to apply a server
   snapshot whose `(x, y)` is the spawn position.

**D6.2-12 — spike-only this cycle.** Instrument
`apps/client/src/prediction/reconciler.ts` and `GameScene` to publish
`__rebno.lastReconcileX/Y`, `__rebno.lastReconcileReason` on every snap.
Reproduce, capture. The fix plan goes to Phase 06.3 if needed (or
folds into the same cycle if the spike resolves in <30 min).

### D-53 — legacy spawn-in init delay (new MVP fidelity gap)

**Observed:** Legacy BNO holds the player sprite still for a brief
moment after spawn before run animation can begin. Operator request:
mirror exactly.

**D6.2-13 — extract the legacy spec.** Read `extracted/client-5-8/objects/
0042-player/events/Create.gml` AND `extracted/client-5-8/objects/0042-
player/events/Other-7.gml` (Phase 5 placeholder) to find the alarm or
counter that gates initial input/animation. Phase 06.2 plan must cite
the exact line + value.

**D6.2-14 — implement.** Add a `spawnDelayTicks` counter to
`PlayerRenderer.local` (decrement per sim-tick; while > 0, ignore input
+ keep `SpriteState` pinned to Stand). Wire `GameScene.onLocalAdd` to
initialize it from the value extracted in D6.2-13.

**D6.2-15 — regression test.** Unit test: spawn local player with
`spawnDelayTicks = N`; assert that the first N
`onSimulationTickLocal(vx>0, vy>0)` calls produce Stand frame; the
N+1th call produces Run frame.

</decisions>

<execution_constraints>
## Wave / Ordering

W1 (spike, parallel-safe — diagnostic only, no behavior change):
  - D6.2-01 floor-tile diagnostic instrumentation (RoomRenderer)
  - D6.2-03 local sim-tick diagnostic instrumentation (PlayerRenderer)
  - D6.2-12 reconciler diagnostic instrumentation
  - D6.2-13 spawn-in delay legacy spec extraction (read-only)

W2 (fix, depends on W1 spike outcomes):
  - D6.2-02 RoomRenderer / server / atlas-race fix (one of three paths)
  - D6.2-04 PlayerRenderer.onSimulationTickLocal axis-held wiring fix
  - D6.2-05 + D6.2-06 Nameplate Y + regression test (no spike needed)
  - D6.2-07 + D6.2-08 Server onLeave hard-delete + regression test
  - D6.2-09 + D6.2-10 + D6.2-11 Replace-prior-session policy + client
    handle + regression test
  - D6.2-14 + D6.2-15 spawnDelayTicks impl + regression test

W3 (verification):
  - End-to-end re-run of `06.1-HUMAN-UAT.md` on staging
  - Capture `06.2-CLI-08-milestone.mp4` if all 5 tests pass
  - Operator approval → close Phase 06.2 → advance to Phase 7

## Test boundary

- Unit + integration tests REQUIRED for D-45, D-50, D-51, D-53 (clear
  behavioral contracts).
- Spikes D-40, D-41, D-52 produce diagnostic instrumentation, then a
  follow-up fix plan in the same cycle.
- e2e: existing `06.1-HUMAN-UAT.md` is the regression gate.

## Anti-Recurrence Discipline

Cycle 1 ⇒ Cycle 2: D-39..D-46. Cycle 2 ⇒ Cycle 3: D-40/D-41/D-45 reopened
(claimed closed by 06.1 plans, but visual / behavioral verification was
not in the GREEN gate — only unit-test claims were). Phase 06.2
**MUST** include a visual / staging-deploy check in the GREEN gate, not
just unit tests. Specifically:

- Floor tiles: add a Playwright e2e that walks the local sprite to the
  southern floor edge and asserts collision (which fails if tiles are
  absent — empty `layout.tiles` produces no collision polys).
- Local sprite animation: add a Playwright e2e that holds KeyD for 1s
  and asserts `__rebno.localFrame` cycled across ≥3 distinct frame
  keys (this was already authored by 06.1-07 but failed silently due to
  RC1 env-gate; with RC1 fixed it becomes a real gate).
- Nameplate Y: assert `nameplate.y < sprite.top` via DOM-mirror data
  attrs.

</execution_constraints>

<traceability>
## Requirements Touched

- REQ-CLI-04 — Animation contract (D-41 reopened) + spawn delay (D-53).
- REQ-CLI-06 — Renderer / atlas / asset pipeline (D-40 reopened).
- REQ-CLI-08 — CLI-08 hard milestone re-gate.
- REQ-SRV-03 — Authoritative server state (D-50, D-51).
- REQ-SRV-14 — Per-entity sim contract (D-52 reconciler).

</traceability>

---

*06.2-CONTEXT.md written from operator UAT verdict + cycle-3 triage.
Skips ceremonial `/gsd-discuss-phase` — operator's findings + my
code-level investigation provided the same decision crispness. Resume in
a fresh session via `/gsd-plan-phase 06.2` against this CONTEXT.*
