# Server architecture

## overview.md
```yaml
id: server-architecture
title: Server architecture
area: server
summary: Architect the server software that runs the actual game — the client is substantially an interface, not the core mechanics.
repo: land-of-lor
depends_on: []
```

## What this is

The architecture for the software that runs Lor: The Ten Thousand Things' world simulation: the grid, recipe matching, and the mechanics governing every Complex Item in it (trees, towers, dragons, players themselves — §8).

## Why it exists — the key architectural stance

**Kris's explicit position: much of the game runs server-side; the client is substantially an interface rather than the core mechanics.** This is not a generic "make it multiplayer" note — it follows directly from the design itself:

- Discovery is meant to be *real* — "the world contains all its secrets physically" (§8). If transmutation and recipe-matching ran client-side, the secrets would live in client code, inspectable and fakeable.
- The economy (blueprints as tradeable knowledge, the barter marketplace, see `barter-marketplace`) needs a single source of truth for what any player actually holds.

So the client's job is largely: render the grid, send intents (place this item, cast this recipe, walk this direction), and display authoritative state pushed back from the server. This is a bigger, harder build than a typical single-player-with-cloud-save mobile game, and it's worth stating plainly rather than discovering it halfway through a client-first build.

## Performance — a standing requirement, stated 2026-08-12

**Kris, by voice: performance is front and centre.** The game is meant to load in many players at once, and on the server that matters enormously. Any decision anywhere in this epic that carries a performance cost must be pushed back on and explored for a best-practice alternative, not quietly accepted because it's the simplest thing to build. This is a review criterion for every decision here, alongside the offline-play constraint (see "Offline play" below) — stated in the same spirit, so neither gets silently deprioritised.

## Scope

- Data model for per-cell floor/occupant slots on the grid.
- Recipe-matching engine: matched via the **cauldron** — not spatial pattern recognition on the grid — mechanism details still to be designed (§8, corrected 2026-08-12).
- The budget-rule derivation function itself is still to be decided (§9, flagged in the source docs as "the hardest design object in the game") — but its enduring goal is fixed: the game must not permit god-mode, and that anti-god-mode intent is the durable requirement any derivation function has to satisfy.
- Authority/networking model: what's authoritative server-side vs. what the client predicts/renders locally — **decided below**, not open any more.

## Non-goals

- Not deciding hosting/deploy/environments — that's `server-instance`, which depends on this epic landing first.
- Not designing the marketplace's economic rules beyond what it needs from this epic (state ownership, transactions) — see `barter-marketplace`.
- Not the ground-type/traversal spec — see `world-traversal-and-ground`, which is explicitly a separate, still-undefined layer.
- Not the actual droplet/hosting work — that's `server-instance`, which executes against this epic's decisions rather than making its own.

---

## Architecture decisions (2026-08-02 architecture conversation)

The three memo tasks below (§ tasks/001–003) were superseded by a direct architecture conversation rather than by the memos they were meant to produce — see each task file for how it was closed out. This section is the durable record of what was actually decided; treat it as ground truth for future sessions, ranking above the now-closed tasks if the two ever seem to disagree.

### Topology — sharded areas, not one shared world

**Roblox-style, not MMO-style.** Many independent areas, each serving dozens of players, connected by portals — explicitly **not** thousands of players in one simulated space. Area processes never talk to each other directly; anything crossing a portal goes through the director (below), not a direct process-to-process link.

