---
id: connection-and-sync/001
title: Stop the bleeding — the sticky close flag, the unseeded watchdog, readable kicks, and no Area shut down under a player
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: S
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/test/client-liveness.test.ts
---

## What prompted it

Kris on the phone, v10 and then v11: the APK's character walked straight through portal rings
(server-driven, so impossible on a live channel), the other player never appeared, and the
server had the character barely moved. Read `overview.md`'s diagnosis. This task is the
smallest change that removes the cause and the two hazards v11 added, and it ships alone.

## What to do

**Client** (`game/world/client/client_main.gd`, `client/ui/app.gd`):
- `expecting_game_close` becomes a property of the peer being closed: stash the closing
  `WebSocketPeer` (`_closing_ws`) and only a disconnect observed on *that* peer consumes the
  flag; the new peer starts with the flag clear. (Minimum: clear it right after
  `game_ws = WebSocketPeer.new()` in `_connect_game_channel`.)
- Seed `_last_game_rx_ms = _last_ping_ms = Time.get_ticks_msec()` where `game_connected =
  true` is set in `_connect_game_channel` — the watchdog must never judge a fresh socket by an
  old (or zero) timestamp.
- Every branch of `_on_game_disconnected` logs a `CLIENT_EVENT type=game_disconnected
  reason=…`; `_send_game` returns `bool` and logs a no-op once per silence; `move_intent_sent`
  is logged only when the frame actually went; a `send_text` error is treated as a drop.
- `_apply_error`: `NOT_HELLO` → re-hello (as `PORTAL_REJECTED` already does); `BAD_GRANT` →
  `_handle_unexpected_disconnect`.
- `app.gd _on_area_lost`: the reason is shown on the title screen (the same line "Can't reach
  the realm" uses), not written into a chip that `_show_title()` hides.
- The hold-to-move chain gate compares against the avatar's standing height (`_standing_y`),
  not `y = 0`, so it keeps chaining in water.

**Area server** (`game/world/area_server.gd`):
- `_reject_hello` and the newest-wins takeover send their reason and hang up through
  `departing_peers` (the 1.5 s deferral 012 introduced for `portal_result`), never in the same
  tick — so this client can read `TAKEN_OVER` / `BAD_GRANT`.
- Log game-peer connect, disconnect (with cause), kick and unbind — today none of these print.
- `_send_raw_to_peer` checks `put_packet`'s return; the first failure per peer is logged.

**Director** (`game/director/src/areaInstance.ts`, `server.ts`):
- `leave` reaching zero defers `shutdownInstance` by a 60 s grace that a re-join cancels (the
  proper, presence-based rule is 003). This defuses the heartbeat-shutdown hazard now.
- `resume_portal` checks `alreadyJoined` before `join()`, like the three join handlers.

## Definition of done

- `test/client-liveness.test.ts` extended, all with the real Godot client: (a) portal hop
  then a real drop → the client re-joins (the exact zombie); (b) a healthy connect whose
  first frame arrives 300 ms after OPEN is *not* killed; (c) `NOT_HELLO` → re-hello;
  (d) `TAKEN_OVER` reaches the client before the close.
- Director tests: deferred shutdown cancelled by a re-join; `resume_portal` twice = one
  refcount.
- `test_runner.gd`, the Godot-backed suites, the live verifiers green; director deployed; web
  build + **APK v12** on `/play`. Kris re-runs APK → browser → back and walks into a door on
  each.
- `deliverables/011-*.md` created as **promised** (delivered by 008).

## Where the work lands

`land-of-lor/game/world/client/`, `game/world/area_server.gd`, `game/director/src/`,
`game/director/test/`; workspace `deliverables/`, `site/`.

## What was done (2026-08-18, laptop session)

- Client: `expecting_game_close` cleared for every new peer (the fix); liveness clocks seeded
  on connect; `_send_game` → bool, logs a dropped frame, treats a send error as a drop;
  `move_intent_sent` only when sent; every `_on_game_disconnected` branch logs a reason;
  `NOT_HELLO` → re-hello, `BAD_GRANT` → re-join; `title_screen.show_notice()` shows
  `area_lost`'s reason; the water hold-to-move gate.
- Area server: `_hang_up_later()` defers the close after `TAKEN_OVER` / `BAD_GRANT`; peer
  connect / hello / disconnect / hang-up logged; `put_packet` checked (first failure logged).
- Director: `leave(…, {deferShutdown})` — a dropped socket gives an empty Area a 60 s grace
  (`SHUTDOWN_GRACE_MS`, cancelled by any join); explicit `leave_area` unchanged;
  `resume_portal` refcounts once per socket.
- Tests: `client-liveness.test.ts` is now a five-scenario harness with the real Godot client
  (silence, sticky-flag, late-frame, not-hello, taken-over) — **sticky-flag and not-hello
  fail on the pre-fix client** (verified by stashing the fix); late-frame passes on both, so
  the "unseeded watchdog" was a frame-ordering race rather than the deterministic trigger the
  plan claimed — the seed is still right. `area-server.test.ts`: TAKEN_OVER arrives ≥1 s
  before the close; dropped-socket grace + cancel-on-rejoin. `home-portal.test.ts`: two
  `resume_portal`s = one refcount. `test_runner.gd` 176/176.
- Shipped: director `7a07744`, web build on `/play`, **APK v12**; five live verifiers green.
- **For Kris:** the APK-then-browser-then-back check, walking into a door on each.