---
id: world-map-and-portals/016
title: The player who wasn't there — a phone in the background keeps walking against a socket that died in silence
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: M
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 played v10 on the phone as the APK and in the phone's browser, one after the other, on
2026-08-17. Both worked — but the other character never moved in either, and the browser's
character never appeared in the APK at all. Two screenshots: in the browser, "kris phone
web" standing next to "kris 2" by the river; in the APK, "kris 2" alone, standing *in* the
river where Kris had just walked it.

## What it was

Not a sync bug — one Village process served both, all along, and it broadcasts every body
to every peer at 10 Hz. It was a **socket that died in silence**. When the APK went to the
background, Android dropped its TCP connection under the WebSocket and no close frame ever
came; `WebSocketPeer` went on reporting `STATE_OPEN`, and the client had no other way of
knowing. So the APK kept *predicting* moves nobody heard (kris 2 walks into the river on
the phone; the server's kris 2 stays by the bank — exactly what the browser showed) and
never received another `area_update` (kris phone web never appears). The browser was
telling the truth the whole time.

## What changed

- **The game channel has a watchdog** (`client/liveness.gd`, `client_main.gd`): the Area
  process streams `area_update` at 10 Hz to every connected peer, so 6 s without a frame
  on an "open" channel means it is dead; the client closes it and runs the existing
  re-join. Packets are drained before the check, so a tab that was merely frozen catches
  up rather than trips it. The client also says `ping` every 3 s.
- **The Area process** answers `ping` and drops peers silent for 90 s — the body stays,
  as on any disconnect; only the dead binding goes.
- **The director channel**: a request that times out closes the socket so the existing
  OFFLINE → backoff reconnect brings it back; the re-join waits up to 25 s for that and
  tries once more; on app resume (`NOTIFICATION_APPLICATION_RESUMED` / `FOCUS_IN`) the
  client probes the director with a 5 s `whoami` so a dead socket is found early.
- **The director** pings every client socket every 30 s and terminates any that hasn't
  ponged — which is what finally releases the player refcount a zombie held, so an Area
  its last real player left can shut down.
- `WebSocketPeer.heartbeat_interval` (4.4+) was considered and not used: it only sends
  pings, does nothing on the web, and can't tell a half-open socket apart on its own.

## Verified

- `test/client-liveness.test.ts`: the **real Godot client** against a fake director and a
  fake Area server that hello_acks, streams two seconds of updates, then goes quiet with
  the socket left open — the client pings, logs "silent … treating it as dead", re-joins
  through the director, hellos again, and does not thrash while updates flow.
- `test_runner.gd` 176/176 (the liveness rules); the Godot-backed suites and the headless
  client harness still pass.
- Live: deployed with the ground-types build (APK v11, web at a1dffbf); `verify-ground`,
  `verify-home-door`, `verify-world-map`, `verify-portal-destinations`,
  `verify-accounts-e2e` green.

## What only Kris can check

The exact reproduction — APK, then browser, then back — on the phone. Expected now: within
about six seconds of coming back to the APK, it re-joins on its own (the character snaps
to where the server had it — the bank, not the river), and the other player appears.