**Membership, not matchmaking.** An area record has an owner and a member list. The director routes a player to areas they're actually a member of — semantics borrowed from a Minecraft server whitelist, economics borrowed from Roblox's on-demand-instance model (an area process only exists, and only costs anything, while someone's actually in it).

**Servers vs. areas — terminology fixed 2026-08-12.** A **server** is a new, distinct outer concept from an area: an independent instance of the entire server stack (director + Postgres + area processes) — a hard-configuration deployment thing, not something a player crosses through play. All servers are literally independent instances of the same server code. The client, Minecraft-style, specifies which server to connect to — chosen deliberately because players already understand Minecraft's server-selection model. Consequences: portals route only *within* a server, via that server's own director; area processes never talk to each other directly, whether within a server or across one; moving between servers is a client-side reconnect to a different server, not a portal; and accounts, membership, and all persistence live per-server. An **area**, by contrast, is a scene/room/region players portal between — all pathfinding and running activity happens within one. Players each have a home area; there are shared areas; a battle arena is a special kind of area; and offline play happens in a special area or areas — consistent with the "different areas impose different rules" concept in the Offline play section and `world-map-and-portals/001` (the terminology now matches that concept exactly). A portal between areas can also take the form of a border/boundary crossing, not only a literal portal object.

### Persistence — the core architectural idea

**Area process lifetime and area persistence are independent.** The *process* is disposable; the *area record* is durable. This is the idea everything else in this section serves.

*(Naming note, 2026-08-12: the Stage 1–3 PoC code and Postgres schema still use `world` naming throughout — e.g. the `worlds` table and its `last_played_at` column. Renaming code and schema to `area` is follow-up work, not part of this doc edit; this document describes the target terminology, not the code as it currently stands.)*
**CLOSED 2026-08-16** — that follow-up landed as `world-map-and-portals/015`: the table is `areas`, the wire says `areaId`, and the GDScript matches. The note above stays because it is the record of the terminology being fixed four days before the code could follow it.*

**Lifecycle:**
1. A player requests an area.
2. The director checks membership.
3. An area process spawns and hydrates its state from Postgres.
4. Live simulation runs, with periodic snapshots back to Postgres.
5. The last player leaves.
6. A final snapshot is written.
7. The process exits. The area's row in Postgres persists — nothing about the area is lost by the process going away.

**Decided: an empty area is a frozen area.** No simulation runs while an area is empty, and there is no offline fast-forward when a player returns — time simply does not pass for an unoccupied area. **Design consequence, stated plainly so it doesn't get silently reversed later:** machines and any Complex Item with its own internal mechanics (§8 of `docs/README.md` — a tree, a tower, a dragon) do **not** run while every player who could observe them is away. A player returning to their home base after a week away finds it exactly as they left it, not further along, not decayed, not caught up.

**Carry a `last_played_at` column on the area record from day one**, even though nothing reads it yet — this keeps a later, deliberate decision to add offline progression (fast-forwarding an area by however much time passed) possible without a schema migration blocking it. Adding the column costs nothing now; retrofitting it once areas already exist without it is real migration work.

**Snapshot cadence: every 30 seconds during active play, plus one on graceful shutdown.** Rationale is crash tolerance, not bandwidth or storage efficiency — a mid-session crash should cost a player at most 30 seconds of building, never more.

**Snapshots flow via the director — decided 2026-08-03 by Kris, after reviewing the trade-offs.** The area process *produces* each snapshot (a serialised state blob sent over the existing area↔director channel); the director *persists* it to Postgres. Area processes never hold DB credentials or a Postgres client — the director owns all database access, schema, and migrations.

Rationale:
- Keeps GDScript out of the database entirely — Godot has no first-class Postgres driver, and the most critical durability path shouldn't rest on a third-party addon; Node's `pg` is mature.
- Clean separation: area = simulation only; director = orchestration + all persistence. One codebase owns schema/credentials/pool/migrations.
- Symmetric with hydration, which already flows director→area at spawn — state crosses one boundary, both directions, over a channel that already exists.
- Known costs, accepted: the director is a persistence funnel (trivial at this project's scale of dozens of areas per droplet) and a director crash pauses snapshots for all areas — which sits inside the already-stated 30-second crash-tolerance budget.

**Required mitigations, part of the decision, implemented starting Stage 2 (`server-architecture/006`):**
1. Snapshots are idempotent — keyed by area id + monotonic tick/sequence number, so retries are always safe.
2. The area process buffers its last unacknowledged snapshot and retries on reconnect if the director is briefly unavailable.
3. Graceful shutdown requires a persistence ack from the director before the area process exits.

This decision strengthens the Postgres-as-transfer-medium candidate for portals (snapshot player out of area A via the director, hydrate into area B) — see "Not yet decided: the portal-transfer mechanism itself" below, still pending Kris's confirmation at Stage 3 pickup.

### Runtime

**Area processes: Godot 4, headless, one OS process per area, over `WebSocketMultiplayerPeer` — not ENet.** The load-bearing reason for WebSocket over Godot's usual ENet multiplayer stack isn't browser compatibility (though it helps) — it's that **the same Godot codebase runs on both the authoritative side and the predicting client**, which eliminates prediction-divergence bugs, the dominant risk category whenever client-side prediction is reimplemented in a second language against a first language's authoritative sim. One codebase, one set of movement/collision rules, both sides.

`lor-elementals`' existing split is already the right shape for this and should be the pattern the new server-side code follows: `Entity` (a pure `RefCounted` data class — stats, blueprint, inventory, no `Node` dependency) is exactly what serialisable, server-authoritative state should look like; `EntityNode` (the `CharacterBody2D` wrapper handling physics/rendering) is exactly client-side concern. The area process runs `Entity` logic (plus whatever area state it owns); the client additionally runs `EntityNode` for rendering and local prediction. This isn't a new pattern to invent — it's `lor-elementals/scripts/entities/entity.gd` and `entity_node.gd`, reused on purpose.

**Director service: a separate, long-running process** owning the area registry, membership, area-process lifecycle (spawn/hydrate/snapshot/exit above), portal routing between areas, and accounts. **Decided 2026-08-03 (confirmed directly by Kris): Node/TypeScript**, for the tooling-reuse reason already stated here — the droplet (deliverable #4) already carries Node tooling for the site and its deploy watcher, so there's no second toolchain to provision. Go is no longer an open candidate. Implemented in `land-of-lor/pocs/multiplayer-poc/director/` (`server-architecture/005`, Stage 1).

**Database: PostgreSQL, on the same droplet** as the area/director processes (per deliverable #4).

**Escape hatch, stated so it isn't reached for prematurely:** if GDScript's performance genuinely proves insufficient somewhere (most likely a pathfinding or interest-management inner loop), move *only that specific hot function* to a GDExtension (C++/Rust) call. Do not rewrite the game, or even the surrounding system, in a different language — this is a targeted escape hatch for one function at a time, not a signal to abandon Godot server-side.

### Netcode

**Server-authoritative, always.** The client sends *intent* ("I am pressing move-north," "I am casting this recipe") and never sends position directly — position is something the server computes and the client is told, never something the client asserts.

**Client-side prediction plus server reconciliation, from day one.** Not deferred: retrofitting prediction onto a netcode layer that didn't have it from the start is close to a rewrite, not an incremental addition, so it's built into stage 3 of the PoC build plan (see deliverable #4's session and the epic-placement question raised there) rather than left until performance or feel demands it.

**Transport: WebSocket.** **Message format: JSON for v1**, behind a versioned, schema-defined message envelope — the envelope's job is specifically to make a later move to a binary codec (MessagePack, a hand-rolled binary format, etc.) a serialisation-layer change only, never a protocol-shape change. Don't let JSON's convenience during early development leak into the message *shape* in ways that would make swapping the encoding later a bigger job than it needs to be.

**Interest management from day one, not added later.** Each client receives state only for entities within some radius, computed via a spatial partition (a grid-based spatial hash is the natural fit given the area is already grid-based). This isn't an optimisation to defer — broadcasting full state to every client is O(n²) in player/entity count and fails outright somewhere around a few hundred entities, well within what a single populated area could plausibly hold.

**Tick rates, deliberately decoupled from each other:**
- Simulation: 20 Hz.
- Network snapshots to clients: 10 Hz, with the client interpolating over roughly a 100 ms buffer to smooth the gap between snapshots.

### Pathfinding and sensing

**Fields are cut — decided 2026-08-12.** Pathfinding is the real scaling concern for this project's actual target load: **hundreds of creatures, from multiple spawn points, converging on a handful of targets** (a home castle, a player) — the tower-defence-style load `battle-arena-poc` and the series' recurring "castle defence" thread both point toward.

**Use flow fields (Dijkstra maps), not per-agent A\*.** One outward Dijkstra sweep from a target produces a direction grid covering the whole reachable map; every creature moving toward that target then does a single array lookup per tick, not a pathfinding search. Cost scales with the **number of distinct targets**, not the number of agents converging on them — the mechanism that makes "hundreds of creatures" tractable at all. Practical bounds: recompute a target's flow field only when it moves to a new cell (not every tick); cap total pathfinding work to a few milliseconds per tick via a work queue, with a slightly stale field being an acceptable, imperceptible trade-off rather than something to chase perfect freshness on. Reserve genuine per-agent A\* for the actual exception case: a single agent with a unique destination nobody else shares (e.g. a player's own click-to-move target, matching `lor-elementals`' existing `player_character.gd` pattern).

**Required change to the grid data structure this depends on:** `lor-elementals/scripts/systems/grid_manager.gd` currently keys its cell dictionary by the string `"layer,x,y"` (a `Dictionary` lookup with string concatenation on every access). That's fine for a single-player prototype; it is **a performance disaster inside a flow-field inner loop**, which is exactly the kind of code this project now needs to run fast, many times per tick. Whatever grid implementation lands in `land-of-lor` (informed by, but not copy-pasted from, `lor-elementals`' pattern) must instead use a **flat `PackedInt32Array` indexed by `(layer * h + y) * w + x`**, with every hot-loop variable statically typed (`var x: int`, not untyped `var x`) — GDScript's static typing measurably matters in code that runs this often. This is a concrete, actionable correction to carry into whatever replaces `grid_manager.gd`'s pattern for the new server-side world, not a note about the dormant `lor-elementals` repo itself.

**Sensing:** register emitters (position, strength, radius — e.g. a hoard of stored food, same design intent as `docs/README.md` §3's "a beacon of scent and vitality") in a **spatial hash**, and answer "what can this creature sense from here" as a spatial query against that hash. A large hoard advertises itself; a quiet one doesn't. **Line of sight:** grid shadowcasting. Both of these ride on the same spatial index that interest management (above) already requires — one piece of infrastructure serving two systems, not two separate builds.

### Identity

**Persistent accounts from day one.** Area ownership requires durable identity — an area record's `owner` field has to point at something that outlives a single session. **No authentication for the MVP:** issue and remember an ID (a token in local storage / a save file, however the client stores it) with no password or login flow behind it yet. The reason this is safe to defer and unsafe to skip entirely: **adding real auth to an existing, populated user-record table is straightforward; retrofitting durable identity onto sessions that were built anonymous is not** — it means matching up orphaned data to accounts after the fact, per-player, by hand or by heuristic. Get the identity column and its foreign keys right now; the login form can come later.

### Accounts & auth — one identity per account (2026-08-28)

**Superseded by Kris, 2026-08-28 (`server-architecture/021`): no more guest accounts, and no more "attach, don't merge."** The MVP-era identity model above and `historic/010-accounts-plan.md`'s "one durable account, several auth identities attached over time" premise are both retired. Going forward:

- **A player must have an account, and must be signed in, to play — full stop.** There is no credential-less guest state anywhere in this client any more, not even transiently at first boot.
- **An account and its one auth identity (email+password, Google Play Games, Apple Game Center, or — later — Steam) are created together, in the same transaction, and stay paired one-to-one forever.** No attaching a second identity later, no linking an existing account to another provider, no "guest now, add an email later." Enforced at the database level (`auth_identities.account_id` is now `UNIQUE`), not just by client behaviour.
- **Cross-device linking (link codes, QR codes) is gone entirely.** The only way to sign in on a second device is to know the email+password (the one credential type that travels); a platform-provider account (Play Games / Game Center) is inherently tied to that platform's own device-level sign-in and was never link-code-portable in a meaningful sense anyway.
- **"Remembered logins" on a device are a local convenience list only** — display name, email address (or "Game Center"/"Play Games" for a platform entry), and an avatar seed, stored client-side so a returning player can tap their name instead of retyping an email. They are never a second credential and never synced or portable between devices.
- Steam stays speced-only (schema + protocol seam), unbuilt, same as before — this decision doesn't change that.

This closes the loop `historic/010-accounts-plan.md` opened: that doc's "attach, don't merge" design is kept for provenance, not as live guidance.

### Offline play — a standing requirement, stated 2026-08-12

**Kris, by voice: the finished game must be playable offline.** He acknowledged it is
probably not achievable on the current build, and stated it now on purpose — so that
nothing gets architected in a way that forecloses it. Treat it as a constraint on every
decision in this epic from here on, not as a feature request sitting in a backlog.

What it does *not* mean: the same game with the network removed. Shared areas are
inherently unavailable offline, and resource rules may have to differ. What it does mean
is that "offline" should fall out of a more general concept Kris raised in the same
breath — **different areas impose different rules** (a battle arena, for instance, likely
won't let you bring your whole inventory in). Once an area carries its own rule set,
offline is one more rule set rather than a second codebase. That concept is being specified
in `world-map-and-portals/001`.

Concretely, for this epic: an authentication or session design that makes the client
useless without a live director violates this (see `tasks/010`), and the `worlds` table's
`last_played_at` column — carried from day one specifically to keep offline progression
possible without a migration — is the kind of foresight this requirement asks for
everywhere else. Still open, deliberately: what happens to resources gathered offline, and
whether offline progress reconciles on reconnect.

### Where PoC code lives

`land-of-lor/pocs/multiplayer-poc/` — following this repo's existing `pocs/` convention (adopt/ditch/partial marking, per `land-of-lor/pocs/README.md`), not a new location invented for this.

## Build plan — decided 2026-08-02: lives in this epic, not a new one

**Kris's decision:** the staged multiplayer PoC build plan is tracked as tasks under `server-architecture` itself — not a new epic, and not folded into `battle-arena-poc`, `character-composition-poc`, `world-traversal-and-ground`, or `object-system-experiments` (none of which touch server implementation; all four are client-facing content/design epics). This closes out the placement question the previous session raised rather than decided unilaterally. See `tasks/004`–`008` for the tracked stages.

**Shape of the plan**, each stage gated on Kris verifying before the next begins:

- **Stage 0 — spec approved.** This "Architecture decisions" section, and deliverable #4. Already done — see `tasks/004`.
- **Stage 1 — director service + Postgres + protocol. No Godot involved.** Verified by a CLI test client proving an area hydrates from Postgres, persists across a full process restart (kill the director, restart it, the area is still there), and that membership routing actually enforces who can enter which area.
- **Stage 2 — Godot headless area process** with grid movement, server-authoritative (client sends intent, server computes position), automated tests passing.
- **Stage 3 — Godot client** with client-side prediction and server reconciliation; two clients connected simultaneously, each seeing the other move — plus portal routing: a player crosses a portal from area A to area B, with membership enforced at the destination, the destination area spawned/hydrated on demand if not already running, and the player's entity state transferred through the director with no loss or duplication. **Also, added 2026-08-03:** Stage 3's close-out includes a **web export of the client deployed to the droplet** — the concrete way Kris human-verifies the stage (two browser tabs, each seeing the other move), and the substance of deliverable #6 (`deliverables/006-play-in-your-browser.md`). See `tasks/007`'s step 8 for the deploy-key and HTTP/TLS prerequisites this carries.
- **Stage 4 — interest management, creatures, flow-field pathfinding**; hundreds of agents processed within the tick budget (§ Netcode, § Pathfinding above).
- **Task 009, added 2026-08-03 — Android APK export & download link**, blocked on Stage 3 (`tasks/007`) rather than gated to a specific stage number of its own: same unchanged Stage 3 client, packaged as an installable APK and linked from the deliverables page. Substance of deliverable #7 (`deliverables/007-the-two-phones-android-apk.md`). Explicitly zero new gameplay/netcode — an export target, not a build stage.

**Every stage ships automated tests plus a headless test-client script, not a manually-clicked demo** — the AI Task Runner container this work is developed in likely cannot expose a listening port for a human to connect to directly, so a stage's own automated verification has to stand on its own; a human (Kris, on the droplet once `server-instance/001` lands) does the final real-world check, but a stage isn't "done" pending that — it's `review` pending that. **Every stage ends by writing a handoff note into the repo** at `land-of-lor/pocs/multiplayer-poc/handoff/stage-N.md` — what was built, how to run its tests/test-client, exactly what Kris needs to verify, and what the next stage assumes. Each stage task's own `state` goes to `review` (not `done`) once its automated tests pass and its handoff note is written; only Kris (or a session acting on his explicit confirmation) moves it to `done` and unblocks the next stage.

**Decided 2026-08-03, at Stage 3 pickup: the portal-transfer mechanism is Postgres-as-transfer-medium, via the director.** A portal crossing forces an out-of-cadence, final snapshot of the player's entity state out of area A (not waiting for the next periodic tick) — sent over the same area→director control channel Stage 2 already built for persistence. The director validates the player's membership in area B, then spawns/hydrates area B on demand if it isn't already running (the same spawn/hydrate path any player joining an area already goes through), and hydrates the player's entity into it. Area processes never talk to each other directly — everything crosses the director, consistent with the topology decision above. Confirmed consistent with the 2026-08-03 "snapshots flow via the director" persistence decision, which this reuses rather than introduces a second mechanism alongside. **Load-bearing constraint carried into Stage 3's implementation and tests:** a crash mid-transfer must leave the player safely recorded in Postgres — never lost, never duplicated — which Stage 3's automated verification tests directly with a real process kill during a portal crossing.

## Key open questions

None. The director runtime/language (Go vs. Node/TypeScript) was the epic's one remaining open question; it closed 2026-08-03 (see "Runtime" above) — Node/TypeScript.

## Relevant docs

- `land-of-lor/docs/README.md` — §2 (three-tier authorship), §3 (one substance/two topologies), §5 (fields — cut from this architecture 2026-08-12; the idea may live on elsewhere as a stashed concept, but it is not part of this epic's design), §7 (changing lines), §8 (recipes/formulas), §9 (budget rule), §13 (open engineering flags).
- `land-of-lor/docs/thought-specs/battle-areans-and-element-types.md` — the ley-lines/conduit vision that originally implied real-time shared field state during battles (fields since cut, 2026-08-12); the sensing-via-spatial-hash decision above is one way that vision survives without needing them.
- `deliverables/004-server-setup-guide.md` — the hosting/deploy side these runtime decisions assume (droplet, systemd units per-process, Postgres alongside).
- `lor-elementals/scripts/entities/entity.gd` and `entity_node.gd` — the existing data/rendering split this epic's server/client architecture reuses directly.
- `lor-elementals/scripts/systems/grid_manager.gd` — the grid pattern to learn from and specifically *not* repeat (string-keyed `Dictionary`) in the new implementation.

## status.md
```yaml
updated: 2026-08-28
parked: false
tasks: {"backlog":2,"needs-input":4,"ready":0,"doing":0,"review":0,"done":16}
open_questions: []
tag: blocked
next: 021 (single-identity accounts + unified account screen) built, tested, and DEPLOYED LIVE 2026-08-28 — needs Kris's own visual/on-device look (no display in the building container, same gap as 019) plus the Android APK rebuild+upload before that ships to phones. Remaining, unchanged: 018 spellgrove.com; 011's Play Console setup; 012's iFastNet DNS records; 020's Apple checklist; 017 (backlog) needs a support address picked.
```

## Task summary

1 backlog · **4 needs-input** · 0 ready · 0 doing · **1 review** · 15 done (21 total).

**021 (single-identity accounts + unified account screen) is `review`**, built
2026-08-28 straight from Kris's voice spec: no more guest accounts — an account and its
one auth identity are created together, atomically, forever (device-link codes gone
entirely); the title/signin/signup/account screens collapsed into one `account_flow.gd`
wrapper with a real rotating 3D character-preview placeholder. Protocol bumped to v5.
Director 210/210 (two pre-existing, unrelated content-fixture failures untouched; the
Godot-process-heavy suites pass 40/40 in isolation — full-suite flakiness confirmed to be
sandbox concurrency, not a regression), world 283/283, live verifiers 28+10+10/48.
Deployed: backup verified, 18 pre-launch test accounts + their 17 home areas wiped (the
authored Spellgrove content and its one system account kept), director restarted, live
green. Handoff: `land-of-lor/docs/handoff/historic/021-single-identity-accounts.md`.
Needs Kris's own look at the rendered screens and the Android APK (bumped to v20, not
yet built/uploaded this session) before calling the UI half visually done.

**019 (account screens + remembered logins) is DONE**, built 2026-08-26 straight from
Kris's voice spec: the account screen's two-panel redesign (Authentication +
Character), the signed-out "Log in again as X" remembered-logins list (quick
resume, long-press forget, never an account-delete path), and the one additive
director change it needed (`whoami`'s `providers` list). No open questions —
the spec's ambiguous edges were assumed and flagged rather than raised,
per `PICKUP.md`'s question bar. Handoff:
`land-of-lor/docs/handoff/historic/019-account-screens-and-remembered-logins.md`.

**Task 020 (Apple Game Center sign-in) is `needs-input`** — split out from 019
the same way 011 split Play Games out from 010. Buildable server + client seam
done and tested (director 170/170, world 116/116) alongside 019 the same
session; waiting on Kris's Apple Developer Program enrollment + App Store
Connect Game Center setup — checklist with links in
`land-of-lor/docs/handoff/historic/020-game-center-server-half.md`.

**The accounts arc — 010, 013, 014 — is DONE**, signed off by Kris 2026-08-13
after confirming the fixed game and the new `land-of-lor-v1.apk` live:

- **010 (real accounts)**: accounts v2 deployed 2026-08-12 (auth_identities
  providers, sessions, join grants, collections storage), independently
  audited 2026-08-13 (`../010-accounts-audit.md` — foundation solid, findings
  became 013/014). Handoff: `land-of-lor/docs/handoff/historic/010-accounts.md`.
- **013 (account-first flow)**: guest-first reversed — explicit signup (name +
  character-placeholder seed pick, the seam for the future hexagram model in
  `character_seed.gd`), world gated on SIGNED_IN, session state machine (no
  auto-guest, timeout never clears an account), `?link=` QR redemption,
  reconnect backoff, seed + display name end-to-end (overhead labels show
  real names). Includes the same-day pinned-character fix (a pre-hello
  world_update race that froze the character — Kris's Android
  touch-doesn't-move bug) and the APK line-in-the-sand naming rule
  (`game/APK_NAME`, now `land-of-lor-v1`; rule in `export-android.sh`'s
  header). Handoff: `land-of-lor/docs/handoff/historic/013-account-first-flow.md`.
- **014 (server hardening)**: the audit's full batch — reset revokes
  sessions, world_state accountId strip, rightmost-XFF, limiter eviction +
  per-IP login cap + link-code mint limit, maxPayload + string caps (which
  exposed and fixed a latent director crash on oversize frames), scrypt N
  honored, TAKEN_OVER + players refcount + joinedWorlds pair set,
  timing-enumeration fixes, server lows. Skipped by design: the
  movement-budget cap 30→40 (gameplay feel — Kris's call, flagged in the
  handoff). Handoff: `land-of-lor/docs/handoff/historic/014-server-hardening.md`.

Final state deployed and verified: director suite 89/89, world suite 86/86,
live checks 9/9 + 4/4 + 5/5 + 29/29; web embed and `land-of-lor-v1.apk` both
serving the fixed client.

**Task 017 (password reset by hand) is `backlog`** — Kris's 2026-08-13 call:
email login stays, but there is no self-service reset; players email a support
address and Kris resets manually. Both automated approaches are off the table
(the emailed link needs DNS on an API-less panel; a recovery code written down
at signup was built and reverted the same day as the wrong shape for a game
whose players are children). The server side of that landed the same day: `request_password_reset` now
answers `RESET_UNAVAILABLE` honestly instead of acking a reset email it never
sends, with the contact address read from a new `SUPPORT_EMAIL` env var
(deployed, 101/101 director, live verifies 9/9 + 32/32). What's left is
genuinely just picking an address — `@cocreations.com.au` is out, its MX
accept-alls into a free-hosting box with no mailbox behind it.

**Task 018 (spellgrove.com) is `needs-input`** — Kris's 2026-08-13 call, and
the deliberate fix for a day lost to DNS: register the working title's domain
and set it up simply for the site and for email, with DNS under an API key an
agent can drive. `ai-task-runner.com` is explicitly rejected for this.
Registering costs money and is Kris's step; everything after is agent work.

**Task 008 (Stage 4) `done`** — signed off by Kris 2026-08-18. **012 (Resend setup) `needs-input`** — narrowed 2026-08-13 to exactly one
remaining action: publishing Resend's DNS records for
`mail.lor-server.cocreations.com.au` in **iFastNet's** zone editor (the
domain delegates to ns101/ns201.ifastnet.com, so the registrar's API can't
do it). Account made, key generated and sitting commented-out on the droplet
— uncommenting it before the domain verifies makes `attach_email` fail
outright, which is why it waits. **016 (base-URL split) `done`** (signed off 2026-08-18) — the QR
link no longer encodes localhost or a dead `/director/play` path;
`EMAIL_VERIFY_BASE_URL` and `LINK_CODE_BASE_URL` now point at the director
and the web client separately, deployed and verified live.
**011 (Play Games sign-in) `needs-input`** — the buildable server half landed
and deployed 2026-08-13 (director auth-code exchange behind an injectable
verifier, mocked-Google tests, client seam with buttons gated on the plugin
singleton; 99/99 director, 86/86 world, live verifies green including the
new PROVIDER_UNAVAILABLE probe); waiting on Kris's Play Console setup — the
handoff `land-of-lor/docs/handoff/historic/011-play-games-server-half.md` carries the
step-by-step checklist with both SHA-1 fingerprints, then the device half
(vendor the plugin, wire the auth-code fetch, on-device e2e).
**015 (first iOS build) `done` (signed off 2026-08-18) — it WORKED**: the game ran on Kris's iPad
Pro on 2026-08-13, same day as the attempt; `game/export-ios.sh` (with
--deploy straight to the device) makes the next build one command. The Mac
runbook for today's attempt; repo prep (iOS export preset) already landed.

## Open questions

None.

## Next

008, 015, 016: Signed off by Kris 2026-08-18 (blanket approval of everything then in review). **011's server half is done and live**; its device half waits on Kris's Play
Console steps (checklist + SHA-1s in the handoff). **019 (the account-screens
design session) is done** — built 2026-08-26 straight from Kris's own voice
spec, see above. **020 (Game Center)'s server+client seam is built the same
day**, waiting on Kris's Apple Developer Program steps, same shape as 011.

**Kris:** 018 — register spellgrove.com (the unblocking move for the DNS/email mess); 011's Play Console setup; 020's Apple Developer Program + App Store Connect Game Center setup (checklist in its handoff); 012's iFastNet DNS records (then uncomment the key and re-run `game/update_game_env.sh`); 017 — pick a support address; a real look at 019's two-panel account screen (no display in the container that built it — it's untested visually); the movement-budget cap call from 014's
handoff. Still outstanding, non-blocking: the droplet's read-only deploy key
(deliverable #6) to retire the git-bundle deploy workaround.

## tasks/ (22)

### server-architecture/001 — Field-simulation scaling memo
```yaml
id: server-architecture/001
title: Field-simulation scaling memo
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-05
updated: 2026-08-02
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## Resolution (2026-08-02)

**Superseded, not written as originally scoped.** A direct architecture conversation answered the question this memo was meant to survey, without the memo itself ever getting written: **fields are now unlikely to survive into the final design** in their original per-cell-diffusion form. The actual scaling constraint this project faces turned out to be pathfinding (hundreds of creatures converging on a handful of targets), not field diffusion — solved with flow fields/Dijkstra maps, not a field-scaling technique from this task's original survey list (chunking / update-on-change / coarser resolution). Sensing (a creature noticing a hoard, a scent radius) is preserved without diffusion via spatial-hash emitter queries instead.

Full decision recorded in `server-architecture/overview.md`'s "Pathfinding and sensing" and "Netcode" sections — read that, not this note, for the actual architecture. This task is closed because the question is answered, not because the original memo was produced.

## What to do

Write a short architecture memo surveying how the four field layers (Charge, Heat, Tempo, Boundary — `land-of-lor/docs/README.md` §5) can be simulated without naive per-cell diffusion, which the docs already flag as "the first real engineering constraint" (§13). Survey at least: chunking (only simulate loaded regions), update-on-change (propagate only from cells whose value actually changed this tick), and coarser field resolution than the item grid (fields as a lower-resolution overlay). Weigh them against this project's actual needs — collaborative terraforming and shared field state during battle arenas (`battle-arena-poc`) — not a hypothetical MMO scale.

## Definition of done

A memo (add it under `land-of-lor/docs/` or as this task's own follow-up content — author's choice, note where it landed) that: describes each approach in a paragraph, gives an honest trade-off table, and ends with a recommendation. This is research/writing, not implementation — no code, no engine chosen yet.

## Where the work lands

`land-of-lor` repo (the memo becomes a doc there, per that repo's own docs/ conventions).

## Docs to read first

`land-of-lor/docs/README.md` §5 (fields), §13 (this exact open engineering flag); `land-of-lor/docs/thought-specs/battle-areans-and-element-types.md` (the ley-lines vision that implies real-time shared field state during a live match — the concrete case this scaling approach has to handle).

### server-architecture/004 — Stage 0 — spec approved
```yaml
id: server-architecture/004
title: Stage 0 — spec approved
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: S
created: 2026-08-02
updated: 2026-08-02
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What this stage is

Not a build task — a checkpoint. The staged multiplayer PoC build plan (this task through `008`) only starts once the architecture it implements is actually settled, not still being argued about. It is: see `server-architecture/overview.md`'s "Architecture decisions" section (topology, persistence, runtime, netcode, pathfinding/sensing, identity) and `deliverables/004-server-setup-guide.md` (hosting). Both exist, both came out of a real architecture conversation with Kris, and this task exists so the staged plan below has a visible, tracked "and here's the checkpoint that unlocked it" rather than starting from an implicit assumption.

## Resolution

Done as of 2026-08-02 — this task is retroactive, recording that the precondition for stage 1 is already satisfied, not requesting new work.

## Where the work lands

N/A — this task records a decision, not code.

## Docs to read first

`server-architecture/overview.md`'s "Architecture decisions" and "Build plan" sections; `deliverables/004-server-setup-guide.md`.

### server-architecture/005 — Stage 1 — director service + Postgres + protocol (no Godot)
```yaml
id: server-architecture/005
title: Stage 1 — director service + Postgres + protocol (no Godot)
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-02
updated: 2026-08-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What to do

Build the director service and its Postgres-backed persistence, with no Godot process involved yet — this stage proves the persistence model (`overview.md`'s "Persistence" section) and the message protocol shape (`overview.md`'s "Netcode" section) work, before any game engine is in the loop at all.

1. **Schema:** a `worlds` table (at minimum: id, owner, member list or a join table, `last_played_at` — per the decided-from-day-one column even though nothing reads it yet — and whatever snapshot-state column/blob the world's serialised state lives in) and whatever `accounts`/identity table backs the "no auth for MVP, but a real durable ID" decision.
2. **Director process:** owns world lookup, membership checks, and (stubbed is fine at this stage, since there's no Godot process yet to actually spawn) the world-instance lifecycle hooks. Runtime: agent's choice between the two candidates `overview.md` names (Go, or Node/TS leaning per the stated tooling-reuse reason) — pick one, state which and why in the handoff note; this task is what actually answers the epic's one remaining open question, in practice if not yet by updating the epic's own prose.
3. **Protocol:** the versioned, JSON, schema-defined message envelope described in `overview.md`'s "Netcode" section — implement it for whatever minimal message set this stage needs (create world, join world, world-state response), not the full in-game protocol yet.
4. **CLI test client:** a script (not a GUI) that: creates/loads a world, confirms it hydrates from Postgres, and — this is the actual proof this stage exists to deliver — **kill the director process, restart it, and confirm the world's state (and membership) survived the restart intact.** Also confirm membership routing actually rejects a non-member's request to join a world they don't belong to.
5. Automated tests covering the above (schema round-trip, protocol envelope encode/decode, the restart-persistence property, the membership-rejection property).
6. Write the handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-1.md` — what was built, which runtime was chosen and why, how to run the tests and the CLI test client, and what stage 2 can assume exists (the protocol shape, the schema, how to talk to the director).

## Definition of done

Automated tests pass; the CLI test client demonstrably proves world-hydration-from-Postgres and survival-across-a-full-process-restart; the handoff note exists. Set this task's own `state` to `review` once that's true — **do not mark it `done` yourself**; that's Kris's call (or a session explicitly acting on his confirmation) once he's verified it, per `overview.md`'s "Build plan" section. Marking `done` unblocks stage 2.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/` (per `overview.md`'s "Where PoC code lives").

## Docs to read first

`server-architecture/overview.md` in full, especially "Persistence," "Runtime" (director runtime candidates), and "Netcode" (protocol envelope, no direct-position messages). `deliverables/004-server-setup-guide.md` §4 for the Postgres shape this stage's schema will eventually run against on the real droplet (this stage can run against a local/dev Postgres — it doesn't need the droplet from `server-instance` to exist yet).

## Status (2026-08-03)

**State: `done`.** Kris personally ran `npm test` and `npm run test-client` in `land-of-lor/pocs/multiplayer-poc/director/` on 2026-08-03: all tests passed, including the 9 test-client checks. This is his explicit confirmation per the epic's build-plan gating rule — Stage 2 (`006`) is unblocked.

**Director runtime: Node/TypeScript** — decided directly by Kris on 2026-08-03 (not an agent assumption this time; this was the epic's one open question and he closed it explicitly before this task was picked up). `overview.md`'s "Runtime" section and "Key open questions" are updated accordingly.

**Built:** `land-of-lor/pocs/multiplayer-poc/director/` — a Node/TS + `ws` + `pg` + `zod` director service, a Postgres schema (`accounts`, `worlds`, `world_members`), a versioned JSON message envelope, 17 passing automated tests (`npm test`, includes a real SIGKILL + process-restart persistence test and a membership-routing test), and a headless CLI test client (`npm run test-client`) that proves all three of the stage's verification bullets end-to-end with no human interaction. Local Postgres for dev/test is provided by the `embedded-postgres` devDependency (a real Postgres binary run userspace, no root needed) — the production code only ever talks to `DATABASE_URL` and doesn't know or care that it's this vs. the droplet's real apt-installed Postgres.

Full detail: `land-of-lor/pocs/multiplayer-poc/handoff/stage-1.md`.

## Assumptions

- **Membership semantics:** the world owner is automatically inserted as a member at world-creation time (one row in `world_members`, no separate "is owner" special case anywhere else) — matches `overview.md`'s "membership, not matchmaking" framing, where the owner is just the first member.
- **`last_played_at` is written on every snapshot** (periodic, and on last-player-leave), even though nothing reads it yet — the column exists per the decided architecture; writing it opportunistically now costs nothing and means Stage 4+ offline-progression work won't also need a backfill.
- **Local/dev Postgres via `embedded-postgres`** rather than an apt-installed system Postgres — this container has no root/sudo, so `apt install postgresql` (as deliverable #4 assumes on the droplet) isn't available here. `embedded-postgres` gives a real Postgres binary with no privilege requirement; it's a devDependency only, irrelevant to how the director talks to Postgres in production.
- **Protocol message set kept to the stage's stated minimum** (`create_account`, `create_world`, `join_world`, `leave_world`, `add_member`, `set_world_note`) rather than anticipating Stage 2+ messages — `set_world_note` stands in for a real gameplay intent purely to prove a mutate-then-snapshot-then-restart round trip exists; it is not meant to survive into Stage 2's actual protocol.

### server-architecture/010 — Real accounts — sign up, log in, and attach a device to an existing account
```yaml
id: server-architecture/010
title: Real accounts — sign up, log in, and attach a device to an existing account
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-12
updated: 2026-08-13
claimed_by: session-a0f7b92d
claimed_at: 2026-08-12T07:36:15.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/010-accounts.md
```

## What

Named by Kris on 2026-08-12 as the next significant build, alongside the world map
(`world-map-and-portals`): "creating accounts and being able to log in and attach to your
account." He flagged it explicitly as needing **significant, careful architecture on both
the server and the client** — not a login form bolted on.

This is the deferred half of a decision already made. `overview.md`'s **Identity** section
committed to *persistent accounts from day one, no authentication for the MVP* — issue and
remember a token, get the identity column and its foreign keys right, and let the login
form come later. That reasoning was: adding real auth to an existing populated user table
is straightforward, retrofitting durable identity onto anonymous sessions is not. This task
is "later" arriving. **Do not re-litigate the deferral** — the deferral was correct and the
table it protected is exactly what makes this task tractable.

## Where it stands today

- `game/director/src/schema.sql` — `accounts (id, token UNIQUE, display_name, created_at)`.
  The comment states the current model plainly: "Whoever holds this token is this account,
  full stop, until real auth replaces it."
- `game/director/src/accounts.ts` — `createAccount`, `getAccountByToken`, `getAccountById`.
  No credentials, no sessions, no revocation.
- `worlds.owner_id` and `world_members.account_id` are already foreign-keyed to `accounts`,
  so real auth is additive rather than a data migration.
- The client stores its token per install. **Known symptom of the current model:** two
  native clients on one machine share a single account unless they get separate `$HOME`s —
  the token is the identity, and there is no way to say "this is me, on a second device."
- The title screen already has a "Change Account" button
  (`game/world/client/ui/title_screen.gd`), and `player_state.gd` is documented as *the*
  single seam where server-backed accounts and collections plug in.

## Scope

- **Sign up** — a real account with a credential, not just an issued token. Which
  credential (email + password, passkey, OAuth, device-code) is an open architectural call
  and part of this task's job to decide and justify.
- **Log in** — establishing a session against an existing account from a fresh install.
- **Attach a device to an account** — Kris's own phrasing. This is the interesting case,
  not an afterthought: the same human on a phone and a browser is one account with two
  devices, and today that's impossible. Sessions, not a single shared secret.
- **Upgrade path for existing token-only accounts.** People (Kris included) already have
  worlds and members tied to token accounts. Claiming an existing token-account with a
  credential has to work, or the deferral's whole benefit is lost.
- **Client architecture** — where identity lives, how a session is stored and refreshed,
  and how every screen reads it. `player_state.gd` is the intended seam; confirm it's still
  the right one at this scale or say what replaces it.
- **Server architecture** — session/token handling in the director, what the world
  processes are allowed to trust, and how `hello`'s `accountId` (currently asserted by the
  client, unverified — `world_server.gd`) stops being self-asserted.

## Explicit non-negotiables

- **Do not foreclose offline play.** Kris stated on 2026-08-12 that the finished game must
  be playable offline, and stated it precisely so nothing gets architected against it. An
  auth model that makes the client useless without a live director violates this. See
  `world-map-and-portals/overview.md`'s offline section.
- **Server-authoritative stays server-authoritative.** Identity being partly client-held
  must not become identity being client-asserted.

## Definition of done

Kris asked for this written up as a task now, with the intention of handing it to a
planning session for a full implementation plan before any code is written. So: **the
first deliverable of this task is the architecture, not the feature.** A plan that names
the credential model, the session model, the device-attach flow, the migration for existing
token accounts, and the client seam — reviewed by Kris — and then the build against it.

## The question this probably raises

Choosing a credential model may reach outside the repo (an email sender, an OAuth provider,
a hosted identity service) and may cost money — which clears `PICKUP.md`'s question bar.
If so, that is the *one* question worth raising on this task, with options and a
recommendation.

## Implementation plan — approved 2026-08-12

The architecture this task names as its first deliverable now exists and is approved:
**[`../010-accounts-plan.md`](../010-accounts-plan.md)** — planned and signed off by
Kris in a live session on 2026-08-12, design verified against the current code. The
implementing agent builds against that document (start at its "Staged build order",
stage 1); this task stays `ready` until claimed for the build.

**Two amendments from that session supersede the text above:**

- **Slate wipe.** The "Upgrade path for existing token-only accounts" scope item is
  void — Kris's explicit call. Existing `accounts` / `worlds` / `world_members` rows
  are disposed at deploy via a deliberate, guarded migration. Ignore the earlier
  "claiming an existing token-account with a credential has to work" line.
- **No gameplay mechanics.** Collections land as storage plus a server→client read
  path only (seeded with the current demo set). No pickup logic, no award paths, no
  health — nothing item-mechanical belongs to this task.

## Audit — 2026-08-13

Kris asked for an independent audit of the delivered build before moving the project
forward: **[`../010-accounts-audit.md`](../010-accounts-audit.md)**. Verdict in short:
foundation solid, handoff honest, both suites reproduce green — but one high-severity
security gap (password reset doesn't revoke sessions), the QR half of device-attach
never built (`?link=` web redemption missing), and the client's failure paths (no
reconnect, silent dead-ends — the likely cause of the observed "sometimes doesn't
run" bug). The audit's Recommendations section proposes the follow-up tasks.

**Decisions settled (no longer open calls):** one LoR account with linked sign-in
providers, guest-first (play never gates on sign-in); email+password is v1's typed
credential; **Resend** is the email sender (Kris sets up the account + DNS — the build
uses a log-fallback mailer until the key exists); device attach = sessions + link codes
with a QR; the self-asserted `hello` accountId is replaced by director-minted join
grants; the anti-cheat handoff's rate limits and movement-pacing fix ride along.
Google Play Games sign-in is split out as task 011.

## Build complete — in review, 2026-08-12

All six stages built, tested, committed, and pushed to `land-of-lor` — see
**[`land-of-lor/docs/handoff/historic/010-accounts.md`](../../land-of-lor/docs/handoff/historic/010-accounts.md)**
for the full report: what shipped stage by stage, test coverage (76/76 director +
75/75 pure-logic GDScript, both green), how the new e2e script was verified without
touching production, and the one thing deliberately left undone — **the live
droplet was not deployed to**. The runbook's slate-wipe step
(`LOR_WIPE_V1=yes-destroy-v1-data npm run migrate-v2`) destroys the droplet's
existing accounts/worlds rows irreversibly; that's Kris's call to make live, not
something to run unattended on a pre-approved plan. Everything up to that point —
code, tests, the runbook itself — is ready. This task stays `review` until Kris
either runs the deploy himself or directs a session to.

## Deployed 2026-08-12 — backend + web live, Android blocked

Kris gave explicit go-ahead to run the live deploy. Executed: verified backup
(`pg_dump`, non-empty, gzip-checked) before the wipe, `migrate-v2` on the droplet
(destroyed 76 accounts / 9 worlds / 74 members, matching the backup), director
restarted on v2, `verify-accounts-e2e` **23/23 passed against prod**. Web client
rebuilt and pushed live at `/play` (confirmed the shipped build actually contains
the accounts v2 client code, not just a timestamp bump). Full record in the handoff
note's new "Deploy" section, including a flagged-but-not-blocking gap in three older
live-check scripts that still use the pre-grant `hello`.

**Android is not done — stays out of `done` for this reason alone.** Rebuilding the
APK needs `.secrets/lor2026.keystore` (+ `.password`), which this workspace's own
`CLAUDE.md` says has lived in this container's `.secrets/` since 2026-08-04 but
which is not actually present here. The currently-downloadable APK now fails
`UNSUPPORTED_VERSION` against the live v2 director (expected cutover behavior, but
means Android play is broken until this is resolved). **Needs Kris**: restore
`lor2026.keystore` + `lor2026.keystore.password` into this container's
`land-of-lor/../.secrets/`, then a session can run `./export-android.sh --upload`
in minutes — everything else in that pipeline (Godot 4.7.1, export templates,
droplet upload path) was confirmed working this session.

### server-architecture/013 — Account-first boot flow, character-seed placeholder, and client robustness
```yaml
id: server-architecture/013
title: Account-first boot flow, character-seed placeholder, and client robustness
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-13
updated: 2026-08-13
claimed_by: session-88fa322b
claimed_at: 2026-08-13T00:45:00.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/013-account-first-flow.md
```

## What

Kris's calls in a live session on 2026-08-13, immediately after the 010 audit
([`../010-accounts-audit.md`](../010-accounts-audit.md)): restructure the client's
boot/signup/menu flow so the player **cannot enter the world without a valid
signed-in session**, fold a character-creation placeholder step into account
creation, and absorb the audit's client-robustness fixes. Full implementation plan
approved by Kris the same session — this task file is the record; the plan's design
is summarised below and the implementing session builds exactly it.

## Decision reversal — the amendment of record

**This task amends `../010-accounts-plan.md`'s approved "guest-first — play never
gates on sign-in" decision.** Kris's explicit call, 2026-08-13, with his reasoning:
most of the client-flow mess traces to the silent auto-guest, and character
selection belongs in signup anyway. The 010 plan document is approved history — do
not edit it; this file is the amendment.

What is reversed: the silent auto-guest at boot, and the ability to start the game
without a session. What **survives** from 010, unchanged:

- **No credential is required to create an account.** An explicit signup (name +
  character step) still creates an identity-less account + session — `create_guest`
  on the wire. Email, and later Play Games / Game Center / Steam (`auth_identities`
  providers, tasks 011+), attach to it afterwards. Kris confirmed this explicitly —
  platform-native sign-in must stay first-class; nothing here forecloses it.
- **Title renders offline.** Boot never blocks on the network; the gate disables
  Start, it never blanks the screen.
- **Future offline play** = a local profile that bypasses the director entirely.
  Nothing in this task may assume a session exists at title time.

## Scope (the approved plan, condensed)

1. **Session state machine** in `account_session.gd`: BOOT / RESTORING / SIGNED_IN /
   NO_ACCOUNT / OFFLINE, `state_changed` signal, no auto-guest anywhere; only an
   explicit server `UNAUTHENTICATED` clears the stored token (timeout ≠ invalid —
   kills audit H4's account loss); reconnect with backoff; real elapsed-time
   timeouts replacing the frame-counted `+= 16` loops.
2. **Flow & screens**: title gates Start on SIGNED_IN ("New Adventurer" / "I already
   have an account" otherwise); new `signup_flow` (name → character placeholder →
   create account), `signin_screen` (email login + link-code redeem), shared
   `lor_forms.gd`; `account_screen` becomes signed-in management only (device list,
   QR add-device, display-name edit, sign out — sign-in/redeem move out). Plain
   functional LorUI styling — Kris restyles in a design session afterwards; the four
   restyle surfaces are signup_flow / signin_screen / account_screen /
   character_preview.
3. **Character-creation placeholder, opaque seed end-to-end**: one nullable
   `accounts.character_seed` column, set at signup, carried through join grants →
   world entities → `world_update` → player tint; display name rides the same
   pipeline (overhead label finally shows "Rah", not entity-id hex). The
   seed→appearance mapping lives in exactly ONE file (`client/character_seed.gd`),
   loudly commented as the placeholder the future hexagram-defined appearance model
   replaces. **No character classes/models/schemas are invented** — that design
   belongs to the full-game structure behind it.
4. **Client robustness (audit absorption)**: `?link=` web QR redemption (H2), the
   reconnect story and all four silent dead-ends (H3), guest-overwrite (H4),
   frame-counted timeouts, account-screen polish debt, doomed same-port reconnect,
   Start double-tap guard, takeover tolerance (M6 client half — dormant until 014
   ships the server's `TAKEN_OVER` reason).

**Explicitly not this task**: the audit's server-hardening batch — task 014.

## Definition of done

Both suites green (director 76+ / world 75+), `verify-accounts-e2e.ts` extended
(name+seed round-trip, seed visible to a second client, link-code device shares the
seed) and passing against the deployed droplet; web + APK rebuilt and uploaded.
Manual checks that need Kris/hardware: first-run signup on the phone, QR scan from
a phone camera landing signed-in in the browser, virtual-keyboard fit. Ends with a
handoff note and `review`, per epic convention.

### server-architecture/021 — Single-identity accounts + unified account screen
```yaml
id: server-architecture/021
title: Single-identity accounts + unified account screen
epic: server-architecture
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-28
updated: 2026-08-31
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/021-single-identity-accounts.md
```

Built straight from Kris's 2026-08-28 voice spec. Two parts:

1. **Model change** — drop guest accounts and "attach later / link several identities"
   entirely. Every account is created with exactly one auth identity (email+password,
   Google Play Games, or Apple Game Center), one-to-one, forever. No device-link codes.
   See the "Accounts & auth — one identity per account (2026-08-28)" decision in
   `../overview.md`, which supersedes `../historic/010-accounts-plan.md`.
2. **UI change** — collapse the title/signin/signup/account screens into one wrapper
   component (`account_flow.gd`) driven by two booleans (signed in? / if not, signing up
   or in?), plus a batch of concrete screen fixes: drop link-code UI, X-to-close instead
   of Back, a game-name field on signup, a real rotating 3D character preview, a trimmed
   signed-in panel, a subtle sign-out/toggle link, and dropping "Account" from the
   in-game menu (reachable via Main Menu → title → Account instead).

Definition of done: director schema/protocol/handlers updated and tested
(`game/director`, embedded-Postgres `npm test`), client screens rebuilt and headless
parse-checked (`game/world`), live droplet deploy (backup, wipe `accounts` per Kris's
call — pre-launch test data — migrate, restart, verify), new web build + APK, and a
build handoff written to `land-of-lor/docs/handoff/historic/021-single-identity-accounts.md`
following the 019/020 handoff shape.

Full plan: `/home/krisrandall/.claude/plans/fixing-up-the-accounts-luminous-dijkstra.md`
(local to the session that wrote it — the handoff doc is the durable record).

### server-architecture/002 — Server runtime/engine options memo
```yaml
id: server-architecture/002
title: Server runtime/engine options memo
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-05
updated: 2026-08-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## Resolution (2026-08-02)

**Decided, not written as originally scoped.** A direct architecture conversation settled this without the comparison memo ever getting written: **world servers run Godot 4 headless, one OS process per world instance, over `WebSocketMultiplayerPeer`** — chosen specifically because sharing simulation code between server and predicting client eliminates prediction-divergence bugs, the dominant risk in client-side prediction generally. A **separate director service** (world registry, membership, instance lifecycle, portal routing, accounts) runs alongside it.

**Update 2026-08-03:** the director's own runtime — this task's one surviving open question — is now also closed: **Node/TypeScript**, confirmed directly by Kris and implemented in `server-architecture/005` (Stage 1, `land-of-lor/pocs/multiplayer-poc/director/`). Nothing about this task remains open.

Full decision recorded in `server-architecture/overview.md`'s "Runtime" section — read that, not this note, for the actual architecture and reasoning.

## What to do

Nothing is chosen yet for the server's runtime/language (`overview.md`'s open questions). Write a short options memo — not a decision — covering realistic candidates for a server that must: hold authoritative field/grid state, run the changing-line transmutation clock, match recipes, and tick Complex Item formulas (ports/stocks/rules) for potentially many simultaneous objects. Consider at least: a Godot-native server (if the client ends up in Godot, avoids a second stack), a general-purpose backend language/framework, and any actor-model or ECS-style runtime suited to many independently-ticking objects.

## Definition of done

A memo comparing 2-4 real candidates on: fit with the tick/formula model (§8), fit with the field-simulation approach chosen in task 001, hosting/ops simplicity (relevant to `server-instance`), and Kris's/agents' familiarity. End with a recommendation, not a final decision — this becomes a Question once the memo exists and Kris needs to actually choose.

## Where the work lands

`land-of-lor` repo.

## Docs to read first

`land-of-lor/docs/README.md` §8 (recipes & formulas — the tick engine this runtime must support), and this epic's own task 001 once it exists (the scaling approach may favor one runtime over another).

### server-architecture/006 — Stage 2 — Godot headless world server with grid movement
```yaml
id: server-architecture/006
title: Stage 2 — Godot headless world server with grid movement
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: L
created: 2026-08-02
updated: 2026-08-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What to do

Add the actual Godot headless world-server process — the thing stage 1's director spawns/hydrates/snapshots/exits, per `overview.md`'s "Persistence" lifecycle.

1. A Godot 4 headless project (in `land-of-lor/pocs/multiplayer-poc/`, alongside stage 1's director) implementing grid-based entity movement server-side, following the `Entity`/`EntityNode` split `overview.md`'s "Runtime" section names — server runs the pure-data `Entity` logic, no rendering.
2. Grid storage uses the **required fix** from `overview.md`'s "Pathfinding and sensing" section from the start — a flat `PackedInt32Array` indexed by `(layer * h + y) * w + x`, statically typed hot-loop variables — not `lor-elementals`' string-keyed `Dictionary` pattern. This stage is exactly where that correction has to actually land in code, not just in spec.
3. **Server-authoritative movement:** the world server accepts *intent* messages (over the stage-1 protocol, extended with movement messages) and computes position itself; nothing about position is ever trusted from a client message.
4. Hydration/snapshot hooks that actually plug into stage 1's director-driven lifecycle (spawn → hydrate from Postgres → run → snapshot every 30s + on shutdown → exit).
5. Automated tests: movement logic, grid indexing correctness, hydrate/snapshot round-trip of actual entity state (not just the director's world-record scaffolding from stage 1).
6. A headless test-client script (extends stage 1's CLI client) that connects, sends movement intents, and asserts the server's resulting authoritative position matches expectations — no manual clicking required to verify this stage.
7. Handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-2.md`.

## Definition of done

Automated tests pass, the headless test-client script demonstrates server-authoritative grid movement end-to-end, the handoff note exists. Set `state: review`, not `done` — same rule as stage 1.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/`.

## Docs to read first

`server-architecture/overview.md`'s "Runtime" and "Pathfinding and sensing" sections. `lor-elementals/scripts/entities/entity.gd`, `entity_node.gd`, and `grid_manager.gd` — the pattern to reuse (`Entity`/`EntityNode` split) and the pattern to specifically *not* repeat (the string-keyed grid `Dictionary`). Stage 1's handoff note (`handoff/stage-1.md`) for the protocol/schema this stage builds on.

## Status (2026-08-03)

**State: `review`, not `done`** — awaiting Kris's verification per the epic's build-plan gating; do not start Stage 3 (`007`) until he moves this to `done`. No `delivers:` gate applies to this task (unlike 007/009) — Kris's sign-off alone unblocks Stage 3.

**Built:** `land-of-lor/pocs/multiplayer-poc/world-server/` — a headless Godot **4.7.1-stable** project (toolchain already pinned/provisioned at `/workspace/lor/godot-setup-for-pocs/`, no new provisioning needed) implementing the real world-server process that replaces Stage 1's in-memory stub. Server-side `WorldEntity` (pure `RefCounted`, no `Node`) and `WorldGrid` (flat `PackedInt32Array`, `(layer*h+y)*w+x` indexing, statically typed — not `grid_manager.gd`'s string-keyed `Dictionary`). Two WebSocket channels: a control channel (world↔director, reusing the director's existing WS port, extending Stage 1's protocol with `world_register`/`world_snapshot`/`world_shutdown`) and a game channel (game client↔world process directly, via `WebSocketMultiplayerPeer` at the low-level packet-peer API). Server-authoritative movement at the decided 20Hz sim / 10Hz broadcast rates; illegal intents (walls, out-of-bounds) and unrecognised message types (standing in for direct position assertion) are refused/rejected, tested explicitly. All three required persistence mitigations from the 2026-08-03 "snapshots flow via the director" decision are implemented and tested against real OS processes: idempotent snapshots keyed by world id + seq, buffer-last-unacked + retry-on-reconnect, and shutdown blocking on a persistence ack. `director/src/worldInstance.ts` rewritten to actually spawn/hydrate/register/snapshot/shut down the real Godot process (env-overridable `GODOT_BIN`/world-server path), with `protocol.ts`/`server.ts`/`worlds.ts` extended accordingly.

**Verification, independently re-run and confirmed by this session (not just the building agent's own report):** `npm test` — 23/23 passed (17 from Stage 1 + 6 new). `npm run test-client` — 9/9 passed (Stage 1 unmodified, no regression). `npm run test-client-2` — 17/17 passed, the actual Stage 2 proof: server-computed movement, wall/out-of-bounds/unknown-type rejection, and — the hardest property — killing the Godot world process directly (SIGKILL, not the director) mid-play, rejoining, and confirming a fresh OS process (different pid) respawns and hydrates from the last persisted snapshot. Godot pure-logic test runner (`test_runner.gd`) — 31/31 passed standalone, no networking.

Full detail, including deviations from the original design note (SIGTERM doesn't reach headless GDScript; `saveWorldState` now merges instead of replaces; `WebSocketMultiplayerPeer`'s leading system packet; `preload()`-based cross-file references instead of bare `class_name`): `land-of-lor/pocs/multiplayer-poc/handoff/stage-2.md`.

## Assumptions

- **Game clients connect directly to the spawned world process's own port (`gamePort`), not through the director**, for gameplay traffic — the director's `join_world` response now includes `gamePort` and only resolves once the process has registered, so the port is always live. This wasn't explicitly specified in the task body, but follows directly from `overview.md`'s "Runtime" rationale for choosing WebSocket (client and server run the same codebase, talking directly) and from the epic's own Stage 3 framing ("two clients connected simultaneously to the same world instance").
- **Control channel reuses the director's existing WS server/port** rather than a second listener, per the persistence decision's "existing world↔director channel" wording — a spawned world process connects to it as a client alongside game clients, distinguished only by message type, not by a separate port or connection role.
- **A per-spawn random `spawnToken`** (not tied to accounts/auth) is the trust mechanism between a specific spawned process and the director's in-memory record of it — sufficient for this PoC stage; not a security mechanism intended to survive into a production identity story.

## Closed 2026-08-03 — `state: done`

Kris directed Stage 3 to proceed, in direct conversation with his assistant (2026-08-03), rather than by personally re-running the suites himself. Recorded honestly: this sign-off rests on the Stage 2 closing session's own independent re-verification logged above (23/23 vitest, 9/9 Stage 1 client, 17/17 Stage 2 client, 31/31 Godot logic tests), not on a fresh personal run by Kris. This unblocks `server-architecture/007` (Stage 3).

### server-architecture/007 — Stage 3 — Godot client with prediction and reconciliation; two clients
```yaml
id: server-architecture/007
title: Stage 3 — Godot client with prediction and reconciliation; two clients
epic: server-architecture
state: done
priority: 2
blocked_by: ["server-architecture/006"]
estimate: L
created: 2026-08-02
updated: 2026-08-04
claimed_by: null
claimed_at: null
delivers: [6]
review_artifact: null
```

## What to do

Add the client side: a Godot client that connects to stage 2's world server, predicts its own movement locally for responsiveness, and reconciles against the server's authoritative state as it arrives — the netcode decision `overview.md` calls out as "not deferred... retrofitting prediction onto a netcode layer that didn't have it from the start is close to a rewrite."

1. A Godot client project using the `EntityNode` rendering half of the split stage 2's server already established for `Entity` data — same codebase family, client-specific concerns only (rendering, local input, prediction).
2. Client-side prediction: on local input, move immediately and locally, without waiting for the server round-trip.
3. Server reconciliation: when the server's authoritative position for this client's own entity arrives (per stage 2's protocol), reconcile — correct the local predicted position if it's diverged, per the standard prediction/reconciliation pattern this decision is named after.
4. **Two clients, simultaneously connected to the same world instance, each seeing the other move** — this is the concrete proof this stage exists to deliver, not just "one client works."
5. **Portal routing:** a player crosses a portal from world A to world B — membership enforced at the destination, the destination world spawned/hydrated on demand if not already running, and the player's entity state transferred through the director with no loss or duplication. See `overview.md`'s "Not yet decided: the portal-transfer mechanism itself" note — the actual transfer mechanism (candidate: Postgres-as-transfer-medium, snapshotting out of world A and hydrating into world B) and its related snapshot-boundary question are open design questions, not settled ones; confirm an approach with Kris before locking in the implementation, don't just build the candidate silently.
6. Automated tests where feasible (reconciliation logic, message handling) plus a headless two-client test-client script that connects two instances of the test client (not two humans clicking) and asserts each sees the other's position update correctly — consistent with every other stage's "no listening port to click on" constraint. Extend this (or add a companion script) to cover the portal-routing case: a client crosses a portal and ends up correctly placed in, and only in, the destination world.
7. Handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-3.md`, including explicitly what a human should look at when actually running two real Godot client windows on the droplet/locally (this is the one stage where an actual visual check by Kris is genuinely the most meaningful verification, even though the automated headless version has to exist too).
8. **Web export, deployed to the droplet — this stage's close-out, and how Kris human-verifies it.** Godot's HTML5/web export target for the same client from step 1, built and served from `lor-game-server-01` (deliverable #4/#5), connected to a live world. Verification is two browser tabs open to that URL, each seeing the other move — the same bar as the headless two-client test, just witnessed directly instead of read from a script's assertions. This is `deliverable #6` (`deliverables/006-play-in-your-browser.md`); it does not flip to `delivered` until this link is live and that two-tab check has actually been done. **Known prerequisite, not yet resolved:** there's no deploy key set up for getting build output onto the droplet yet (see deliverable #5's server notes) — sort that out as part of this step, don't assume it exists. **Known constraint, not a blocker:** the droplet currently serves plain HTTP with no domain/TLS, so this ships as `ws://` from an `http://` page — that's fine for the PoC, but flag in the handoff note that a later move to a real domain + TLS turns this into `wss://`, a client connection-code change, not just a hosting one.

## Definition of done

Automated/headless-scripted verification passes and demonstrates two clients observing each other's movement, plus a portal crossing between two world instances with membership enforced, on-demand destination hydration, and lossless entity-state transfer; the handoff note exists. **Additionally, per the `delivers: [6]` gate (see `epics/README.md`'s "Promised deliverables" section): this task cannot move to `done` until deliverable #6 is `delivered` with its live link in place** — the web export running on the droplet and the two-browser-tab check, not just the headless proof. The review→done gate this extends already required Kris's sign-off; this makes explicit that his sign-off on deliverable #6 specifically is part of it. Set `state: review`, not `done`, once the automated/headless portion lands — `done` still waits on the web-export deploy and Kris's check.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/`.

## Docs to read first

`server-architecture/overview.md`'s "Netcode" section (prediction/reconciliation, the reason it's day-one not deferred). Stage 2's handoff note for the protocol/movement contract this stage's client talks to.

## Status (2026-08-03)

**State: `review`.** All of steps 1-8 are built and automated-verified; deliverable #6 is `delivered` (live link in place, verified externally per the gate in "Definition of done" above) — so the `delivers: [6]` gate is satisfied. `state: review` (not `done`) regardless, per this epic's build-plan gating: only Kris moves a stage to `done`, same rule every prior stage followed.

**Built, across three sessions (server protocol, client, deploy):**
- Portal transfer ("Postgres as the transfer medium, via the director" — closed 2026-08-03, see `overview.md`'s "Persistence" section) and `seq`-based move reconciliation protocol (`intent_ack`/`intent_rejected`), director + world-server.
- A real Godot client (`world-server/client/`) reusing `movement.gd`/`grid.gd`/`entity.gd`/`protocol.gd` byte-for-byte: client-side prediction against the shared `Movement.try_move()`, server reconciliation on `intent_ack`, interpolated rendering of remote entities, `EntityNode` (`ColorRect`-based, per Kris's own "we see each other's balls moving" bar), full connect/join/hello flow with remembered identity, and portal traversal (direct handoff + a proven 2-second crash-safety fallback — see handoff note's Deviation 3 for why the fallback, not the direct handoff, is the actually-reliable path in this Godot build).
- Automated headless verification: 28 vitest + 12 + 15 CLI-driven checks (portal transfer incl. real `SIGKILL` crash-safety, mutual visibility, reconciliation-correction proof, illegal-intent refusal, portal crossing incl. crash-safety) + 41 Godot pure-logic checks — all independently re-run and confirmed by the orchestrating session, not just trusted from the building agents' own reports.
- Web export (Godot 4.7.1 HTML5, `world-server/client/web-build/`, committed to the repo) deployed to `lor-game-server-01`: Node 22 installed, director built and running as `lor-director.service` (replacing a placeholder stub), the obsolete placeholder `lor-world@1.service` stopped/disabled (the director spawns world processes itself, on demand — no per-world systemd unit), Caddy serving the web export at `/play/*`, firewall opened for direct game-channel connections (`32768:60999/tcp`, the ephemeral range `allocateFreePort()` actually uses).
- **Live, external verification from the container**: `director/cli-test-client/verify-live-droplet.ts` (`npm run verify-live-droplet`) proves, over the public internet against the real running droplet (not a local stand-in), two accounts joining the same live-spawned world, both connecting directly to its real `gamePort`, mutual movement visibility both directions, and a live illegal-intent refusal — **9/9 passed**. The web client URL and its `.wasm`/`.pck` assets independently confirmed to return HTTP 200 with correct sizes.

**Known gap, flagged plainly, not silently worked around:** a GitHub deploy key for the droplet (deliverable #5's outstanding follow-up) could not be registered via API this session — the container's token lacks the required "Administration" repo permission (403). A read-only keypair was generated on the droplet (public key in `deliverables/006-play-in-your-browser.md`) and needs a human with GitHub admin access to add it. Code was still gotten onto the droplet, via the same `git bundle` transfer method deliverable #5 originally used — not blocking, just not the cleaner `git pull` workflow deliverable #5 described as the goal.

**What's still a human check, not provable from this container:** actually opening `http://209.38.29.22/play/index.html?director=ws://209.38.29.22/director/` in two browser tabs and watching the canvas render — no display/browser exists in this container. The live-verification script proves the underlying protocol traffic works end-to-end; the visual confirmation is Kris's own remaining step, same as it would be for the desktop two-client check.

Full detail: `land-of-lor/pocs/multiplayer-poc/handoff/stage-3.md` (and `handoff/stage-3-server-side.md` for the protocol-only first half).

## Verified and closed (2026-08-04)

Kris performed the human verification bar directly: the web client at `https://lor-server.cocreations.com.au/play/index.html?director=wss://lor-server.cocreations.com.au/director/` — two browser tabs, each seeing the other's player move in the live commons world. (His words on seeing it work are not printable on a family-friendly board, but they were an emphatic yes.) Getting the browser path to this point required the same-day HTTPS wiring and the public-commons-world work recorded in deliverable #6's 2026-08-04 update. The `delivers: [6]` gate was already satisfied; with the visual check done, this stage is `done`.

### server-architecture/011 — Google Play Games sign-in on Android
```yaml
id: server-architecture/011
title: Google Play Games sign-in on Android
epic: server-architecture
state: needs-input
priority: 2
blocked_by: ["server-architecture/010"]
estimate: M
created: 2026-08-12
updated: 2026-08-13
claimed_by: session-88fa322b-agent2
claimed_at: 2026-08-13T03:05:00.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/011-play-games-server-half.md
```

## What

The first platform-native sign-in provider on the identity seam that task 010 builds:
**Google Play Games Services** on Android — one-tap (usually silent) sign-in, no
password ever typed on a phone. Decided 2026-08-12 as the immediate follow-on to 010,
split out so the core accounts work doesn't block on Play Console setup.

Per the approved architecture (`../010-accounts-plan.md`, "The identity architecture"):
this is **one new row-kind in `auth_identities`** (`provider = 'google_play'`), one new
director message pair (client sends a Play Games server auth code; the director
exchanges it with Google, reads the player ID, finds-or-links the identity, mints a
session), and one client-side integration (the community Godot Play Games Services
plugin wired into `account_session.gd` + a "Sign in with Play Games" button on the
account screen). No schema change, no session-model change — that's the whole point of
the seam.

## Blocked by

- **Task 010** — the `auth_identities` / `sessions` substrate must exist first.
- **Kris, outside the repo:** a Google Play Console entry for the game ($25 one-time if
  no developer account yet), Play Games Services configured on it, OAuth client set up,
  and the signing-cert SHA-1s registered (including the debug keystore's, so sideloaded
  test builds can sign in). Cleared PICKUP.md's question bar in the 2026-08-12 planning
  session — Kris chose this staging himself.

## Non-goals

Game Center (iOS) and Steam — speced in the 010 plan, built when those platforms exist.
Achievements / leaderboards / cloud save via Play Games — later, same integration rides.

## Server half built — 2026-08-13

Everything buildable without the Play Console is DONE, tested, and deployed
(handoff: `land-of-lor/docs/handoff/historic/011-play-games-server-half.md`):
`login_google_play` on the director (injectable Google exchange —
real/disabled by env, mocked in tests; attach-don't-merge with
`IDENTITY_TAKEN`; `PROVIDER_UNAVAILABLE` while unconfigured), the generic
`attachProviderIdentity` helper, the client seam (`login_google_play` RPC +
`platform_supports_play_games()` gate + sign-in/link buttons that never
render without the plugin), and a live-e2e step proving the droplet answers
`PROVIDER_UNAVAILABLE` cleanly. Director 99/99, world 86/86, live verifies
9/9 + 4/4 + 5/5 + 32/32.

**Now waiting on Kris** — the Play Console checklist (with both SHA-1
fingerprints, release + Godot debug, already computed) is in the handoff:
developer account ($25), app `au.com.cocreations.lor_spellgrove`, Play Games
Services config, Android + Web OAuth clients, `GOOGLE_PLAY_CLIENT_ID/SECRET`
into `/etc/lor/game.env`, testers. After that, the device half: vendor the
Iakobs/godot-play-game-services plugin, wire `request_server_side_access`
into the two `_fetch_play_games_auth_code()` stubs, on-device e2e per the
DoD below.

## Definition of done

On a real Android device: fresh install → play as guest → "Sign in with Play Games" →
one-tap → same LoR account appears on a second device signing in with the same Play
Games identity. Attach-vs-conflict behavior matches the plan's "attach, don't merge"
rule. Server-side verification covered by director tests (mocked Google exchange).

### server-architecture/012 — Set up Resend for real account emails
```yaml
id: server-architecture/012
title: Set up Resend for real account emails
epic: server-architecture
state: needs-input
priority: 2
blocked_by: []
estimate: S
created: 2026-08-12
updated: 2026-08-13
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What this is

Task 010 (real accounts) is deployed and live — see `land-of-lor/docs/handoff/historic/010-accounts.md`.
Its mailer (`director/src/email.ts`) already knows how to send through
[Resend](https://resend.com); until real credentials exist it falls back to
`LogMailer`, which just writes the verify/reset link to the director's own log
instead of emailing it. **This is non-blocking** — accounts, guest play, and
link-code device attach all work today without this. It only matters for two
things: a real "verify your email" message, and self-service password reset
(right now, a password reset can only be completed by someone reading the
director's server log for the link).

This task is **entirely external-repo actions only Kris can do** — creating a
Resend account, proving domain ownership via DNS, and generating an API key —
so it's filed straight to `needs-input` rather than `backlog`. No design
decision is open here (see "Why these exact values" below); it's a checklist.

## Step by step

**1. Create a Resend account** — go to [resend.com](https://resend.com), sign
up (free tier is enough: 3,000 emails/month, 100/day — nowhere near what this
game needs any time soon). Use whatever email/login Kris normally uses for
project services.

**2. Add and verify the sending domain** — in the Resend dashboard, go to
**Domains → Add Domain**. Enter exactly:

```
mail.lor-server.cocreations.com.au
```

(a dedicated subdomain of the game's own subdomain — not the bare
`cocreations.com.au` root, which is the CoCreations business domain and
already carries its own MX record; keeping the sending domain scoped under
`lor-server.` means this can't touch or break any existing business email
setup, and it's exactly what Resend itself recommends: a dedicated
subdomain for transactional mail).

Resend will display a set of DNS records to add (typically an MX record plus
2-3 TXT/DKIM records, and it'll suggest a DMARC TXT too — take that
suggestion). **Add exactly what Resend's dashboard shows you** — the values
are generated per-domain at add-time, so there's nothing to copy from this
task file. `lor-server.cocreations.com.au`'s DNS (and therefore this new
`mail.` subdomain's) is hosted by iFastNet (nameservers `ns101.ifastnet.com` /
`ns201.ifastnet.com` — confirmed live 2026-08-12), so add the records there,
in whatever iFastNet/cPanel-style DNS zone editor Kris already uses to manage
`cocreations.com.au`.

Resend auto-checks verification in the background; it's usually minutes, can
be a few hours depending on DNS propagation. The domain shows **Verified** in
the dashboard once it's done — don't move on to step 4 (dropping the API key
onto the droplet) until it does, since Resend will reject sends from an
unverified domain.

**3. Generate an API key** — **API Keys → Create API Key**. Name it something
identifiable, e.g. `lor-director-prod`. Permission: **Sending access** only
(not "Full access" — the director only ever calls the send-email endpoint,
per `director/src/email.ts`'s `ResendMailer`). Copy the key immediately —
Resend only shows it once (`re_...`).

**4. Drop the three env vars onto the droplet** — SSH to the droplet
(`ssh lor@209.38.29.22`), then:

```bash
sudo nano /etc/lor/game.env
```

Add these three lines (the file already has `DATABASE_URL`, `DIRECTOR_PORT`,
`SNAPSHOT_INTERVAL_MS`, `GODOT_BIN` — confirmed live 2026-08-12; add these
alongside, don't replace anything):

```
RESEND_API_KEY=re_your_actual_key_from_step_3
EMAIL_FROM="Legend of Rah <noreply@mail.lor-server.cocreations.com.au>"
PUBLIC_BASE_URL=https://lor-server.cocreations.com.au/director
```

Save, then restart the director so it picks up the new env:

```bash
sudo systemctl restart lor-director.service
sudo systemctl status lor-director.service   # confirm active (running), no crash-loop
```

**5. Verify it's actually sending** — trigger `attach_email` or
`request_password_reset` against the live director (e.g.
`npm run verify-accounts-e2e` from `game/director/`, or by hand from a
client) and confirm two things: (a) `journalctl -u lor-director -f` no longer
logs a `[mailer:log-fallback]` line for that request, and (b) the email
actually lands in the test inbox used.

## Why these exact values

- **`mail.lor-server.cocreations.com.au` as the sending domain, not the bare
  `cocreations.com.au` root**: confirmed 2026-08-12 that the root domain
  already carries an MX record (self-pointed, `0 cocreations.com.au.` — no
  active third-party mail provider visible, but still Kris's real business
  domain). Resend's own guidance is to use a dedicated subdomain for
  transactional/marketing mail regardless, so the sending domain's DNS is
  fully isolated from anything else on `cocreations.com.au`.
- **`PUBLIC_BASE_URL=https://lor-server.cocreations.com.au/director`, with
  the `/director` suffix**: confirmed against the droplet's live Caddyfile
  (2026-08-12) — `lor-server.cocreations.com.au` only reverse-proxies paths
  under `/director/*` to the director process; everything else either hits
  `/play/*` (a static file server) or falls through to a placeholder
  `"Lor game server is up."` response. `email.ts`'s `buildVerifyUrl`/
  `buildResetUrl` build `${PUBLIC_BASE_URL}/verify?token=...` and
  `/reset?token=...`; `http.ts`'s route matcher (`matchesPath`) expects
  either a bare `/verify` or a `/director`-prefixed `/director/verify` — so
  the value here needs the `/director` suffix for the emailed link to
  actually reach the handler through Caddy instead of 404ing.

## Flagged, not fixed here: the link-code `/play` URL breaks either way

`server.ts`'s `create_link_code` handler builds its QR/URL field the same way:
`${PUBLIC_BASE_URL}/play?link=<code>`. With the `/director`-suffixed value
above, that becomes `.../director/play?link=...` — not a route Caddy or the
director serves, so **the QR code / link-code URL will be broken** once this
env var is set (today, with `PUBLIC_BASE_URL` unset, it falls back to
`http://localhost:8081/play?link=...`, which is equally unusable off the
droplet — so this isn't a regression, just not fixed by this task either).

This isn't fixable by choosing a different single value: verify/reset need
the director's own domain+prefix; the play link needs wherever the *actual*
current web client is hosted, which per `land-of-lor/docs/handoff/historic/010-accounts.md`'s
Deploy section is `https://lor.kris.ai-task-runner.com/play/` (the workspace
site) — **not** `lor-server.cocreations.com.au/play/`, which Caddy still
points at an old `pocs/multiplayer-poc` web build that was never updated in
the accounts-v2 deploy (confirmed live 2026-08-12: still serving from
`/opt/lor/game-server/pocs/multiplayer-poc/world-server/client/web-build`).
One env var can't correctly serve both purposes under the current route
layout. Not blocking — a link code can also be typed in by hand on the
account screen (`redeem_link_code`), so the QR/URL is a convenience, not the
only path — but worth a real follow-up: either split into two env vars
(e.g. a `DIRECTOR_PUBLIC_URL` for verify/reset and a separate one for the
play link), or update the droplet's own `/play` to reverse-proxy or mirror
the current build instead of serving the stale `pocs/` one. Prioritized
verify/reset (the security-relevant, actually-requested piece) over the QR
link in the values recommended above.

## Progress — 2026-08-13 (steps 1, 3 and 4 done; step 2 is the whole remainder)

Kris created the Resend account and generated the API key (sending-access only
— confirmed by the key being rejected for `GET /domains`, which is exactly the
scope this task asked for). The domain `mail.lor-server.cocreations.com.au` is
**added but not verified**: the dashboard is sitting on "Fill in your DNS
Records", and a live DNS check found no TXT, MX or DKIM record published under
that subdomain.

**The key is on the droplet but commented out, on purpose.** Deploying it live
and running `verify-accounts-e2e` showed why: `createMailer` only falls back to
`LogMailer` when `RESEND_API_KEY` is *absent*, so with the key set and the
domain unverified, every send throws and `attach_email` answers
`INTERNAL_ERROR` — strictly worse for real users than the log fallback. A
direct API send confirmed the cause (`403 — the … domain is not verified`).
`/etc/lor/game.env` therefore has the key present, commented, with a note; the
last step of this task is to uncomment it and re-run `game/update_game_env.sh`.
(A `422 Invalid to field` seen alongside this was just the e2e's
`@example.com` test recipient, not a config problem.)

`EMAIL_FROM` is live. `PUBLIC_BASE_URL` was superseded the same day by
`EMAIL_VERIFY_BASE_URL` + `LINK_CODE_BASE_URL` — see task 016, which fixes the
broken-QR-link consequence flagged below.

### The remaining work is DNS, and it is at iFastNet

The records must be added in **iFastNet's** DNS zone editor: `cocreations.com.au`
delegates to `ns101.ifastnet.com` / `ns201.ifastnet.com` (re-confirmed
2026-08-13), so whoever registers the domain does not control its records. A
Namecheap API key was tried and cannot do this job for two independent reasons:
the credentials were rejected outright (`1011102 — API Key is invalid or API
access has not been enabled`; Namecheap also requires API access switched on
per-account and the calling IP whitelisted), and more fundamentally Namecheap's
`domains.dns.setHosts` only edits zones served by Namecheap's own BasicDNS —
it has no effect on a domain delegated elsewhere. Moving the nameservers to
Namecheap to make the API usable would mean re-creating the entire existing
zone by hand, including the business domain's live MX and web records, to fix
one subdomain: not worth the blast radius.

So step 2 stays a human, in-browser action in iFastNet's control panel.

## Definition of done

- Resend domain `mail.lor-server.cocreations.com.au` shows **Verified** in
  the Resend dashboard.
- `/etc/lor/game.env` on the droplet has all three vars, `lor-director.service`
  restarted clean.
- A live `attach_email` or `request_password_reset` produces a real email
  (not a log line) in the test inbox used to check.

### server-architecture/014 — Server hardening batch — the 010 audit's security fixes
```yaml
id: server-architecture/014
title: Server hardening batch — the 010 audit's security fixes
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-13
updated: 2026-08-13
claimed_by: session-88fa322b-agent
claimed_at: 2026-08-13T01:35:00.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/014-server-hardening.md
```

## What

The 010 audit ([`../010-accounts-audit.md`](../010-accounts-audit.md)) —
recommendation 1's batch, split out from task 013 (which takes the client-side
findings) by Kris's call on 2026-08-13. All small, local, independently testable
server fixes; none touches the grant architecture. Work in
`land-of-lor/game/director/` (plus one `world_server.gd` change for the takeover
reason).

## The batch (audit reference in parens)

1. **Revoke all sessions on password reset** (H1) — call the existing, tested
   `revokeAllSessions` from `http.ts`'s POST `/reset` path. The audit's one
   high-severity security gap.
2. **Strip `accountId` from client-facing `world_state` entities** (M1) — the
   director's join reply currently re-links every player's opaque `p-<hex>` wire id
   to their accountId. The `members` array stays; entity `accountId` goes.
3. **XFF parsing** (M2) — stop trusting `split(',')[0]`; use the rightmost
   untrusted entry, and record the Caddy `trusted_proxies` invariant in the deploy
   runbook (verify the live Caddyfile once).
4. **Rate limiter** (M3) — evict full/idle buckets periodically; add an overall
   per-IP cap on `login_email` alongside the per-identity key; rate-limit
   `create_link_code` (currently unlimited QR-generation CPU + inserts).
5. **Message-size limits** (M4) — `maxPayload` on both WebSocketServers; `.max()`
   on `password`, `email`, `create_world.name`, `set_world_note.note`.
6. **scrypt cost actually applied** (M5) — pass `{ N: SCRYPT_N }` at hash AND
   verify time; parse the stored N on verify so parameter upgrades work.
7. **Same-account lifecycle, server halves** (M6) — emit a `TAKEN_OVER` reason on
   the newest-wins disconnect in `world_server.gd` (013's client already handles it,
   dormant); refcount `ActiveWorld.players` per connection instead of a
   `Set<accountId>` (closing one of two same-account tabs currently shuts the world
   down under the survivor); convert `server.ts`'s `joinedWorlds` Map to the pair
   set the 010 plan specified (one socket, two sessions currently leaks a refcount).
8. **Timing-based account enumeration** (M7) — dummy scrypt on the unknown-email
   login path; ack `request_password_reset` before (or without awaiting) the Resend
   HTTP call.
9. **Server lows from the audit, same pass**: don't echo `err.message` in
   `INTERNAL_ERROR`; fix the grant-mint-failure phantom-player leak
   (`server.ts:342-345`); uppercase+trim link-code redemption input; prune world
   `move_budget` entries for departed entities; consider movement-budget cap ~40
   for honest diagonal headroom (audit low — Kris's call if behaviour changes).

## Definition of done

Each fix lands with a test where the suite can express it (reset-revocation,
world_state accountId absence, size-limit rejection, scrypt N round-trip, TAKEN_OVER
reason, refcounted players). Full suite green, deploy per the standing runbook,
existing verify scripts still pass live. Handoff note + `review`, per convention.

### server-architecture/015 — First iOS build — run the game on an iPhone from Kris's Mac
```yaml
id: server-architecture/015
title: First iOS build — run the game on an iPhone from Kris's Mac
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: S
created: 2026-08-13
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/export-ios.sh
```

## What

Kris's call, 2026-08-13: attempt the first-ever iOS build of the game, today,
on his Mac. This is hands-on-Mac work (agents in this workspace have no Mac),
so this task is the runbook plus the repo-side prep — the prep is already
done: an iOS export preset exists in `game/world/export_presets.cfg`
(land-of-lor `6350b1d` — bundle id `au.com.cocreations.lor-spellgrove` with
Kris's Team ID baked in; Android uses `lor_spellgrove` since Android forbids
hyphens in app ids; `export_project_only=true` so Xcode owns signing. The
"spellgrove" ids are a naming direction, still provisional — Kris, 2026-08-13).

The project should export clean: pure GDScript (no native plugins), all
network traffic is TLS (`wss://` to the live director — satisfies iOS App
Transport Security), and the mobile default director URL is already baked in.
Nobody has ever run this export, so expect small snags, not big ones.

## The Mac runbook

1. **Install Godot 4.7.1** — official universal .dmg from godotengine.org
   (must be 4.7.1 to match the project). Open it once, then
   Editor → Manage Export Templates → Download and Install (4.7.1.stable).
2. **Install Xcode** from the Mac App Store (large download). Launch once,
   accept the license, let it install the iOS platform. Then
   `xcode-select --install` for the command-line tools.
3. **Clone the repo** (private — needs GitHub auth on the Mac):
   `git clone git@github.com:krisrandall/land-of-lor.git`
4. **Open the project**: `land-of-lor/game/world/project.godot` in Godot.
   Let the import finish.
5. **Export**: Project → Export → the **iOS** preset → Export Project (it
   produces an Xcode project folder, e.g. under
   `game/world/client/ios-build/`). If Godot complains about missing icons,
   assign `client/ui/assets/lor-banner.jpg`-derived placeholders or let it
   use defaults — cosmetic only for a device build.
6. **Xcode**: open the generated `.xcodeproj`. Signing & Capabilities →
   Team → your personal team (a **free Apple ID is enough** to run on your
   own iPhone; the $99/yr Developer Program is only needed later for
   TestFlight / App Store / Game Center — see 010-accounts-plan.md's
   provider table).
7. **iPhone**: plug in via cable, trust the Mac, and enable Developer Mode
   (Settings → Privacy & Security → Developer Mode, then reboot — iOS 16+).
   Select the device in Xcode and Run.
8. **Sign in on the iPhone**: the account-first flow works cross-platform
   already — either create a new character, or attach the phone to your
   existing account with a typed **link code** from your Android/web session
   (Account → Add a device). QR scanning waits on 012's `PUBLIC_BASE_URL`.

## Definition of done

The game runs on a real iPhone against the live director: signup or
link-code attach, walk around the commons, see other players. Snags found
along the way get written back into this file (or a handoff) so the next
iOS build is a checklist, not an expedition. Game Center sign-in is NOT this
task (speced in 010's plan; needs the paid Developer Program — later).

## Done on device — 2026-08-13

Kris ran the game on his iPad Pro the same day: exported from Godot 4.7.1 on
the Mac, signed in Xcode (bundle `au.com.cocreations.lor-spellgrove`, Team
GUBJ3SLQ7S), launched against the live director. The session also produced
**`game/export-ios.sh`** (land-of-lor `b7de399`) — headless Godot export +
`xcodebuild` device build, with `--deploy` installing and launching on the
iPad — so the next iOS build is one command on the Mac, not an expedition.
Snags encountered and fixed along the way: the icon-set requirement (full
placeholder set now wired into the preset, `9f4d20b`) and Godot rewriting
`export_presets.cfg` wholesale on first open in 4.7.1 (accepted, committed).
In `review` for Kris's formal sign-off, per convention.

### server-architecture/016 — Split PUBLIC_BASE_URL into EMAIL_VERIFY_BASE_URL and LINK_CODE_BASE_URL
```yaml
id: server-architecture/016
title: Split PUBLIC_BASE_URL into EMAIL_VERIFY_BASE_URL and LINK_CODE_BASE_URL
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: S
created: 2026-08-13
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/016-base-url-split.md
```

## What this is

The follow-up task 012 flagged and deliberately did not fix. The director built
two different public links from a single `PUBLIC_BASE_URL`:

- **verify / password-reset links**, which must reach the director itself —
  exposed by Caddy only under `/director/*` on `lor-server.cocreations.com.au`;
- **the link-code QR** ("attach this device"), which must reach the **web
  client**, served from the workspace site at `lor.kris.ai-task-runner.com/play/`.

Those are different hosts, so one value could never be right for both. Setting
the variable to the director's origin (012's choice, correct for the
security-relevant links) made the QR resolve to `…/director/play?link=…`, which
nothing serves.

## What was done

Two variables replace the one, each falling back *specific → `PUBLIC_BASE_URL`
→ `http://localhost:8081`*, so an older env file behaves exactly as before:

- `EMAIL_VERIFY_BASE_URL` — `emailVerifyBaseUrl()` in `director/src/email.ts`,
  feeding `buildVerifyUrl` / `buildResetUrl`.
- `LINK_CODE_BASE_URL` — `linkCodeBaseUrl()` in `director/src/server.ts`,
  feeding the `create_link_code` URL and its QR PNG.

`game.env.example` documents both and marks `PUBLIC_BASE_URL` deprecated. The
live droplet env was updated and the service restarted.

## Definition of done

- Both variables read, with the documented fallback chain. ✓
- Live droplet set to `https://lor-server.cocreations.com.au/director` and
  `https://lor.kris.ai-task-runner.com` respectively. ✓
- `verify-live-droplet-wss` and `verify-accounts-e2e` green against the live
  droplet after the change. ✓
- The QR URL resolves: `/play?link=CODE` 308s to `/play/?link=CODE`, 200. ✓

## Assumptions

That the workspace site (`lor.kris.ai-task-runner.com/play/`) is the current
web client, per `land-of-lor/docs/handoff/historic/010-accounts.md`'s deploy section —
**not** `lor-server.cocreations.com.au/play/`, which still serves a stale
`pocs/multiplayer-poc` build. Retiring that stale route is separate work, not
done here.

### server-architecture/018 — Register spellgrove.com and set it up for the site and email
```yaml
id: server-architecture/018
title: Register spellgrove.com and set it up for the site and email
epic: server-architecture
state: needs-input
priority: 2
blocked_by: []
estimate: M
created: 2026-08-13
updated: 2026-08-13
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What this is

Kris's call, 2026-08-13: register **`spellgrove.com`** — the current working
title (see [`brand/naming.md`](../../../brand/naming.md), which already lists
securing this domain as an outstanding step) — and set it up **simply and
clearly for our actual needs**: wrapping the public site, and carrying email.
DNS to be managed with an API key so an agent can do the work, rather than by
hand in a slow panel.

This is the deliberate correction to a day spent discovering how bad the
current arrangement is, and the reasoning is worth keeping:

- `cocreations.com.au`'s DNS is at iFastNet/Byethost, which has **no DNS API**.
  A day's work on Resend email verification died there ([012](012-resend-real-email-setup.md)).
- That domain also can't hold a mailbox: its MX points at the free-hosting web
  server, which `250 Accepted`s mail for *every* address — real and fake alike
  — with nothing behind it to deliver into ([017](017-manual-password-reset.md)).
- The registrar is not the DNS host. A Namecheap API key could not have edited
  those records even had it been valid, because Namecheap's DNS API only serves
  zones on Namecheap's own nameservers. **Whatever is chosen here must be
  verified on that specific point, not assumed.**
- `ai-task-runner.com` was floated purely because its DNS has a working API and
  is **explicitly rejected** by Kris — it's the task-runner platform's domain
  and shouldn't carry game traffic or mail.

## What this needs to end up doing

1. **The public site** — currently `lor.kris.ai-task-runner.com`, built by the
   host cron deploy-watch (see the workspace README's Deployment section).
   `spellgrove.com` should front it. Note the knock-on: `LINK_CODE_BASE_URL` in
   `game/director/game.env` points at the current host and must move with it
   ([016](016-split-public-base-url.md) is why that's a single, isolated line).
2. **Email** — at minimum a receiving address for `SUPPORT_EMAIL`
   ([017](017-manual-password-reset.md)), so password resets have somewhere to
   arrive. Sending (verify-email, [012](012-resend-real-email-setup.md)) becomes
   possible again too, since DKIM/SPF records would finally be settable by API.
3. **The game server** — `lor-server.cocreations.com.au` could move under
   spellgrove.com as well. **Not** a given: it's live, it works, and moving it
   means new TLS and a client-visible URL change. Worth deciding explicitly
   rather than by momentum.

## Open, and genuinely Kris's to decide

- **Where to register, and where DNS lives.** These can be the same provider or
  not. The hard requirement is a DNS API that actually controls the zone.
  Cloudflare is the obvious candidate — registrar at cost, free DNS with a
  first-class API, and **Email Routing that forwards `support@spellgrove.com`
  to any inbox for free**, which solves the mailbox problem without hosting a
  mail server. Not a decision to take unilaterally: it puts a third party in
  front of the site, so it's Kris's call.
- **Which names move now vs later** — site first is the low-risk order; the
  game server can follow once the rest is proven.
- **The API key.** Whatever provider is chosen, the key belongs in the
  workspace `.secrets/` alongside `do_api_token`, gitignored, per
  `../../CLAUDE.md`.

Filed `needs-input` because the first step — registering a domain — costs money
and is Kris's to do; everything after it is agent work.

## Definition of done

- `spellgrove.com` registered, with DNS on a provider whose API an agent can
  drive, and the key stored in `.secrets/`.
- The public site served from it.
- A working receiving address, and `SUPPORT_EMAIL` set to it on the droplet.
- Whatever moved is recorded — this is the domain layout the project keeps.

> **Update 2026-09-03:** spellgrove.com is live — DNS, TLS and the site itself (`building-the-game/001`). What remains of this task is moving the game client's base URL onto it.

> **Update 2026-09-03:** spellgrove.com is live — DNS, TLS and the site itself (`building-the-game/001`). What remains of this task is moving the game client's base URL onto it.

### server-architecture/019 — Account screens redesign — two panels, and this device's remembered logins
```yaml
id: server-architecture/019
title: Account screens redesign — two panels, and this device's remembered logins
epic: server-architecture
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-26
updated: 2026-08-26
claimed_by: session-2026-08-26-accounts-spec
claimed_at: 2026-08-26T00:00:00.000Z
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/019-account-screens-and-remembered-logins.md
```

## What

Kris recorded a full voice spec of the account screens on 2026-08-26 — this task is that
spec, transcribed, plus the build against it. This is the "account-screens design session"
`status.md` had been carrying as outstanding next-step work since 2026-08-18 (restyle
`signup_flow` / `signin_screen` / `account_screen` / `character_preview`; primitives in
`theme/lor_forms.gd` + `lor_ui.gd`).

Kris's own framing going in: the architecture (`010-accounts-plan.md`, the
`auth_identities` seam, the provider list) is already settled and not being re-litigated
here — this is specifically the UI/UX layer on top of it, described in enough detail to
build directly, with two exceptions he flagged himself: whichever bits stayed genuinely
undecided get a question against this ticket, and the platform sign-in setup steps
(Apple/Google) get written out clearly enough that he can do them himself afterward.

## The spec (Kris's voice memo, transcribed)

**Sign-in methods**, in the order discussed: 1) Apple Game Center (iOS), 2) Google Play
Games (Android), 3) email + password ("the full message... enter email, enter password to
log in"), 4) Steam — his own words: "which I have zero experience with... I don't even
play PC games, but Steam has an equivalent kind of thing... option four is the main
placeholder for now." All four already match `010-accounts-plan.md`'s provider table
exactly (`email`, `google_play`, `game_center`, `steam` — the last two already marked
"speced only" there before this session).

**The signed-in account screen** — two panels, side by side:

- **Authentication panel**: which method this account signs in with, and the identity
  info that method carries (email address; a platform gamertag/alias once linked); the
  device list; rename; attach-email / link-provider actions; add-a-device. **Sign out
  lives here too, deliberately styled small and easy to miss** — "a subtle baby-tuck
  way... the option for them to log out" — not another entry in the main button column.
- **Character panel**: the character's name, and a preview of the character "spinning
  around gently and slowly in 3D".

**Signed-out state**: the two panels disappear. In their place: a button per identity this
*device* remembers, reading **"Log in again as [name]"**. Tapping one:
- On a platform-native login (Game Center / Play Games), resumes automatically — "they
  press that button and they're immediately back to their game centre account."
- On the web, for an email-based login, **requires typing the password again** — "if I
  click that, they should be required to type in their password, because that's the way
  we're going to do that login." (Re-entering the address is what's being skipped, not
  proving you know the password.)

Below the remembered-logins list, on every platform, in the signed-out state: the normal
"enter email, enter password to log in" form, and "create account" (email + password —
**no email verification, no password reset in this version** — already settled the same
way independently in server-architecture/017, unchanged by this task).

**Removing a remembered login**: "some other mechanism [so] it's not ever going to
accidentally be done" — a **long press** on a remembered-login entry removes it from the
device's list. This **never deletes the account itself** — there is no account-deletion
path anywhere in this client, on any platform, and this task doesn't add one.

## What was genuinely ambiguous, and what was assumed instead of asked

None of the open points below reached `epics/PICKUP.md`'s question bar (money /
irreversible / public-facing / reaches outside the repo) — so per that bar, this task
carries **zero open questions**, and each point was assumed, built, and flagged plainly
for correction:

- The "3D spinning" character preview doesn't exist — there is no 3D character model yet
  (`character_preview.gd` already documents the real hexagram-defined model as
  deliberately undesigned). Built a placeholder: the existing 2D seed-tile, animated with
  a gentle sway, swapped for the real thing wherever `character_preview.gd`'s own seam
  does that later.
- How many logins a device remembers (assumed 6, most-recent-first) and what happens when
  a remembered session has actually expired (assumed: drop it and fall back to a full
  re-auth for that same identity, never leave a dead button offering a login that can't
  work).
- Whether the Authentication panel shows one provider or all linked ones (assumed: all of
  them, joined — an account can accumulate more than one over time).

## Definition of done

Built, tested, and documented this session — see
**[`land-of-lor/docs/handoff/historic/019-account-screens-and-remembered-logins.md`](../../land-of-lor/docs/handoff/historic/019-account-screens-and-remembered-logins.md)**
for the full report: the two-panel `account_screen.gd`, the remembered-logins list in
`signin_screen.gd` (quick-resume + long-press-forget), `account_session.gd`'s new
device-local history storage, and the one additive director change (`whoami`'s
`account_info` reply now carries `providers: string[]`). Director suite 170/170 (+10 from
server-architecture/020's Game Center tests, built alongside since both touch the same
`server.ts`/`whoami` code), world suite 116/116, all touched `.gd` UI files parse-checked
clean.

**What's still Kris's to do**: look at the actual two-panel layout (no display in this
container — nobody has seen it render), and see `020-apple-game-center-sign-in.md` for the
platform sign-in setup steps this task's spec asked to have written out.

## Non-goals

- Apple Game Center's and Google Play Games' actual platform setup — server-architecture/011
  (Play Games, already `needs-input` on Kris's Play Console steps) and the new
  server-architecture/020 (Game Center) carry those.
- Steam — stays "speced only" exactly as `010-accounts-plan.md` already had it; no work
  landed on it here, matching Kris's own "placeholder for now" framing.
- Email verification / self-service password reset — server-architecture/017's territory,
  untouched.

### server-architecture/020 — Apple Game Center sign-in on iOS
```yaml
id: server-architecture/020
title: Apple Game Center sign-in on iOS
epic: server-architecture
state: needs-input
priority: 2
blocked_by: []
estimate: M
created: 2026-08-26
updated: 2026-08-26
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/handoff/historic/020-game-center-server-half.md
```

## What

Apple Game Center on iOS, split out from `019` (the account-screens redesign) the same
way `011` split Google Play Games out from `010` — so the buildable server work doesn't
sit blocked on Kris's Apple Developer Program setup. `019`'s spec named this as sign-in
option #1 of four; the schema and generic identity-attach helper (`attachProviderIdentity`)
have listed `game_center` as a provider since `010-accounts-plan.md`, unbuilt until now.

Per the approved architecture: one new row-kind in `auth_identities`
(`provider = 'game_center'`), one new director message pair (the client sends Apple's
signed identity-verification bundle; the director verifies the signature itself against
Apple's certificate), and one client-side integration (an iOS Game Center plugin wired into
`account_session.gd` + a "Sign in with / Link Game Center" button on the sign-in and
account screens). No schema change, no session-model change — same seam, same shape as 011.

## Blocked by

**Kris, outside the repo:** enrolling in the Apple Developer Program ($99/yr — a free
Apple ID, which is what task 015's first iOS build used, is not enough for Game Center),
registering the App ID with the Game Center capability, and turning on Game Center for the
app in App Store Connect. The full step-by-step, with links, is in the handoff below. This
clears `PICKUP.md`'s question bar (costs money, reaches outside the repo) the same way
011's Play Console step did — Kris's own call to stage it this way (2026-08-26).

## Non-goals

Google Play Games (011, already built server-side, `needs-input` on Play Console setup)
and Steam (still "speced only" — `010-accounts-plan.md`, unchanged by `019`).
Achievements / leaderboards / cloud save via Game Center — later, same integration rides.

## Server + client seam built — 2026-08-26

Everything buildable without Kris's Apple Developer Program membership is DONE, tested,
and ready to deploy (handoff:
[`land-of-lor/docs/handoff/historic/020-game-center-server-half.md`](../../land-of-lor/docs/handoff/historic/020-game-center-server-half.md)):
`login_game_center` on the director (injectable Apple-signature verifier —
real/disabled by env, mocked in tests; verifies against Apple's documented byte layout
with a host-checked certificate fetch so a forged `publicKeyUrl` can't substitute its own
key; attach-don't-merge with `IDENTITY_TAKEN`; `PROVIDER_UNAVAILABLE` while unconfigured),
reusing the generic `attachProviderIdentity` helper from 011, the client seam
(`login_game_center` RPC + `platform_supports_game_center()` gate + sign-in/link buttons
that never render without the plugin). Director 170/170, world 116/116.

**Now waiting on Kris** — the App Store Connect / Apple Developer Program checklist is in
the handoff (bundle id already fixed by task 015's export preset —
`au.com.cocreations.lor-spellgrove` — nothing new to name). After that, the device half:
vendor an iOS Game Center plugin (candidate named in the handoff, unconfirmed), wire the
real identity-verification fetch into the two `_fetch_game_center_bundle()` stubs,
on-device e2e per the DoD below.

## Definition of done

On a real iPhone/iPad: fresh install → the OS silently authenticates the Game Center
player at launch → "Link Game Center" from the account screen attaches it to the current
account → the same LoR account appears on a second device signing in with the same Apple
ID's Game Center identity. Attach-vs-conflict behavior matches the plan's "attach, don't
merge" rule (same as 011's Play Games DoD). Server-side verification covered by director
tests (mocked Apple verification).

### server-architecture/003 — Tick-rate / real-time-vs-lazy-evaluation memo
```yaml
id: server-architecture/003
title: Tick-rate / real-time-vs-lazy-evaluation memo
epic: server-architecture
state: done
priority: 3
blocked_by: []
estimate: S
created: 2026-08-05
updated: 2026-08-02
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## Resolution (2026-08-02)

**Decided, not written as originally scoped.** A direct architecture conversation settled concrete tick rates without the survey memo ever getting written: **simulation at 20 Hz, network snapshots to clients at 10 Hz** (with ~100 ms client-side interpolation), **field diffusion at 2–5 Hz if fields survive at all** (they mostly don't — see task 001's resolution). The lazy-evaluation half of this task's original question is answered more fundamentally than "which systems can skip ticks while unwatched": **an empty world is a frozen world** — no simulation runs at all while a world has zero players in it, so there's no "far-off unvisited region ticking slowly" case to design for; there's simply no ticking until someone's there.

Full decision recorded in `server-architecture/overview.md`'s "Netcode" and "Persistence" sections — read that, not this note, for the actual architecture.

## What to do

Survey how much of the simulation genuinely needs a fixed real-time tick versus can be lazily evaluated (e.g. only recompute a region's fields when a player is actually nearby — per `overview.md`'s open question). This bears directly on both hosting cost (`server-instance`) and on how "alive" the world feels when nobody's watching a given region.

## Definition of done

A short memo: which systems need true real-time ticking (e.g. an active battle arena) versus which can be computed on-demand/lazily (e.g. a far-off, unvisited region's slow terraforming). End with a recommended default tick strategy — a starting point for `server-instance`'s environment planning, not a locked-in architecture.

## Where the work lands

`land-of-lor` repo.

## Docs to read first

`land-of-lor/docs/README.md` §5 (fields), §7 (changing lines — the aging mechanism this tick strategy has to serve); this epic's task 001 (scaling approach) for consistency.

### server-architecture/008 — Stage 4 — interest management, creatures, flow-field pathfinding at scale
```yaml
id: server-architecture/008
title: Stage 4 — interest management, creatures, flow-field pathfinding at scale
epic: server-architecture
state: done
priority: 3
blocked_by: ["server-architecture/007"]
estimate: L
created: 2026-08-02
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/pocs/multiplayer-poc/handoff/stage-4.md
```

## What to do

The scale stage — everything before this proves the mechanism works with a handful of entities; this stage proves it holds up at the load `overview.md`'s "Pathfinding and sensing" section actually targets: hundreds of creatures, from multiple spawn points, converging on a handful of targets, within tick budget.

1. **Interest management:** each client receives state only for entities within a radius, via the spatial partition `overview.md`'s "Netcode" section calls for (day-one requirement, being implemented properly at the first stage where it actually matters at scale).
2. **Creatures:** NPC entities that move autonomously toward targets (a home castle, a player) rather than only being driven by client intent.
3. **Flow-field/Dijkstra-map pathfinding:** one outward sweep per distinct target producing a direction grid; creatures do a single array lookup per tick rather than individual A* searches — per `overview.md`'s explicit "cost scales with number of targets, not number of agents" mechanism. Recompute a target's field only when it changes cell; cap total pathfinding work per tick via a work queue.
4. **The actual scale proof:** hundreds of simulated creature agents, load-tested within this stage's tick budget (20 Hz simulation, per `overview.md`'s "Netcode" section) — measure and report actual numbers, not just "it didn't crash."
5. Automated tests (flow-field correctness on a known map, recompute-on-cell-change behaviour) plus a headless load-test script that spins up the target creature count and reports tick timing — the scale claim has to be a number in a script's output, not an impression from watching it run.
6. Handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/stage-4.md`, including the actual measured numbers (how many creatures, at what tick cost) and an honest note on where the ceiling was found, if one was.

## Definition of done

Automated tests and the load-test script both pass and report concrete numbers; the handoff note exists with those numbers in it. Set `state: review`, not `done`.

## Where the work lands

`land-of-lor/pocs/multiplayer-poc/`.

## Docs to read first

`server-architecture/overview.md`'s "Pathfinding and sensing" and "Netcode" sections (tick rates, interest management) in full — this stage is where nearly every decision in those two sections gets exercised together. Stage 3's handoff note for the client/server contract this stage's creatures and interest management build on.

### server-architecture/009 — Android APK export & download link
```yaml
id: server-architecture/009
title: Android APK export & download link
epic: server-architecture
state: done
priority: 3
blocked_by: ["server-architecture/007"]
estimate: M
created: 2026-08-03
updated: 2026-08-04
claimed_by: null
claimed_at: null
delivers: [7]
review_artifact: null
```

## What to do

Package the unchanged Stage 3 client (`server-architecture/007`) as an installable Android APK, and host it with a download link from the deliverables page. This is an **export target, not new game code** — zero new gameplay or netcode is in scope here; if something about the client doesn't work correctly on Android, that's a bug to flag against Stage 3, not a feature to add in this task.

1. **Android export preset** in the Stage 3 Godot client project — same codebase, same `Entity`/`EntityNode` split, no gameplay changes.
2. **SDK/JDK/signing setup** needed for Godot's Android export (Android SDK, a JDK, a debug or release keystore — `lor.keystore` already exists at the workspace root per the top-level `CLAUDE.md` and may be reusable; confirm whether it's appropriate for this build or a separate key is warranted before reusing it, since it's currently described there as used for `lor-elementals` APKs).
3. **Build the APK** from the Stage 3 client, connecting to the same droplet-hosted world deliverable #6's web export connects to (same server, same protocol, same `ws://` constraint noted in task 007's step 8 — no TLS yet).
4. **Host the APK and link it** from this workspace's deliverables page (`site/`) as a tap-to-download link.
5. **Verification: two physical Android phones**, each with the APK installed, connected to the same live world, each seeing the other's player move — the concrete proof this task exists to deliver. Where a headless equivalent is feasible (e.g. confirming the exported build connects and moves correctly at the protocol level, independent of two real devices), add it as automated coverage alongside the human check, consistent with every other stage's "no listening port to click on" pattern — but the two-phone check itself is the actual bar, and Kris does it directly rather than an agent asserting it happened.
6. Handoff note: `land-of-lor/pocs/multiplayer-poc/handoff/task-009-android-export.md` — what was built, exact steps to reproduce the APK build, where it's hosted, and what Kris needs to check on the two phones.

## Definition of done

APK builds successfully from the unchanged Stage 3 client, is hosted with a working download link on the deliverables page, and installs on Android; the handoff note exists. Set `state: review`, not `done`. Per the `delivers: [7]` gate (see `epics/README.md`'s "Promised deliverables" section), this task cannot move to `done` until deliverable #7 is `delivered` with its live download link in place and the two-phone check has actually happened.

## Where the work lands

Android export/build config in `land-of-lor/pocs/multiplayer-poc/` (alongside the Stage 3 client); the hosted APK and its download link live in this workspace repo's `site/`.

## Docs to read first

`server-architecture/overview.md`'s "Runtime" section (single Godot codebase, client/server split). Task `007`'s handoff note and its step 8 (the web export this Android export is a sibling target to — same client, same connection constraints).

## Verified and closed (2026-08-04)

Kris confirmed it working the same day, using one Android phone (APK from deliverable #7's download button) plus the web client — cross-device multiplayer against the live commons world, which is the substance of the two-phone bar (two installed phones simply weren't both to hand; the install path and mutual visibility are both proven). Deliverable #7 is delivered; done.

### server-architecture/017 — Password reset by hand — a support address, not self-service
```yaml
id: server-architecture/017
title: Password reset by hand — a support address, not self-service
epic: server-architecture
state: backlog
priority: 3
blocked_by: []
estimate: S
created: 2026-08-13
updated: 2026-08-13
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What this is

**Kris's call, 2026-08-13: there is no self-service password reset.** Email
login stays (it's how the web build signs in), but a player who forgets their
password emails a support address, Kris confirms it's really them, and Kris
resets it by hand.

This replaces both earlier attempts at the problem: the emailed reset link
(blocked on DNS at a panel with no API — task 012) and an automated recovery
code (built 2026-08-13, then reverted the same day; a code written down at
signup is a second secret handed to someone who just demonstrated they lose
secrets, so for this game's players the realistic recovery rate is near zero).

Doing it by hand is the honest answer at this scale. There are no players yet;
when there are, a human reply is better service than a broken robot, and it can
be automated later if the volume ever justifies it.

## Step 2 is done — the server no longer lies (2026-08-13)

`request_password_reset` used to ack and silently do nothing, so a client could
say "check your email" for a mail that would never arrive. It now returns
`RESET_UNAVAILABLE` with a message read from the new **`SUPPORT_EMAIL`** env
var: with an address set it says "email X and we'll sort it out"; with none set
(the current state) it says plainly that passwords cannot currently be reset
and names no channel at all — inventing a contact route the player can't use
is the exact failure being fixed. Deployed and verified live.

Turning this on is one env-file line plus `game/update_game_env.sh` — no code
change, no rebuild.

Worth knowing: nothing in the Godot client ever called
`request_password_reset` (there's a helper in `account_session.gd` and no UI),
so no player was ever actually shown the false promise.

## What to do

1. **Pick the address** (Kris) — the open item. It needs a mailbox that
   actually RECEIVES mail; this is unrelated to task 012's Resend *sending*
   setup and does not require it. What was ruled out on 2026-08-13:
   `@cocreations.com.au` addresses don't work, and the reason is worth
   recording — the domain's MX points at `sv1.byethost1.org` (the Byethost
   free-hosting web server), whose Exim accepts mail for *every* address at the
   domain (`kris@` and a deliberately fake address both answered `250
   Accepted`) and then has no mailbox to deliver into. Mail arrives somewhere
   that can't store it. Fixing that needs real hosting, which is the same wall
   task 012's DNS work hit.

   **`ai-task-runner.com` is explicitly ruled out** (Kris, 2026-08-13) — it was
   floated only because its DNS sits somewhere with a working API, but it is
   the task-runner platform's domain and has no business carrying game mail.

   **The intended answer is `spellgrove.com`** (Kris, 2026-08-13): register the
   working title's domain and use it for both the public site and email, set up
   deliberately and simply, with DNS under an API key an agent can actually
   use. That's [018](018-spellgrove-domain.md). Until it exists, a plain new
   Gmail forwarded to Kris's inbox is the stopgap — and since `SUPPORT_EMAIL`
   is just an env var, swapping the stopgap for the real address later costs
   one line.
2. ~~**Make the server honest**~~ — done, see above.
3. **Give Kris a reset command** — resetting by hand currently means hand-rolled
   SQL against `auth_identities`, which is exactly how a wrong row gets updated
   at 11pm. `identities.ts` already exports `updateIdentityPassword`; a small
   one-shot admin script (email in, new password out, refuses on no-match)
   wrapping it is the whole job. It should also revoke that account's sessions,
   per 014's reasoning that a forgotten password may be a stolen one.
4. **Client copy** — the "forgot password" surface says to email the address.

## Definition of done

- ~~No UI anywhere claims a reset email is coming.~~ ✓ (2026-08-13)
- Kris can reset a named account's password with one command, with sessions
  revoked, without writing SQL.
- The support address is documented wherever the client copy points at it.

## Assumptions

That email+password login itself stays exactly as it is — Kris confirmed the
web build still needs it. Nothing here changes signup, login, link codes, or
the provider paths.

### server-architecture/022 — Multiple characters under one login
```yaml
id: server-architecture/022
title: Multiple characters under one login
epic: server-architecture
state: backlog
priority: 3
blocked_by: []
estimate: L
created: 2026-08-31
updated: 2026-08-31
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

Kris, 2026-08-31: "at some point I'm thinking we might want to allow multiple
characters under the one login — not now." This file is the requested sketch of
what the change looks like, written while 021's single-identity model is fresh;
it is a design note in backlog form, not scheduled work.

## What changes — DB

- New `characters` table: `id`, `account_id` FK → accounts, `display_name`,
  `character_seed`, `entity_id` (UNIQUE — the body id moves here from
  accounts), `created_at`, `last_played_at`.
- `accounts` slims to auth-only: keep `id`, `created_at`, `banned_at`; its
  `display_name` / `character_seed` / `entity_id` columns migrate away.
- Boot migration (idempotent, additive): for every existing account, mint one
  character row from the account's current three fields — nobody loses their
  adventurer.
- `sessions` gains `active_character_id` (nullable FK) — which character this
  device is playing.
- `account_collections`: decide whether the element collection is per-account
  (shared knowledge across your characters — likely, per the trading design)
  or per-character. Per-account = no schema change.

## What changes — director

- `signup_email` / provider signup: same one transaction, now inserting
  account + identity + FIRST character together.
- New messages: `create_character {displayName, characterSeed}`,
  `select_character {characterId}` (stamps the session); optional
  `delete_character` is a DECISION, not a given (the no-delete-anything rule
  from 019 may extend here).
- `whoami` returns `characters: [...]` plus the session's active one; join
  grants (`join_home_area` etc.) resolve `entity_id` / `display_name` from the
  ACTIVE CHARACTER, not the account.
- Home Areas: today `ensureHomeArea` is per-account. **The one real design
  fork**: per-character homes (each adventurer their own front door — more
  Areas, portals need a "whose home" answer) vs one shared family home per
  account (cheapest, no portal changes). Decide before building.
- Protocol version bump (whoami shape changes); old clients refused as usual.

## What changes — client

- `account_session.gd`: track `active_character_id`; `signup_email` unchanged
  in shape; new `create_character` / `select_character` calls.
- `account_flow.gd`: the signed-in view's character panel becomes the
  character list/picker (the 70%-sized preview per character, tap to select,
  a "+ new adventurer" tile reusing the existing signup character step).
- Remembered logins already store one seed per account — becomes "the last
  played character's seed"; no format change needed.
- e2e harness (`game/test-device/`): extend the driver's loop with
  create-second-character → switch → verify the overhead name changes.

## What does NOT change

- One account ↔ one auth identity (021's model) — characters multiply UNDER
  the login; the login stays singular.
- Sessions/auth flow, the entry sheet, the sign-in screens.

## questions/ (0)

## deliverables (3)

- #8 — 3D people walking — the browser world gets bodies (2026-08-04) — /deliverables/8
- #7 — The two phones — Android APK (2026-08-03) — /deliverables/7
- #6 — Play it in your browser — the multiplayer world, live (2026-08-03) — /deliverables/6