---
id: connection-and-sync/002
title: The client connection state machine — freeze rather than fake, reconnect visibly, give up honestly
epic: connection-and-sync
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/client/game_channel.gd
---

## What to do

- New `game/world/client/game_channel.gd` (RefCounted, table-driven, pure where possible):
  `IDLE → CONNECTING → HELLO_SENT → LIVE → STALLED → DEAD` (+ `LEAVING` for portal / exit).
  Inputs: socket state, a received frame, ack / reject / `SUPERSEDED`, a send failure, silence
  (6 s), oldest-unacked-intent age (2 s), portal events, explicit leave, app pause / resume.
  Owns `game_ws`, the timers, `pending_intents` (bounded, stamped with send time, discarded on
  reconnect — the server's position wins), the closing-peer bookkeeping. Emits
  `state_changed(from, to, reason)`.
- `client_main.gd` consumes it: `send_move` only in `LIVE`; in `STALLED` input is accepted but
  **not predicted** — the avatar stops — and a "Reconnecting…" overlay shows; `DEAD` → rejoin
  through the director with a **15 s** budget in a `multiplayer` Area, then a "Connection lost"
  screen (Retry / Main menu) and no play until `LIVE` again.
- New `client/director_channel.gd` — a view over `account_session.gd` (`ONLINE / OFFLINE /
  RECONNECTING`) that `client_main` *listens* to: a director drop while `LIVE` is fine; while
  `DEAD` it blocks the rejoin, and says so.
- Lifecycle: `NOTIFICATION_APPLICATION_PAUSED / RESUMED`, `FOCUS_OUT / IN`,
  `WM_GO_BACK_REQUEST` (Android back = confirm, `leave_area`, clean close). On resume both
  channels are probed: game — a frame within 2 s or `STALLED` → reconnect; director — a 5 s
  `whoami`.
- Configure `WebSocketPeer` buffers (`inbound_buffer_size`, `max_queued_packets`) before
  `connect_to_url`; build the ground mesh **outside** the packet-drain frame (deferred, chunked).

## Definition of done

`test_runner.gd` drives every transition of the table; vitest with the fake director/Area (from
001) covers: silence, unacked-only stall (pongs and updates flow, intents refused),
close-without-frame, portal hop then drop, director dead during rejoin, resume probes,
first-frame-late connects. The Godot-backed suites and the client harness still pass; 012's
rule holds (a drop during a pending portal defers to the portal fallback, never rejoins the
origin).

## Where the work lands

`land-of-lor/game/world/client/`, `game/director/test/`.

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

- `client/game_channel.gd`: the machine (IDLE / CONNECTING / HELLO_SENT / LIVE / STALLED /
  DEAD / LEAVING) with STALL_MS 2 s, SILENCE_DEAD_MS 6 s, UNACKED_DEAD_MS 6 s, GIVE_UP_MS
  15 s, PING_MS 3 s; `tick()` returns `ping` / `declare_dead` / `give_up`; 23 checks in
  `test_runner.gd` (199 total).
- `client_main.gd` obeys it: `_send_move` predicts only in LIVE (`move_held` logged once per
  stall); STALLED snaps the avatar to `confirmed_pos`; DEAD closes and re-joins **with
  retries** inside the budget (`rejoin_retry`), then `_give_up()` → `area_lost` with a
  sentence on the title; every transition logged `type=connection from=… to=… reason=…`;
  buffers 1 MiB / 8192 packets; ground mesh built off the drain frame; app resume probes
  the game channel (`on_resumed`); `connection_changed` signal → HUD notice
  (`hud.set_connection_notice`, "Reconnecting…").
- Android back (`quit_on_go_back=false` + `app._notification`) leaves cleanly and tells the
  director (`leave_area`); on the title it quits.
- Scenarios added to `client-liveness.test.ts`: **unacked** (frames flow, moves unanswered →
  STALLED, avatar held, DEAD, re-join) and **give-up** (dies, director refuses the re-join
  → retries, then `give_up` at ~15 s from the stall). Seven scenarios green; harness and
  Godot-backed suites green.
- Not done here (folded into 004): the director-channel view object — `client_main` still
  reads `_session.is_director_open()` directly; the wait is budgeted.
- Shipped with 003: director + world `cee0ccd`, web on `/play`, **APK v13**.