← Connection and sync — never pretend to be online · all tasks
Stop the bleeding — the sticky close flag, the unseeded watchdog, readable kicks, and no Area shut down under a player
connection-and-sync/001 · created 2026-08-18 · updated 2026-08-18
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_closebecomes a property of the peer being closed: stash the closingWebSocketPeer(_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 aftergame_ws = WebSocketPeer.new()in_connect_game_channel.)- Seed
_last_game_rx_ms = _last_ping_ms = Time.get_ticks_msec()wheregame_connected = trueis set in_connect_game_channel— the watchdog must never judge a fresh socket by an old (or zero) timestamp. - Every branch of
_on_game_disconnectedlogs aCLIENT_EVENT type=game_disconnected reason=…;_send_gamereturnsbooland logs a no-op once per silence;move_intent_sentis logged only when the frame actually went; asend_texterror is treated as a drop. _apply_error:NOT_HELLO→ re-hello (asPORTAL_REJECTEDalready 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), noty = 0, so it keeps chaining in water.
Area server (game/world/area_server.gd):
_reject_helloand the newest-wins takeover send their reason and hang up throughdeparting_peers(the 1.5 s deferral 012 introduced forportal_result), never in the same tick — so this client can readTAKEN_OVER/BAD_GRANT.- Log game-peer connect, disconnect (with cause), kick and unbind — today none of these print.
_send_raw_to_peerchecksput_packet’s return; the first failure per peer is logged.
Director (game/director/src/areaInstance.ts, server.ts):
leavereaching zero defersshutdownInstanceby a 60 s grace that a re-join cancels (the proper, presence-based rule is 003). This defuses the heartbeat-shutdown hazard now.resume_portalchecksalreadyJoinedbeforejoin(), like the three join handlers.
Definition of done
test/client-liveness.test.tsextended, 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_OVERreaches the client before the close.- Director tests: deferred shutdown cancelled by a re-join;
resume_portaltwice = 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-*.mdcreated 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_closecleared 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_sentonly when sent; every_on_game_disconnectedbranch logs a reason;NOT_HELLO→ re-hello,BAD_GRANT→ re-join;title_screen.show_notice()showsarea_lost’s reason; the water hold-to-move gate. - Area server:
_hang_up_later()defers the close afterTAKEN_OVER/BAD_GRANT; peer connect / hello / disconnect / hang-up logged;put_packetchecked (first failure logged). - Director:
leave(…, {deferShutdown})— a dropped socket gives an empty Area a 60 s grace (SHUTDOWN_GRACE_MS, cancelled by any join); explicitleave_areaunchanged;resume_portalrefcounts once per socket. - Tests:
client-liveness.test.tsis 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: tworesume_portals = one refcount.test_runner.gd176/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.
View source in repository · also available as raw markdown.