← World map and portals · all tasks
The player who wasn't there — a phone in the background keeps walking against a socket that died in silence
world-map-and-portals/016 · created 2026-08-18 · updated 2026-08-18
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 streamsarea_updateat 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 sayspingevery 3 s. - The Area process answers
pingand 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 swhoamiso 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.gd176/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-e2egreen.
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.
View source in repository · also available as raw markdown.