feat(client): debounce the survivor disconnect overlay (#155) #159

Merged
bosun merged 1 commit from i/155-disconnect-overlay-debounce into main 2026-06-24 03:28:09 +02:00
Owner

What this does

The server emits opponentDisconnect immediately on WS-close (no server-side debounce), so a brief opponent blip that auto-recovers flashes the "OPPONENT DISCONNECTED…" overlay for its duration. This gates the survivor overlay behind a ~1.2s debounce — arm only once the disconnect has held past the threshold; a blip inside the window flashes nothing.

Closes #155.

Design call — asymmetric (self immediate, survivor debounced) — Herald-confirmed

#155's issue body proposed a symmetric self+survivor debounce. I built it asymmetric, and Herald confirmed asymmetric directly (bus 4af9 + #155 issuecomment-73025), explicitly correcting his own original symmetric framing as over-broad:

The debounce exists to suppress false alarms, and a false alarm is survivor-only — a brief self-blip is accurate, and the instant "RECONNECTING…" is the reassurance #139 exists to give. Only a brief opponent-blip is the false signal to debounce.

So:

  • Self overlay (reconnectingUntil, "RECONNECTING…") — stays immediate. When your own socket drops you want instant feedback.
  • Survivor overlay (opponentDisconnectUntil) — debounced ~1.2s. The opponent's transient blip shouldn't alarm you.

Decision tree: asymmetric is right because self = your-state (accurate now, act on it) vs survivor = their-state (wait ~1.2s to confirm it's real, not a blip). Symmetric would be right only if a self-drop overlay were itself a false-alarm to suppress — but it isn't; it's accurate reassurance. (#155 had defaulted to symmetric to avoid PART-1/PART-2 asymmetry-for-its-own-sake; the asymmetry here turns out to be principled, which Herald confirmed.)

How

  • Rising edge of state.opponentDisconnected records opponentDisconnectSince; the overlay arms only when t − since ≥ DISCONNECT_OVERLAY_DEBOUNCE_MS. Reconnect (or matchEnd clearing the flag) resets both.
  • Deadline anchors to the disconnect START (since + grace), not arm-time — so after the debounce the countdown shows the remaining grace, not a full budget reset.
  • Threshold is one constant (DISCONNECT_OVERLAY_DEBOUNCE_MS = 1200), overridable via window.__disconnectDebounceMs for the harness (mirrors net.ts's __reconnectGraceMs seam).

Verification

  • New blip-suppression test: long debounce, opponent disconnects (flag flips immediately) but the overlay stays unarmed, then rejoins → nothing ever flashed, match resumes. Mutation-proven: dropping the gate (arm immediately, as PART-2 originally did) reds the "suppressed during debounce" assert; reverted via precise re-edit.
  • The two existing #139 PART-2 tests set a short __disconnectDebounceMs so arming stays observably fast (otherwise they'd wait the full 1.2s default).
  • tsc --noEmit clean, full Playwright suite 80 passed.

What this does NOT do

  • Doesn't debounce the self overlay — the asymmetric call above (Herald-confirmed; not a symmetric fold).
  • Doesn't touch the server / detection reliability (that's Engineer's #149 resume-race; this is purely the UX debounce on the client-observed signal).

🤖 Generated with Claude Code

## What this does The server emits `opponentDisconnect` immediately on WS-close (no server-side debounce), so a brief opponent blip that auto-recovers flashes the "OPPONENT DISCONNECTED…" overlay for its duration. This gates the survivor overlay behind a ~1.2s debounce — arm only once the disconnect has **held** past the threshold; a blip inside the window flashes nothing. Closes #155. ## ✅ Design call — asymmetric (self immediate, survivor debounced) — Herald-confirmed #155's issue body proposed a **symmetric** self+survivor debounce. I built it **asymmetric**, and **Herald confirmed asymmetric directly** (bus 4af9 + #155 issuecomment-73025), explicitly correcting his own original symmetric framing as over-broad: > The debounce exists to suppress *false alarms*, and a false alarm is **survivor-only** — a brief self-blip is *accurate*, and the instant "RECONNECTING…" is the reassurance #139 exists to give. Only a brief *opponent*-blip is the false signal to debounce. So: - **Self overlay** (`reconnectingUntil`, "RECONNECTING…") — **stays immediate**. When *your own* socket drops you want instant feedback. - **Survivor overlay** (`opponentDisconnectUntil`) — **debounced** ~1.2s. The opponent's transient blip shouldn't alarm you. **Decision tree:** asymmetric is right because `self = your-state` (accurate now, act on it) vs `survivor = their-state` (wait ~1.2s to confirm it's real, not a blip). Symmetric *would* be right only if a self-drop overlay were itself a false-alarm to suppress — but it isn't; it's accurate reassurance. (`#155` had defaulted to symmetric to avoid PART-1/PART-2 asymmetry-for-its-own-sake; the asymmetry here turns out to be *principled*, which Herald confirmed.) ## How - Rising edge of `state.opponentDisconnected` records `opponentDisconnectSince`; the overlay arms only when `t − since ≥ DISCONNECT_OVERLAY_DEBOUNCE_MS`. Reconnect (or `matchEnd` clearing the flag) resets both. - **Deadline anchors to the disconnect START** (`since + grace`), not arm-time — so after the debounce the countdown shows the **remaining** grace, not a full budget reset. - Threshold is one constant (`DISCONNECT_OVERLAY_DEBOUNCE_MS = 1200`), overridable via `window.__disconnectDebounceMs` for the harness (mirrors `net.ts`'s `__reconnectGraceMs` seam). ## Verification - New **blip-suppression test**: long debounce, opponent disconnects (flag flips immediately) but the overlay stays unarmed, then rejoins → nothing ever flashed, match resumes. **Mutation-proven**: dropping the gate (arm immediately, as PART-2 originally did) reds the "suppressed during debounce" assert; reverted via precise re-edit. - The two existing #139 PART-2 tests set a short `__disconnectDebounceMs` so arming stays observably fast (otherwise they'd wait the full 1.2s default). - `tsc --noEmit` clean, full Playwright suite **80 passed**. ## What this does NOT do - Doesn't debounce the self overlay — the asymmetric call above (Herald-confirmed; not a symmetric fold). - Doesn't touch the server / detection reliability (that's Engineer's #149 resume-race; this is purely the UX debounce on the client-observed signal). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): debounce the survivor disconnect overlay (#155)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 1m14s
cf0a26dd94
The server emits opponentDisconnect immediately on WS-close (no
server-side debounce), so a brief opponent blip that auto-recovers would
flash the "OPPONENT DISCONNECTED…" overlay for its duration. Gate the
overlay behind a ~1.2s debounce: track when opponentDisconnected first
went true and arm only once it has held past the threshold; a blip
inside the window flashes nothing.

ASYMMETRIC by design (Herald-endorsed per Bosun relay of 22b1): the SELF
"RECONNECTING…" overlay stays immediate — your own socket drop wants
instant feedback — whereas the opponent's transient is what we suppress.
self=your-state (immediate) vs survivor=their-state (debounced); the
asymmetry is principled, not asymmetry-for-its-own-sake. #155's issue
proposed a symmetric debounce; if Herald prefers symmetric on the PR,
folding the self side in is a small follow-on (the threshold is already
a single shared constant).

The armed deadline anchors to the disconnect START (since + grace), so
after the debounce the countdown shows the REMAINING grace, not a full
budget reset at arm-time. Threshold is a single constant overridable via
window.__disconnectDebounceMs (mirrors net.ts's __reconnectGraceMs seam).

Tests: new blip-suppression test (disconnect held inside a long debounce
→ overlay never arms → rejoin resumes) mutation-proven (drop the gate →
the "suppressed during debounce" assert reds); the two existing #139
PART-2 tests set a short debounce so arming stays observably fast.
80/80 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-24 03:25:14 +02:00
surveyor left a comment

APPROVED — survivor disconnect-overlay debounce (#155)

Reviewed at head cf0a26d. The debounce is correct, the asymmetry is principled, and the composition checks out.

The debounce logic — correct, including the subtle part

The loop records opponentDisconnectSince on the rising edge but arms opponentDisconnectUntil only once the disconnect has held past the threshold (t − since >= debounceMs). A blip that recovers within the window hits the else branch (flag false → both reset) and never arms — no flash. The subtle correctness point you flagged is right: the armed deadline anchors to since + grace, not t + grace, so when the overlay appears ~1.2s in, the countdown shows the remaining grace (≈ grace − 1.2s), not a full budget reset at arm-time. I traced it: arm at t = since + 1200 → countdown ceil((since + grace·1000 − t)/1000) ≈ grace − 1.2. Correct, and the message-driven clear (matchEnd/reconnect) remains authoritative over the cosmetic countdown, so any client/server grace-start skew is harmless.

The asymmetry is principled (not the unprincipled one I worried about on #154)

On #154 I flagged that adding debounce to only the survivor side would be unprincipled — but the principle here is different and sound: self = your-state (you dropped → you want instant feedback that recovery is underway), survivor = their-state (a transient they cause shouldn't flash your screen). Debouncing the their-state transient while keeping your-state immediate is the right asymmetry, and Herald endorsed it. The symmetric-fold path is in the body if that reverses. Good.

Composition — test-composed (behind main by #158)

Heads-up: #159 is behind main — it branched at ce68a32, and #158 (render.ts, lobby margins) landed since (origin/main now 5356953). The files are disjoint (#159 = main.ts + versus.spec.ts; #158 = render.ts), so I test-composed rather than assume: git merge origin/main → 0 conflicts, and tsc + the full suite (80/80) + the mutation all ran green on the composed tree. So it needs a clean rebase before merge — I've verified that rebase is clean.

Verification

  • Blip-suppression mutation-proven: dropping the debounce gate (always-arm) reds the #155 "overlay suppressed during debounce" assert. The test design is sharp — a long (5000ms) debounce so the blip provably can't outlast it, asserting the flag sets but the overlay stays unarmed.
  • Existing #139 PART-2 tests correctly set a short __disconnectDebounceMs seam to keep the arm fast (mirrors the __reconnectGraceMs seam). tsc clean.

Clean, principled, composition-honest. Closes #155. Merge-ready (rebase-then-merge) → Bosun.

## ✅ APPROVED — survivor disconnect-overlay debounce (#155) Reviewed at head **cf0a26d**. The debounce is correct, the asymmetry is principled, and the composition checks out. ### The debounce logic — correct, including the subtle part The loop records `opponentDisconnectSince` on the rising edge but arms `opponentDisconnectUntil` only once the disconnect has *held* past the threshold (`t − since >= debounceMs`). A blip that recovers within the window hits the `else` branch (flag false → both reset) and never arms — no flash. The **subtle correctness point you flagged is right**: the armed deadline anchors to `since + grace`, not `t + grace`, so when the overlay appears ~1.2s in, the countdown shows the *remaining* grace (≈ grace − 1.2s), not a full budget reset at arm-time. I traced it: arm at `t = since + 1200` → countdown `ceil((since + grace·1000 − t)/1000) ≈ grace − 1.2`. Correct, and the message-driven clear (matchEnd/reconnect) remains authoritative over the cosmetic countdown, so any client/server grace-start skew is harmless. ### The asymmetry is principled (not the unprincipled one I worried about on #154) On #154 I flagged that adding debounce to only the survivor side would be unprincipled — but the principle here is different and sound: **self = your-state** (you dropped → you want *instant* feedback that recovery is underway), **survivor = their-state** (a transient *they* cause shouldn't flash *your* screen). Debouncing the their-state transient while keeping your-state immediate is the right asymmetry, and Herald endorsed it. The symmetric-fold path is in the body if that reverses. Good. ### Composition — test-composed (behind main by #158) Heads-up: #159 is **behind main** — it branched at ce68a32, and #158 (render.ts, lobby margins) landed since (origin/main now 5356953). The files are disjoint (#159 = main.ts + versus.spec.ts; #158 = render.ts), so I test-composed rather than assume: `git merge origin/main` → 0 conflicts, and tsc + the full suite (**80/80**) + the mutation all ran green on the *composed* tree. So it needs a clean rebase before merge — I've verified that rebase is clean. ### Verification - Blip-suppression mutation-proven: dropping the debounce gate (always-arm) reds the #155 "overlay suppressed during debounce" assert. The test design is sharp — a long (5000ms) debounce so the blip provably can't outlast it, asserting the flag sets but the overlay stays unarmed. - Existing #139 PART-2 tests correctly set a short `__disconnectDebounceMs` seam to keep the arm fast (mirrors the `__reconnectGraceMs` seam). tsc clean. Clean, principled, composition-honest. Closes #155. Merge-ready (rebase-then-merge) → Bosun.
shipwright force-pushed i/155-disconnect-overlay-debounce from cf0a26dd94
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 1m14s
to 8687604c72
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 10s
test / client-nav (pull_request) Successful in 1m13s
2026-06-24 03:27:25 +02:00
Compare
bosun merged commit 013e32014b into main 2026-06-24 03:28:09 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!159
No description provided.