# World map and portals

## overview.md
```yaml
id: world-map-and-portals
title: World map and portals
area: design
summary: The map of Lor — how areas link through portals, what rules each area imposes, and the two surfaces that show it (a standalone page on the website and a screen in the game), including a personal map of your own home and your friends' realms.
repo: land-of-lor (game + director) and this workspace (site/)
depends_on: ["server-architecture"]
```

## What this is

Defined by Kris by voice on 2026-08-12, alongside accounts (`server-architecture/010`),
as the next significant build after the interface pass landed.

The world is organised into discrete **areas** — Kris's working word is *rooms*, naming
still open — linked to each other by **portals**. The public part of that graph is a
shared, published thing: a village of Lor with portals out to a beginner battle arena,
to the Badlands (desert), and onward. Every player also has a **private** part: their own
home area, and portals to their friends' realms. Both parts are one map with two layers,
not two separate maps.

The map is a **first-class artefact, not a UI convenience**. Kris wants it in two places:

1. **On the public website, standalone** — something people can look at without playing,
   showing how the world hangs together. A server picker sits above it, because there will
   be more than one server.
2. **In the game**, reachable from the interface, showing the same public graph plus the
   player's own private layer.

Kris has drawn a paper sketch of the Lor map. **It is not yet in the repo** — it needs to
be photographed and added before the visual design of either surface is settled; the
sketch is the source of truth for the initial public topology, not anything an agent
invents.

**Superseded, 2026-08-15.** The sketch was never photographed in, and it is no longer
blocking: Kris commissioned the map's design instead (three handoffs and two `.dc.html`
mocks, in `land-of-lor/docs/design/ClaudeDesignWorldMapBrief/`), and its nine-area world —
Village of Lor, Market Stalls, Beginner Arena, The Badlands, Deep Cut, Wizard Battle Pit,
and three private homes — is the starting topology, now authored in
`land-of-lor/game/content/spellgrove.world.json`. If the sketch turns up it becomes an edit
in the world editor, not a rebuild.

## Why it exists as its own epic

It sits across three existing epics without belonging to any of them: `server-architecture`
owns the transport and the authoritative state, `public-site-and-comms` owns the website,
`world-traversal-and-ground` owns what the ground under your feet is made of. None of them
owns *the shape of the world at the scale of areas* — which areas exist, what links them,
what rules each one imposes, and how a player reads that at a glance. That's this epic.

## The substrate that already exists

This is not a green field. The multiplayer work adopted into `land-of-lor/game/` already
carries most of the primitives, and any plan should build on them rather than re-invent:

- **`worlds` table** (`game/director/src/schema.sql`) — id, name, `owner_id`, a JSONB
  snapshot blob, `last_played_at`. An "area" is a world row today.
- **`world_members`** — membership as an explicit whitelist, owner included. This is
  already the mechanism for "my realm, and the friends I let in."
- **Portals are implemented**, not hypothetical: `PortalConfigSchema` /
  `SetWorldPortalsRequest` (which grid cells are portals and where each leads) and the
  `portal_transfer_*` control messages that move an entity between world processes via
  Postgres, carrying its full state and a spawn token. See `game/director/src/protocol.ts`.
- **A public commons world** — `JoinPublicWorldRequest` finds-or-creates it and
  auto-members the caller, so strangers land in the same place.

So the graph the map draws is, structurally, `worlds` as nodes and portal configs as
directed edges. What's missing is: the graph is currently a test-scoped configuration
detail, not a designed, named, rule-bearing thing anybody can look at.

## The offline requirement — stated now, deliberately

**Kris's explicit call, 2026-08-12: the finished game must be playable offline.** He
recognises it is probably not achievable on the current build, and is stating it now
anyway so that nothing gets architected in a way that forecloses it.

Offline play does not mean the same game with the network removed. Shared areas are
inherently unavailable offline, and resource rules may have to differ. But that is not a
special case bolted on later — it belongs to a broader principle Kris raised in the same
breath: **different areas already impose different rules.** A battle arena, for example,
probably won't let you bring your whole inventory in. Once "an area carries its own rule
set" is a real, designed concept, "offline" becomes one more rule set rather than a second
codebase.

Open, and deliberately not decided here: what happens to resources gathered offline, and
whether offline progress reconciles with the server on reconnect. The `worlds` table
already carries `last_played_at` from day one specifically to keep offline progression
possible without a migration.

## Scope

- Naming the concept ("rooms"? areas? realms?) and settling it.
- The area/portal graph model: what an area *is* as a designed object, what rules it can
  carry, how portals are authored rather than test-configured.
- The public topology, from Kris's sketch.
- The public/private split: home areas, friends' realms, who can see what on whose map.
- Multiple servers, and what a server means to a player choosing one from a dropdown.
- Two rendering surfaces (site page, in-game screen) from one source of truth.
- Offline play as a stated end-state requirement that the model must not foreclose.

## Non-goals

- Not accounts and login — that's `server-architecture/010`, which this epic depends on
  for any personal/private map to mean anything.
- Not ground types or moment-to-moment traversal within an area — that's
  `world-traversal-and-ground`.
- Not building offline play now. Stating it as a requirement, and not blocking it.

## Key open questions

- What are areas called? (Kris: "rooms for now.")
- Is the map authored (a designed, hand-laid topology) or derived (drawn from whatever
  `worlds` + portal rows happen to exist)? This is the single biggest fork for both
  surfaces, and it decides whether the site page is a static asset or a live view.
- What does a "server" mean to a player — a shard with its own worlds and its own
  population, or something lighter?
- What does the private layer reveal? Do you see a friend's realm on your map before
  they've let you in?

## status.md
```yaml
updated: 2026-08-18
parked: false
tasks: {"backlog":1,"needs-input":0,"ready":0,"doing":0,"review":0,"done":15}
open_questions: []
tag: blocked
next: All built work signed off 2026-08-18. Only 008 (players stacking on the arrival cell) remains, in backlog until player collision exists.
```

# Task summary

16 tasks: 15 done, 1 backlog. The epic went from spec-only to built on
2026-08-15; Kris has now played it four times, and 009 through 014 are each a round of
what playing it found.

- **001 — world-graph spec** — `done`. Answered by CORE_ENTITIES plus the design handoffs.
  The name is **Area**; positions are authored, not derived; a locked realm is fogged, not
  hidden.
- **002 — the two map surfaces** — `done`, superseded by 006.
- **003 — Areas and Portals in the database** — `done`. The Area model, the `portals`
  table, and `ensureAccessAllowed`, without which every authored journey was rejected.
- **004 — portals visible and walkable** — `done`. `hello_ack` carries the Area, the client
  rebuilds on arrival, and there is a ring standing in every portal.
- **005 — the authored world, its loader and the public endpoint** — `done`.
- **006 — the two map surfaces** — `done`. `/world` on the site, and a native screen in the
  game.
- **007 — entity class restructure** — `done`. `ComplexItem` → `LivingCreature` → `Player`.
- **008 — players pile up on the arrival cell** — `backlog`. Noted during 009; waits on
  player collision, which doesn't exist yet.
- **010 — what playing it actually found** — `done`. Start in your own home; a refused
  door restores you in front of it with a message instead of teleporting you to the map's
  corner; hidden portals hidden in-game as well as on the map; an on-screen keyboard for
  touch web; and the Android build finally shipped (v3).
- **009 — homes connect to the world, and the game gets a way out of itself** — `done`.
  A home is no longer a sealed box; `home_area` portals actually work; the Village is an
  undeletable hub; the top-right identity text is a profile menu holding World Map, Account
  and Main Menu. Also: the public map stopped over-fogging and stopped leaking place names
  through portal slugs, the demo homes left the seed, and production was wiped and re-seeded.
- **011 — seeing where you are** — `done`. A readable Area name, a "You are here" bubble
  on the map that collapses to a dot, pinch zoom in the world and on the map, and the
  identity chip off the title screen. Small, and the reason 012 was diagnosable at all.
- **016 — the player who wasn't there** — `done`. A phone in the background lost its
  socket in silence and kept predicting against it; the client now treats 6s of silence on
  an open game channel as a drop and re-joins, pings, and probes the director on resume;
  the Area and the director drop peers that stop answering. Regression test drives the
  real client against a fake Area that goes quiet.
- **015 — "world" means Area** — `ready`. The rename `schema.sql` promised: protocol, database,
  GDScript and docs in one coordinated cutover, so the word stops meaning two things. Deferred three
  times on purpose; specced and shipped 2026-08-16. The table is `areas`, the wire says `areaId`,
  protocol v3, and `worlds.ts` is folded into `areas.ts`. Rehearsed against a restored copy of
  production before the real one was touched. APK v8.
- **014 — one body everywhere** — `done`. Entity ids move from the world to the account
  (`accounts.entity_id`, carried on the join grant), which is the root cause under 013. Plus:
  the on-screen keyboard had never activated on any device in any browser — three separate
  faults, all found by driving the real build in headless Chrome, none findable by reading.
- **013 — meeting yourself in the Badlands** — `done`. Two William 3rds, one account.
  Entity ids are per-world, and an Area you left by closing the game keeps your body; coming
  back through a portal added a second. One account, one body, per world — enforced on
  arrival and on snapshot load, which heals the existing duplicates for free.
- **012 — the door that went nowhere** — `done`. The home door dropped you in the corner
  of the room you started in: the origin world sent `portal_result` and hung up in the same
  tick, and the Godot client only ever saw the close. Fixed on the write side, with a
  `resume_portal` RPC behind it. Also: the ancient 10×10 test map stopped flashing up on
  every launch, and your own home now appears on your own world map.

## Open questions

None.

## Next

001, 003–007, 009–016: Signed off by Kris 2026-08-18 (blanket approval of everything then in review). 008 (arrival collision) stays in backlog until player collision exists.

## tasks/ (16)

### world-map-and-portals/001 — Spec the world graph — areas, portals, per-area rules, and the public/private split
```yaml
id: world-map-and-portals/001
title: Spec the world graph — areas, portals, per-area rules, and the public/private split
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-12
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/docs/design/ClaudeDesignWorldMapBrief/HANDOFF World Map 1 - Data, Portals, Classes.md
```

## What

Write the specification for the world at the scale of areas — the layer above ground and
below "the game". Output is a design document in `land-of-lor/docs/` (agent-authored, so
it goes in the shared part of the tree, not the human-owned `docs/thought-specs/`), not
code. Kris's intent is that this spec then feeds a full implementation plan.

Read `epics/world-map-and-portals/overview.md` first — it carries the source material
from Kris's 2026-08-12 voice brief and, importantly, the list of primitives that already
exist in `land-of-lor/game/director/` so this doesn't get re-invented.

## What the spec must settle

1. **The name.** Kris is calling them *rooms* provisionally and explicitly asked what they
   should be called. Areas? Realms? Rooms? Pick one, argue it, use it consistently
   everywhere afterwards.
2. **What an area is as a designed object** — beyond "a `worlds` row". Name, description,
   who owns it, whether it's public or private, and its rule set.
3. **Per-area rules as a first-class concept.** Kris's example: a battle arena won't let
   you bring your whole inventory in. Define what dimensions a rule set can constrain
   (inventory, resources, PvP, building, persistence) and how an area declares them.
   This is the concept that later makes offline play a rule set rather than a fork.
4. **Portals as authored objects.** They exist in the protocol today
   (`PortalConfigSchema`, `SetWorldPortalsRequest`, `portal_transfer_*`) as test-scoped
   configuration applied at next spawn. Specify how a portal becomes a designed, named,
   durable part of the world instead — including whether portals are one-way or paired,
   and what a player sees and does at one.
5. **The public/private split.** The public commons and its onward areas (village of Lor →
   beginner battle arena → the Badlands, per Kris's sketch) versus each player's home area
   and portals to friends' realms. `world_members` is already the whitelist mechanism.
   Settle what a player can *see* of a realm they can't enter.
6. **Servers.** There will be more than one. Define what a server is to a player, what is
   and isn't shared across servers (accounts? collections? friends?), and therefore what
   the site's server dropdown is actually selecting.
7. **Authored vs. derived.** State plainly whether the canonical map is hand-authored
   content or a live projection of `worlds` + portal rows. Both surfaces in task 002
   depend on this answer, so it cannot be left open.
8. **Offline play — as a constraint, not a feature.** Do not design offline play here.
   Do state, for each decision above, whether it forecloses offline play, and reject any
   design that does. Kris's position, verbatim in spirit: probably not possible in the
   current build, wanted in the final version, and announced now precisely so nothing is
   built that rules it out.

## Definition of done

A document that answers 1–8 with decisions (not option lists), each with a short reason,
and that an implementation plan can be written directly from. Where a decision genuinely
belongs to Kris, state a recommendation and mark it — don't leave a bare question.

## Input Kris still owes

**The paper sketch of the Lor map.** Kris drew it on 2026-08-12; it is not in the repo.
The initial public topology should come from that sketch, not from invention. If it still
isn't available when this task is picked up, spec everything else and leave the concrete
topology as a clearly-marked placeholder rather than guessing at Kris's world.

## Assumptions

- The output belongs in `land-of-lor/docs/` as agent-authored design material. If Kris
  wants it in the workspace instead, it moves — the location isn't load-bearing.
- Spec-only, no code, matching how `world-traversal-and-ground/001` was scoped.


---

## Answered, 2026-08-15

The spec exists, in two pieces Kris authored and commissioned rather than an agent
inventing: `land-of-lor/docs/specs/CORE_ENTITIES.md` (Kris, 2026-08-15) and the three
Claude Design handoffs in `land-of-lor/docs/design/ClaudeDesignWorldMapBrief/`. Between
them the eight questions are settled, and the implementation has landed against them
(tasks 003–007). The decisions, for the record:

1. **The name is Area.** Not "room", not "realm" — used consistently in the DB model, the
   loader, both map surfaces and the editor. The `worlds` table keeps its name because
   "worldId" is the wire protocol, the GDScript and every CLI harness; a table-only rename
   would split the vocabulary in two. `land-of-lor/game/director/src/areas.ts` is where the
   new vocabulary lives.
2. **An Area as a designed object:** slug, name, type (`shared` / `home` / `battle_arena`,
   from a lookup table so a new type is one insert), access (`public` / `restricted` /
   `private`), owner, grid size, map position, and an optional parent for a nested room.
3. **Per-area rules are first-class** — a row per rule in `area_rules` with a JSON `params`
   column, never a text blob, so giving a rule parameters later isn't a migration of meaning.
4. **Portals are authored objects** in their own table. One row is one portal even when it
   goes both ways; the return leg occupies the destination cell. One-way is a flag, not a
   second row. `to_special = 'home_area'` resolves per player at travel time.
5. **Public/private:** public areas auto-member whoever walks in (this was the change that
   made authored travel possible at all); private and restricted still gate on
   `world_members`. A realm you can't enter is **fogged, not hidden** — the map returns its
   position and nothing else, because hiding a ball its neighbour can see makes the graph lie.
6. **A server** is a `servers` row; there is one, **Spellgrove**, and the picker reads it
   from data rather than hardcoding it.
7. **Authored, not derived.** The canonical world is
   `land-of-lor/game/content/spellgrove.world.json`, edited by the local editor, reviewed in
   git, applied to a database by `npm run load-world`. Both map surfaces then read the
   database through one public endpoint.
8. **Offline is not foreclosed.** Nothing above requires the network to *describe* the
   world: the authored world is a file, area rules are data, and the map surfaces are
   readers. What is not offline-ready remains what always was — the live shared areas.

**Dropped:** `Portal.distance` and `Portal.direction`. They existed only to hint map
layout, and the editor now authors positions directly (`Area.map_x` / `map_y`). The one
survivor of that family is nesting (`parent_area_id`), because "the Market Stalls are a room
off the Village" says something true about the world rather than about drawing.

**No longer blocked on the paper sketch.** Kris's 2026-08-12 sketch was never photographed
into the repo; the design's nine-area world supersedes it as the starting topology.

### world-map-and-portals/003 — Areas and Portals in the database, and travel between them unblocked
```yaml
id: world-map-and-portals/003
title: Areas and Portals in the database, and travel between them unblocked
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-15
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/src/schema.sql
```

## What landed

The Area model and the portal graph, in Postgres, plus the change that made authored travel
possible at all.

- **`ensureAccessAllowed`** (`director/src/areas.ts`) — membership **or** public, with a
  public Area auto-membering whoever walks in. Before this, portal-transfer step 3 rejected
  every destination the account wasn't already a member of, and the commons was the only
  world anyone was ever a member of: **a seeded, authored world was unwalkable.** Private and
  restricted areas are unchanged, and the test asserting a private destination is refused
  still passes.
- **Area columns on `worlds`** — slug (partial-unique, NULL for player worlds), area_type,
  access, description, grid width/height/layers, map_x/map_y/map_pinned, parent_area_id,
  server_id. Defaults chosen so every pre-existing row describes itself truthfully with no
  backfill: 10x10x1 is exactly what `WorldGrid.build_test_map()` builds.
- **New tables** — `servers` (one row: Spellgrove), `area_types` (a lookup table, not a DB
  enum, so a new type is one insert), `area_rules`, `portal_criteria`, `area_access_entries`,
  and `portals`.
- **`portals`** — one row per portal, with the return leg of a two-way portal occupying the
  destination cell rather than getting a row of its own. `getPortalsForArea()` expands that
  into the exact `PortalConfig` shape the world process already consumed, so
  `_apply_portals()` and every pre-existing test cannot tell the difference.
- **Home areas at account creation**, with an idempotent backfill in `migrate()`, so a
  `to_special = 'home_area'` portal can never fail mid-step.

## Why the `worlds` table wasn't renamed to `areas`

> **Closed 2026-08-16.** The rename this section defers happened, as
> `world-map-and-portals/015` — protocol, database and GDScript in one cutover,
> which is exactly the condition this reasoning set for doing it. The section
> stays as written: it is why the wait was right, not a mistake to erase.

"worldId" is the wire protocol, the GDScript, the CLI harnesses and every handoff doc.
Renaming the table alone leaves the vocabulary split in two, which is worse than either
consistent state. New code reads in Area terms via `src/areas.ts`; a full rename (protocol,
DB and GDScript together) is its own deliberate change, the way `migrate-v2` was.

## Verification

`npm test` in `game/director` — 139 passing, including `portals-table.test.ts` (bidirectional
expansion, one-way, cascade, cell collisions) and `portal-authored.test.ts`, where a stranger
joins a public area they have never been a member of, walks into a table-authored portal, and
arrives at the authored destination cell.

### world-map-and-portals/004 — Portals you can see and walk through, and a 3D object at every one
```yaml
id: world-map-and-portals/004
title: Portals you can see and walk through, and a 3D object at every one
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-15
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/client/portal_node.gd
```

## What landed

Portals were invisible: the client built one hardcoded 10x10 grid for every world and never
rebuilt it, and knew about portal cells only through a `--portal-cell=` test argument.

- **`hello_ack` carries the Area** — dimensions, map name and portal cells. On `hello_ack`
  rather than the director's `world_state` because a portal arrival reconnects straight to the
  destination game port and never asks the director again; anything sent only in `world_state`
  would leave portal arrivals rendering nothing.
- **Destinations are never sent.** Travel is entirely server-driven, so a client that knew
  where a portal led could only use it to lie.
- **`WorldGrid.build_for_area()`** — a real bug found during the build: an authored 40x40
  Village kept the 10x10 test grid, so `set_portal()`'s bounds check *silently dropped* its
  portal at (39,20). A 10x10x1 request still returns the deterministic test map, walls and all,
  so nothing existing changed.
- **`WorldGrid.from_area_payload()`** — the payload-to-grid conversion, static and testable
  headlessly, which is the only way the client-side work gets a real automated test.
- **The client rebuilds on arrival** — `_build_3d_stage()` (environment, sun, camera, entities)
  is built once; `_rebuild_area_geometry()` (ground, walls, portals) is freed and recreated
  under its own `AreaRoot`, guarded so a `PORTAL_REJECTED` re-hello doesn't tear the scene down
  for nothing.
- **`portal_node.gd`** — the placeholder object: an upright gold ring that turns, a sky-blue
  veil that pulses, motes drifting up. Procedural, in the idiom `humanoid_node.gd` established
  (this project has no 3D assets, no Tween, no AnimationPlayer). Meshes are named `portal_ring`
  / `portal_veil` / `portal_motes` so a real per-type model is a straight substitution.

## The guard that wasn't needed

The plan called for a re-entry guard against a two-way portal looping on arrival. Tracing it
showed the transfer fires only when a *move resolves* onto a portal cell, and arriving via
`hydrate_entity` is not a move — so the loop cannot happen. Rather than add speculative code,
`portal-authored.test.ts` now pins that behaviour: a traveller lands on the return-leg cell,
sits there without bouncing, then steps off and back on to prove the return leg works.

### world-map-and-portals/009 — Homes connect to the world, and the game gets a way out of itself
```yaml
id: world-map-and-portals/009
title: Homes connect to the world, and the game gets a way out of itself
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/test/home-portal.test.ts
```

## What prompted it

Kris played the deployed build and found four things (2026-08-16).

### 1. A home was a sealed box

Account creation made a home Area with no portal out of it. A player who reached their home
could never leave. Fixed: `ensureHomeArea` now creates the door in the same breath as the
home — from the home's bottom-middle cell into the hub Area — and a boot backfill opens
homes that predate it (it ran in production and reported `opened a way out of 1 home area(s)`).

### 2. `home_area` portals never worked

The Village's `village-to-home` portal was **drawn on the map and inert underfoot**. It
failed at three layers: `getPortalsForArea` filtered out every row with a null destination,
so the cell never reached the world process's grid; and the director had no resolution branch
at all. Now the row reaches the grid carrying a `destSpecial`, and the director resolves it
per player, at travel time, to that player's own home.

**A sentinel `destWorldId: "home_area"` was considered and rejected.** `getWorldById` queries
a UUID column, so a non-UUID string doesn't return null — it throws (Postgres 22P02), which
escapes into a fire-and-forget catch. Neither `confirmed` nor `rejected` would ever be sent,
and the player — already erased from the source world by step 1 of the sequencing — would
hang forever, with the request re-sent on every control reconnect. Hence a real protocol
field.

### Why the home portal is one-way

Kris asked for bidirectional, and the *experience* is: out through your own door, back
through the Village's home portal. The implementation cannot be a bidirectional row, and
this is correctness, not preference:

> A two-way portal's return leg lives at the **destination** cell. Every player's home portal
> points at the same Village cell, so N players means N return legs stacked on one cell, and
> `grid.set_portal()` keeps only the last. Stepping there would drop you into a stranger's
> home.

`test/home-portal.test.ts` is the proof: two travellers walk the same Village cell and each
arrives in their own home.

### 3. The Village must not be deletable

It carries `is_home_hub` (one per server, enforced by a partial unique index) and an authored
`entry_x`/`entry_y`. The editor shows a hub badge and replaces the delete button with an
explanation; the loader refuses a file with no hub, a hub that isn't public, an out-of-bounds
entry cell, or an entry cell sitting on a portal.

### 4. The in-game map was unreachable, and there was no way out of the game

The map screen existed but hung off the account screen, which is only reachable from the
title — so an in-game player could not get to it at all. The top-right "Signed in as …" was
a click-through Label. It is now a profile pill opening a menu: the player's name, **World
Map**, **Account**, and **Main Menu** (Kris's wording) to leave the world.

**And nothing was visible anyway:** the deployed web build was `02456a9` from 2026-08-13,
**39 commits behind**, predating the world map entirely. Re-exported and redeployed.

## Also fixed along the way

- **The public map fogged too much.** The design says fog private areas *a public portal
  points at*; the endpoint fogged every private area unconditionally, publishing exact map
  coordinates for places nothing visible connects to. Now an area is fogged only if a visible
  portal touches it, and omitted otherwise.
- **Portal slugs leaked names.** A portal's authored slug was used as its public id, so
  `deep-cut-to-beginner-arena` spelled out the name of a place the caller couldn't see. Edges
  touching a fogged area now get an opaque id. Caught by a test asserting no private name
  appears anywhere in the body.
- **The three demo homes are gone from the seed** (Kris's call). They were mock data from the
  design; real player homes have no slug and never appear on the map.
- **Production wiped and re-seeded** — 85 accounts and 120 worlds removed, backed up first to
  `/backups/lor_game-20260815T213814Z-pre-wipe.sql.gz`.

## Deliberately not done

Players stacking on the arrival cell — `world-map-and-portals/008`, which waits on player
collision existing at all.

### world-map-and-portals/010 — What playing it actually found — starting at home, closed doors, a keyboard on iPad, and the builds nobody had shipped
```yaml
id: world-map-and-portals/010
title: What playing it actually found — starting at home, closed doors, a keyboard on iPad, and the builds nobody had shipped
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/client/ui/theme/web_keyboard.gd
```

## What prompted it

Kris played the deployed build on a phone, an iPad and a laptop on 2026-08-16 and found
six things. Every one of them was real.

## 1. Strangers standing in the world

The live Village contained `MapProbe`, `Wanderer-6137`, `E2E-Verifier`, `Sir K` and a second
`MapProbe`, clustered on the spawn cell. **They were the verification scripts' own
characters** — the live verifiers create a guest, join, walk, and their entity persists in
the world snapshot. Cleared, and noted as a recurring hazard: any live verify run leaves a
character behind until someone empties `state->'entities'` for the authored areas.

## 2. A new player started in the commons, among those strangers

The client joined the public world by default. Now there is a `join_home_world` RPC and new
adventurers **start in their own home**, walking out of the front door into the Village.
A better first minute, and the first thing a player sees is a place that is theirs.

## 3. A door that teleported you to the corner of the map

The Village's portal at (20,0) leads to the Wizard Battle Pit, which is `restricted`. Walking
into it refused the transfer — and because the transfer erases your entity from the source
world *before* the director answers (that is what makes crash-safety hold), a refusal left
the player with no entity at all. The client's recovery re-said hello and the world built a
**brand-new entity at the default spawn**: the top-left corner, silently, with no message.

Now a refusal **restores the actual entity** — same id, same character — at the cell the
player stepped in from, and the HUD says *"That way is closed to you — for now."* The
`portal.test.ts` case that documented this as a known gap has been rewritten to assert the
fix.

## 4. Hidden portals weren't hidden in the game

`hidden_unless_passable` filtered the world *map* but not the world. The in-game grid is
hydrated per area, shared by everyone in it, so it can't answer a per-player question. The
director now works out at join time which doors this account may not see and sends the list
**with the join grant** — one shared grid, drawn differently per player. Tested with a
stranger and a member standing in the same area seeing different doors.

## 5. No keyboard on iPad

Godot's web export draws to a canvas, so there is no focused DOM element for the browser to
raise a keyboard for, and `DisplayServer.virtual_keyboard_show()` is a no-op on web. Every
text field in the game was dead on a touch device — signup included, which is a hard stop.

Fixed with a real DOM `<input>` floated over the canvas (`game/shell/text-input.js`), hooked
into every `LineEdit` at the factory (`theme/lor_forms.gd`) via
`theme/web_keyboard.gd`. Touch devices only, so laptop web is untouched. iOS only opens the
keyboard for a genuine gesture on a real input, so the focus call is synchronous inside the
tap that focused the Godot field — anything deferred and Safari refuses.

**Not verified on a real iPad from this session** — the plumbing is tested and deployed;
the device check is Kris's.

## 6. The Android build was never deployed

Correct. The world-map work went out to the web and the server, and the APK was left at the
2026-08-13 build — so the phone showed no map, no profile menu and no portals. Built and
uploaded twice today under the line-in-the-sand rule: `APK_NAME` v1 → v2 → **v3**, with
`play.astro`'s download link following each time.

## Verified

Director 150 tests, Godot 116. On production: a new account lands in its own 16×16 home with
one door at the bottom middle and nobody else inside; the public map is unchanged; both
builds are live.

### world-map-and-portals/012 — The door that went nowhere — a portal frame lost to the disconnect behind it, the test map that flashed on every launch, and your own home missing from your own map
```yaml
id: world-map-and-portals/012
title: The door that went nowhere — a portal frame lost to the disconnect behind it, the test map that flashed on every launch, and your own home missing from your own map
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/cli-test-client/verify-home-door.ts
```

## What prompted it

Kris played v4 on the phone on 2026-08-16, with the readable Area name and the world map's
"You are here" marker newly in place — and those two things are exactly what made the real
bug legible. Five screenshots and a recorded transcript. Three findings, all real.

## 1. The door out of your own home went nowhere

Step into the portal at the bottom of your home and you reappear in the **top-left corner of
your own home**. Step in again and nothing happens at all.

**The server was never wrong.** A TypeScript client walks home → Village against the live
droplet and gets `portal_result` with the right destination every time. The droplet's own
logs show the give-away: across the whole session only the *home* world process was ever
spawned. No Village process, so no transfer ever completed.

What breaks is one frame. `world_server.gd` sent `portal_result` and called
`disconnect_peer()` **in the same tick**, and this Godot `WebSocketPeer` reproducibly only
ever surfaces the close — never the data frame behind it. The client then took its "the
connection died" branch, whose recovery is a fresh `join_world` against the world it was in.
But transfer step 1 had already erased its entity from that world, so the world built a
brand-new one at the default spawn, `(1,1)`. The top-left corner. And the second attempt was
a *different, fresh* entity standing on a door cell it had never stepped **onto** — portals
fire on the transition, not on standing there.

Reproduced exactly with the real Godot client, headless, against production before anything
was touched. That reproduction is the whole reason this was a one-sitting fix rather than a
week of guessing.

`client_main.gd`'s own comment on `_on_game_disconnected()` had diagnosed this race in
detail and concluded it was unfixable **from the read side**. It was right. The fix belongs
on the write side, which is where nobody had looked:

- **`world_server.gd` defers the hang-up.** A departing peer goes into `departing_peers` with
  a 1.5s deadline instead of being cut off mid-frame. The entity bindings still drop
  immediately, so a later `move_intent` still answers `NOT_HELLO` exactly as before. A
  well-behaved client closes the socket itself the moment it applies `portal_result`, so the
  timer almost never fires — it only stops a client that ignored us from lingering.
- **The client's portal fallback arms from the live grid.** It armed from the `--portal-cell`
  CLI list, which only a test scenario ever has — so the one mitigation that existed
  *could not fire in the shipped game*. It now arms from `local_grid`'s own portal cells,
  which arrive with the Area precisely so they can be drawn.
- **And it now has somewhere correct to fall back to.** New `resume_portal` RPC: the client
  is deliberately never told where a portal leads, so it asks the director — which already
  remembers every completed transfer — and arrives exactly as `portal_result` would have
  landed it. It answers only the account whose entity it was.

## 2. The ancient test map flashed up on every single launch

A small green grid with one grey block, for about half a second, every time the game started.
It is `grid.gd`'s `build_test_map()` — 10×10, four blocked cells at y=5 — the fixture both
sides hardcoded back in the multiplayer POC. `client_main` builds it in `_ready()` so
movement code always has *a* grid, which is fine; the bug was that it also **drew** it, in
the gap before `hello_ack` named the real Area. Nothing is drawn now until the server has
named an Area.

Worth recording while it was asked: blocked cells today are one int per (x, y, layer) in a
flat `PackedInt32Array`, rendered as grey boxes. **Authored areas have none** —
`build_for_area()` makes an open field of the right dimensions, because ground types aren't
designed yet (`world-traversal-and-ground`). The test map is the only thing in the codebase
that has ever had a wall in it.

## 3. Your own home wasn't on your own map

Not fogged — absent. Homes have no slug, and the map query was `WHERE slug IS NOT NULL`.
That is exactly what keeps every *other* player's home off the map, and it stays. But the
person standing in one has to be able to find it, and with the new "You are here" marker
there was no ball for it to sit in.

The map now carries **the caller's own home, and only theirs**: a fixed opaque id
`your-home` (never a database id), named, `ownerLabel: "yours"`, positioned beside the hub it
opens onto, with its door drawn. The two "Locked realm" balls Kris asked about are Deep Cut
and the Wizard Battle Pit — both `restricted`, neither a home.

## On Kris's expectation

Right about the first hop: the home door leads to the Village of Lor. The Badlands is one
further — Village → Beginner Arena → Badlands. There is no direct Village→Badlands portal in
the authored world.

## Verified

New `npm run verify-home-door`, **10/10 against the live droplet**: a real client out of a
real home, into the Village, arriving as the same character on the hub entry cell, plus the
map it sees on arrival and what a stranger sees. `verify-accounts-e2e` 32/32,
`verify-world-map` 10/10 — no regression. Director 153 tests, Godot 116. And the actual
Godot client, headless against the droplet, landing in `Village of Lor` 40×40 at (20,38).

Shipped as **APK v5** (installed to Kris's phone over `adb`) and a re-exported web build —
`/play` had been serving `fbf8bed`, which predates all of this.

### world-map-and-portals/013 — Meeting yourself in the Badlands — one account, one body, per world
```yaml
id: world-map-and-portals/013
title: Meeting yourself in the Badlands — one account, one body, per world
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: S
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/world_server.gd
```

## What prompted it

Kris, exploring v5 with the portals finally working, walked into The Badlands and found a
second William 3rd standing beside him — same name, same character, both idle.

Not a leftover verifier character. Both rows in that world's snapshot carried **his** account
id.

## What was actually wrong

Entity ids are minted **per world**, and there are two ways to leave an Area:

- **Through a portal** — transfer step 1 erases you from the origin. Clean.
- **By closing the game** — your body stays standing where you left it. Also correct; that is
  the persistent world working as intended.

Come back into that Area later *through a portal* and you arrive carrying the id you hold in
the world you came **from**. `_handle_hydrate_entity` pointed `entities_by_account` at the
arriving body and simply left the old one sitting in `entities` — still broadcast to every
peer, still written into every snapshot, and never looked at again by anything, because the
account index only ever points at one. A body with no way back to its owner.

Kris's own path was exactly this: he had been in the Village, closed the game, then come
back in through his own front door. The Badlands twin was the same shape one hop further out.

## The fix

`_drop_other_entities_for_account()`, enforcing one invariant — **one account, one body, per
world** — at both points where an entity enters a world:

- **on arrival through a portal**: the arriving body is the only one that account gets here,
  and any older one goes with it;
- **on hydrating a snapshot**: last-one-wins, and earlier bodies for the same account are
  dropped rather than loaded. That is what **heals the snapshots already carrying
  duplicates**, on their next spawn, with no migration to write.

Kris's actual duplicate row was also deleted from production directly, so he didn't have to
wait for the Badlands to next spawn.

## Verified

New test walks his exact path — be in the Village, close the game, come back in through your
own front door — and asserts one body in the Village afterwards. **Confirmed it fails without
the fix** (`expected length 1, got 2`), which is the only thing that makes a regression test
worth having. Director 154 tests, Godot 116. `verify-home-door` and `verify-world-map` both
10/10 against the droplet after deploying.

Server-side only — `world_server.gd` runs on the droplet, so no new APK and no web re-export.
v5 on the phone is still current.

### world-map-and-portals/014 — One body everywhere — entity ids move from the world to the account, and the on-screen keyboard finally runs at all
```yaml
id: world-map-and-portals/014
title: One body everywhere — entity ids move from the world to the account, and the on-screen keyboard finally runs at all
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/shell/verify-touch-keyboard.mjs
```

## 1. Entity ids belong to the account

Kris, reading 013's write-up: *"entity ids absolutely should not be per-world, obviously they
should be per account."* Right — and it is the root cause one level below 013. A stale body
could only exist because the same player wore a **different id in every Area**.

`accounts.entity_id` is minted once, at account creation, and travels to the world process
with the join grant beside `display_name` and `character_seed`. The world uses it verbatim and
only mints one when it wasn't given one (a standalone or load-test run with no director).

- **Migration is one UPDATE on boot** for accounts predating the column, `gen_random_uuid()`
  per row so 80 accounts can't collide with each other. 23 backfilled on production; 26
  accounts, 26 distinct ids, none missing.
- **There is no entity data to convert.** A body already standing in a world under an older
  per-world id is **renamed on first contact** — same cell, same everything — and any other
  body for that account is dropped. A player keeps the spot they left off.
- **Not the account id itself**, deliberately: every peer in an Area sees every other body's
  `entityId` in `world_update`, and an account id is not theirs to see. It stays the same
  short opaque token it always was.

Two portal tests asserted *"a fresh entity gets a new, different random id"* — that assertion
**was** the per-world assumption. They now assert the id is the same, and that what proves the
entity left is its **position**: a fresh body on the spawn cell rather than the transferred one
where it walked to.

## 2. The on-screen keyboard had never worked, anywhere

Kris asked for a way to enter a name in Chrome on an iPad. There already was one, shipped that
morning and marked *"not verified on a real iPad"*. It had never worked on any device in any
browser. **Three** separate faults, each fatal on its own, and none of them findable by
reading:

1. **The feature was never on.** The availability probe tested `probe is bool`, and
   `JavaScriptBridge` hands a JS boolean back as an **int** (`probe=1`, `TYPE_INT`).
   `available()` was false everywhere, so `attach()` returned immediately and no field was ever
   hooked up. This is why no amount of reasoning about iOS gesture policy explained the iPad:
   nothing was running to be refused.
2. **`focus()` was always a frame late.** WebKit — every browser on iPadOS, Chrome included —
   opens the keyboard only for a `focus()` inside the gesture's own DOM handler. The old design
   focused from Godot's `focus_entered`, on the *stated assumption* that the signal fires inside
   the tap handler. It does not: Godot buffers input and flushes it at the top of the next
   frame. The engine cannot win this, so it stopped trying — it publishes where its fields are
   (as fractions of the viewport) and the browser hit-tests `touchstart` itself.
3. **The engine took the focus back.** Godot's web export calls `canvas.focus()` from its own
   touch listener, and focuses a hidden IME div whenever a `LineEdit` takes GUI focus — either
   one blurs the input and shuts the keyboard a frame after it opened. `preventDefault` does not
   stop propagation, so a tap landing on a field is now swallowed outright, gesture and all.

The consequence is the shape of the whole thing: **the DOM input IS the input**, and the
LineEdit mirrors it through one callback registered once. Nothing gives the field GUI focus.

**`shell/verify-touch-keyboard.mjs`** is the new check, and the reason this one is trustworthy:
it drives the real exported build in headless Chrome over CDP with touch emulation, taps
through to the signup field, and asserts the input is focused **inside** the touch event, that
the bar survives a second (the canvas-steals-focus failure), and that typing reaches the engine.
**Every one of the three faults was caught by it, not by reading the code.** 8/8.

What it still cannot check is WebKit's own policy — no iPad here — but nothing in the design
now depends on a browser being lenient about it, which is exactly what the old one did.

## Verified

Director 155 tests, Godot 116, `verify-touch-keyboard` 8/8, and on production after deploying:
`verify-home-door` 10/10, `verify-accounts-e2e` 32/32. Shipped as **APK v6** and a web
re-export at `db6a49b`.

Housekeeping: the `DoorProbe` and `MapProbe` characters were cleared from the Village (the
recurring live-verifier hazard first noted in 010) — which also cleared Kris's own bodies in
the Village and The Badlands, so he respawns at the entry cell there. Home, account and
collection untouched.

### world-map-and-portals/015 — "World" means Area — the rename the schema promised, across protocol, database, GDScript and docs
```yaml
id: world-map-and-portals/015
title: "World" means Area — the rename the schema promised, across protocol, database, GDScript and docs
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/src/schema.sql
```

## What prompted it

The word **world** currently means two different things in this codebase, and one of them is wrong.

`land-of-lor/docs/specs/CORE_ENTITIES.md` (Kris, 2026-08-15) already settles the vocabulary:

- **Area** — one explorable place. "The arena in which a game is played… like a room." Its own grid,
  portals, owner, access rules.
- **World** — the whole conceptual set of Areas on one server. "World map" is correct. The authored
  `spellgrove.world.json` is correct. The `/world` page is correct.
- **Server** — one backend instance: its own database and its own running backend code.

The code never followed the spec. The Postgres table that stores Areas is called `worlds`, the wire
protocol says `worldId`, and the documentation inherited the code's word.

**This has been deferred three times, deliberately and in writing** — which is exactly why doing it
now is well-founded rather than churn:

1. `epics/server-architecture/overview.md:53,59` (2026-08-12): "terminology fixed"; *"Renaming code
   and schema to `area` is follow-up work."*
2. `game/director/src/schema.sql`, NAMING block: *"Renaming the table alone would split the vocabulary
   in two, which is worse than either consistent state; **the full rename (protocol + DB + GDScript
   together) is its own deliberate change later**."*
3. `tasks/003-areas-and-portals-in-the-database.md`, section "Why the `worlds` table wasn't renamed".

Those three notes are the reason this is one coordinated cutover and not a series of tidy-ups. **Do
not delete them when this lands** — annotate them as closed. The record of why it waited is worth more
than the tidiness of removing it.

## Scale (measured, not estimated)

`worldId` 448 · `join_world` 106 · `destWorldId` 54 · `world_update` 41 · `world_state` 39 ·
`create_world` 39 · `leave_world` 38 · `FROM worlds` 31 · `to_world_id` 30 · `from_world_id` 22 ·
`world_members` 19. GDScript: `client_main.gd` 135 hits, `world_server.gd` 95, `app.gd` 55,
`test_runner.gd` 35. Docs: 49 markdown files under `epics/`, both CLAUDE.md files, `game/README.md` 37.

## Decisions (settled with Kris, 2026-08-16)

- **Depth: all of it** — docs + code + wire protocol + database, one coordinated change.
- **`game/world/` keeps its name.** Kris: *"this folder contains complex_item.gd, creature.gd,
  grid.gd — it's the whole game in some ways… the world is conceptually all of the stuff in the
  world."* Correct under his own definition, and more right than `game/godot/` (which would name the
  tool, not the domain). The folder is the World; the process inside it serves one Area, so
  `world_server.gd` → `area_server.gd` and `class_name WorldGrid` → `AreaGrid` still follow.
- **Stale builds get an honest error first**, as its own small ship (v7), before the protocol version
  moves. See below.
- **History is kept.** Annotate closed items as closed; never rewrite a signed-off record.
- **`docs/WORLD_ZONES.md` ("Zones") and the `world-traversal-and-ground` epic are left alone.** Kris:
  other terms recurring in the thinking is useful — consistency is only required in the core of the
  system, and specifically where there was a genuine ambiguity, as there was with "world".

## Why the stale-build fix ships FIRST

`game/world/client/account_session.gd:176-179` silently drops any frame it cannot decode
(`if decoded["ok"]:`, no else). `Protocol.decode` returns `ok:false` on a version mismatch — so an
out-of-date client never stores the reply, spins out its 20s timeout, and lands in
`SessionState.OFFLINE`, whose title screen reads **"Can't reach the realm — retrying"**.

An old app would therefore tell Kris the *server* is down when the server is fine and the app is old.
That is the mystery-failure outcome to design away, and it has to land *before* `PROTOCOL_VERSION`
moves — bundling it into the rename would only protect the next cutover, never this one.

**Never bump the protocol version without this having shipped.** That combination is the silent hang.

## The rename

| Layer | From | To |
|---|---|---|
| DB tables | `worlds`, `world_members` | `areas`, `area_members` |
| DB columns | `world_id`, `from_world_id`, `to_world_id` | `area_id`, `from_area_id`, `to_area_id` |
| DB indexes | `worlds_*`, `world_members_*`, `portals_to_world_idx` | `areas_*`, `area_members_*`, `portals_to_area_idx` |
| Wire, client↔director | `create_world`, `join_world`, `join_public_world`, `join_home_world`, `leave_world`, `world_state`, `world_created`, `set_world_note`, `set_world_portals` | `…_area` / `area_state` / `area_created` |
| Wire, director↔area process | `world_register`, `world_registered`, `world_snapshot`, `world_snapshot_ack`, `world_shutdown` | `area_register`, `area_registered`, `area_snapshot`, `area_snapshot_ack`, `area_shutdown` |
| Wire, client↔area process | `world_update`; `hello_ack.area.worldId` | `area_update`; `areaId` |
| Every payload field | `worldId`, `destWorldId` | `areaId`, `destAreaId` |
| Protocol version | 2 | **3** |
| TS | `worldInstance.ts`; `worlds.ts` | `areaInstance.ts`; folded into `areas.ts` |
| GDScript | `world_server.gd`/`.tscn`, `class_name WorldGrid` | `area_server.gd`/`.tscn`, `AreaGrid` |
| CLI / URL | `--world-id=`, `?world=` | `--area-id=`, `?area=` |
| Env | `PUBLIC_WORLD_*`, `WORLD_SERVER_DIR` | `PUBLIC_AREA_*`, `AREA_SERVER_DIR` (**reading the old names as a fallback**, so the deploy doesn't need `/etc/lor/game.env` edited in the same breath) |

### Must NOT be renamed — "world" is correct

`worldContent.ts` / `WorldContentSchema` / `loadWorldContent`, `spellgrove.world.json`,
`getPublicWorldMap` / `/world-map.json` / `verify-world-map`, `tools/world-editor`,
`site/src/pages/world.astro`, the Godot `world_map_screen.gd` / `WorldMapScreen` /
`world_map_requested` / "World Map" menu label, `WorldEnvironment`, and `world_root` /
`_build_3d_world` (the persistent 3D scene root that outlives any single Area).

**Two specific landmines:** the fog salt `lor-world-map:` in `publicMap.ts:77` — changing it rotates
every opaque `fog-…` id the public site already renders. And the epic *folder* names, which are
cross-links across 49 files.

## Database migration

Guarded in-place `ALTER … RENAME`, at the **very top** of `schema.sql`. Not new-tables-and-backfill
(six FK columns point at this table — enormous risk for 120 rows), and not compatibility views (they
leave both vocabularies live forever, which is the exact split the schema comment calls worse than
either endpoint).

Safe because: `RENAME TO` is catalogue-only (O(1), no rewrite); **foreign keys follow the table OID,
not the name**, so `area_rules.area_id REFERENCES worlds(id)` becomes `REFERENCES areas(id)` with zero
statements; and `db.ts:migrate()` sends the whole file as one multi-statement query, which Postgres
wraps in an implicit transaction — no half-renamed state is reachable.

**Two traps, both of which must carry loud comments:**

1. The rename block must precede `CREATE TABLE IF NOT EXISTS areas`. Otherwise boot #1 creates an
   empty `areas` beside the populated `worlds`, the guard sees `areas` already exists, skips the
   rename, and the director serves an empty world to 26 accounts.
2. **Indexes do not follow a table rename** — they keep their own names. Without explicit
   `ALTER INDEX … RENAME`, the `CREATE INDEX IF NOT EXISTS areas_*` statements below would create
   duplicates alongside the surviving `worlds_*`.

Guards are "old exists AND new does not", so a re-boot and a fresh database are both no-ops.
Auto-generated constraint names (`world_members_pkey`, `worlds_owner_id_fkey`) do not follow the
rename and cannot collide with anything — left deliberately, noted here so the next `\d areas` reader
isn't surprised.

**The highest-value artefact in the whole job** is a new `director/test/rename-migration.test.ts`:
apply the *pre-rename* schema to a fresh embedded Postgres, insert an account + two Areas + a member +
a bidirectional portal, run `migrate()`, then assert the rows survived, the FKs still cascade, the new
index names exist and the old ones are gone, and that a second and third `migrate()` are clean no-ops.
`test/schema.test.ts` cannot catch any of this — it only ever exercises the fresh-database path.

## Done, 2026-08-16

Shipped as **APK v8** and web at `b66abfb`; director deployed and migrated.

**The database.** 37 Areas, 74 memberships, 39 portals, 31 accounts — every row
through, old table gone, no stray indexes, 60ms. Rehearsed first against a
restored copy of production on the droplet itself, with the live service still
running the old code, and only then restarted. The legacy `state.portals` JSONB
step turned out to be dead code: recon found zero rows carrying it, so it was
never written.

**Two traps the codemod set for itself**, both caught by tests rather than by
reading the diff:

1. It modernised the pre-rename `INSERT`s *and* the `to_regclass` assertions
   inside `area-rename-migration.test.ts` — the one file whose old names are
   load-bearing. The test went green while asserting nothing.
2. The hyphenated CLI key `world-id` slipped through a `\bworld_id\b` word
   boundary, so the director sent `--area-id=` while GDScript still read
   `world-id`, and every Area process booted with an empty id and was refused
   registration.

**One thing found along the way:** the stage CLI harnesses (`test-client-godot`,
`-3`, `-stage4`) do not pass and have not since accounts v2 in August — they
send a pre-grant `hello {accountId}` that the Area server has rejected ever
since. Confirmed broken at the pre-rename commit too. They are stale scaffolding
and want either updating to send a join grant or deleting; `area-server.test.ts`
covers the same wire through real Godot processes and does pass.

## Progress

- **WP1 done and deployed, 2026-08-16** — APK **v7** + web at `d54698f`, director deployed. A stale
  client (`VERSION = 99`) run against the live droplet now gets its refusal back stamped at v99 with
  its own request id, resolves immediately instead of timing out, and raises "Update needed" with the
  download URL. `test/stale-client.test.ts` (4 tests) pins it; all three assertions confirmed failing
  without the fix. Director 161 tests, Godot 116, live verifiers 10/10 and 32/32.

  One thing only running it caught: once the director echoes at the *client's* version the client
  decodes the refusal perfectly well, so it arrives as an ordinary error payload and never reaches the
  undecodable branch that set the flag — the title screen stayed silent in exactly the case the change
  exists for. Both paths raise it now.

## Order of work

1. ~~**Honest failure for stale builds** — ships as APK v7 + web, on its own.~~ **Done.**
2. **Recon** (read-only against production): row counts, confirm no `areas`/`area_members` already
   exist, and check whether any `state->'portals'` blob still carries `destWorldId` (if none, the
   JSONB data step is dead code and can be skipped).
3. **Schema + migration**, 4. **director TypeScript**, 5. **GDScript** — three commits, **one push**.
   The repo does not build between them; nothing between 3 and 5 may be deployed.
6. Fold `worlds.ts` into `areas.ts`; rewrite the schema NAMING block to say the rename happened.
7. Docs, per the "keep history" rule above. Add a **Terminology** section to `land-of-lor/CLAUDE.md`
   (Area / World / Server) citing CORE_ENTITIES.md — there is no glossary anywhere today, and
   `docs/context-architecture.md:46` points at one in CLAUDE.md that no longer exists.
8. Deploy: back up **and copy the dump off the droplet**; restore it locally and run the new
   `migrate()` against Kris's real data before touching production; build BOTH clients from the exact
   commit before deploying the director; APK v8.

## Method

Codemod over an explicit identifier list, longest-token-first (`destWorldId` before `worldId`,
`join_public_world` before `join_world`), on an explicitly constrained file set — never a blanket
`s/world/area/`, never a repo-root `grep -r`. Excluded: `node_modules`, `.godot`, `director/dist`,
`web-build`, `android-build`, `ios-build`, `shell/engine`, `lor/public/`, `site/dist`.

A **denylist pass after the codemod and before committing** catches a codemod that has eaten a
legitimate World: any hit for `AreaContent`, `/area-map.json`, `AreaMapScreen`, `AreaEnvironment`,
`area-editor`, `lor-area-map:`, `spellgrove.area.json`, `getPublicAreaMap` is a bug.

Prose is hand-edited, and it is most of the work: `world_server.gd` alone has ~40 prose uses, some of
which ("world A"/"world B" as transfer endpoints) become Area and some of which ("the world is over
for this client") are idiom.

## Verification

Migration test first, alone. Then `npm run build`, 155 vitest, 116 Godot, and — most importantly for a
wire rename — the three harnesses that spawn real Godot processes: `test-client-godot`,
`test-client-3` (portal transfer between two Area processes), `test-client-stage4`. Then `migrate()`
plus `load-world` against a restored production dump, which also proves `worldContent.ts` was *not*
renamed. Live: `verify-accounts-e2e` 32/32, `verify-world-map` 10/10, `verify-home-door` 10/10,
`verify-touch-keyboard` 8/8, `SELECT to_regclass('public.worlds')` → NULL.

## Rollback

Forward-only under old code: reverting the director without reversing the SQL is the **worst**
available outcome, because the old `schema.sql` would recreate an empty `worlds` and serve an empty
game. So the reverse `ALTER … RENAME` block is written before the deploy, and the order is: stop the
service **first**, run the reverse block, then revert the code. Nuclear option is dropdb/createdb and
restore — 26 accounts and ~120 Areas come back in seconds.

### world-map-and-portals/016 — The player who wasn't there — a phone in the background keeps walking against a socket that died in silence
```yaml
id: world-map-and-portals/016
title: The player who wasn't there — a phone in the background keeps walking against a socket that died in silence
epic: world-map-and-portals
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-18
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/test/client-liveness.test.ts
```

## What prompted it

Kris played v10 on the phone as the APK and in the phone's browser, one after the other, on
2026-08-17. Both worked — but the other character never moved in either, and the browser's
character never appeared in the APK at all. Two screenshots: in the browser, "kris phone
web" standing next to "kris 2" by the river; in the APK, "kris 2" alone, standing *in* the
river where Kris had just walked it.

## What it was

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

## What changed

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

## Verified

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

## What only Kris can check

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

### world-map-and-portals/002 — The two map surfaces — a standalone map page on the website and a map screen in the game
```yaml
id: world-map-and-portals/002
title: The two map surfaces — a standalone map page on the website and a map screen in the game
epic: world-map-and-portals
state: done
priority: 2
blocked_by: []
estimate: L
created: 2026-08-12
updated: 2026-08-15
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What

Build the two places a player looks at the map. Both render the *same* world graph — one
source of truth, two surfaces — which is why this task waits on 001 settling whether that
graph is authored content or a live projection.

### Surface 1 — the public site, standalone

A page on lor.kris.ai-task-runner.com showing the public world: the village of Lor and
what it links to, laid out so someone who has never played can see how the world hangs
together. Above it, a **server dropdown** — there will be more than one server, and the
map is per-server.

Constraints that already apply and are not up for renegotiation here:
- Site code is agent-only, human-directed (workspace `CLAUDE.md`, "Who edits what").
- `brand/design.md` (storybook-cover direction, palette, typefaces) and `brand/voice.md`
  are read first, before any pixel or any sentence.
- The site is a thin renderer over markdown built by `npm run build` into `../public`.
  If 001 decides the map is *live* rather than authored, that collides with "no backend,
  no database — if it can't be a markdown file read at build time it's out of scope"
  (`public-site-and-comms` non-goals). Resolving that collision is part of this task:
  either the map is authored content, or the site fetches it client-side from the
  director, or a build step snapshots it. Pick one, and say why.

### Surface 2 — in the game

A map screen reachable from the game interface, showing the same public graph **plus the
player's private layer**: their own home area and portals to their friends' realms. Built
as native Godot Control-node scenes alongside the existing UI in
`land-of-lor/game/world/client/ui/`, per `game-interface/004`'s resolution — one
implementation on web and Android, no DOM shell UI.

`player_state.gd` is the documented seam where per-account data plugs in
("when the server starts owning this data, this is the ONE file that changes"). The
private map layer is exactly that kind of data, so it arrives through that seam and not
by a second mechanism.

## Definition of done

- The public map is live on the site with a working server picker, and looks like it
  belongs to the rest of the site.
- The in-game map screen opens from the interface, shows public and private layers, and
  ships identically on web and Android.
- Both read the same source of truth, whatever 001 decided that is.

## Blocked by

`world-map-and-portals/001` — naming, the authored-vs-derived call, what a server means,
and what the private layer reveals all have to be settled before either surface can be
built without guessing.

## Input Kris still owes

The paper sketch of the Lor map (see 001) — it drives the visual layout of both surfaces.


---

## Superseded, 2026-08-15

Both surfaces were built under **world-map-and-portals/006**, against the approved Claude
Design output rather than the sketch this task was waiting on. Closed here so the work isn't
tracked in two places; 006 carries what actually landed and what was deliberately left.

The collision this task flagged — a live map versus `public-site-and-comms`' "no backend, no
database, if it can't be a markdown file read at build time it's out of scope" — was resolved
the third way it offered: the site stays a static build and fetches a public read-only JSON
endpoint on the director at runtime. Kris's call. It is the site's first runtime fetch, which
is why the page has a real loading state and a real failure state rather than rendering an
empty world when the game server is unreachable.

### world-map-and-portals/005 — The authored world of Spellgrove, its loader, and the public map endpoint
```yaml
id: world-map-and-portals/005
title: The authored world of Spellgrove, its loader, and the public map endpoint
epic: world-map-and-portals
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-15
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/content/spellgrove.world.json
```

## What landed

- **`game/content/spellgrove.world.json`** — nine areas and ten portals, the design's approved
  topology verbatim, including the deliberate hard cases: a one-way into a dead end, a
  `home_area` portal, hidden portals, an area you can see but not enter, and a nested room.
  Areas and portals reference each other by slug and the file carries no database ids, which is
  what lets the same file load into a local database and production independently.
- **`npm run load-world`** — idempotent, keyed on slug, validating the whole graph (every
  endpoint resolves, no two portals share a cell *including* two-way return legs, every cell
  inside its area's grid) before it touches the database. Rows with a NULL slug — players' own
  worlds and homes — are never touched, and authored rows missing from the file survive unless
  `--prune` is passed.
- **`GET /director/world-map.json`** — the director's first unauthenticated read surface. Both
  visibility passes happen server-side: a logged-out caller gets public areas in full, private
  ones **fogged** to a position and an opaque id (no name, no slug, no size, no rules, no
  owner), and hidden portals not at all. 60s in-process memo, ETag, and permissive CORS with a
  `PUBLIC_MAP_ALLOWED_ORIGINS` override.
- **`join_public_world` lands players in the authored Village**, falling back to the old
  name-based find-or-create when the slug isn't present, so an unseeded database still works.

## Verification

`public-map.test.ts` asserts a logged-out response contains none of the private areas' names,
slugs, sizes, rules or database ids anywhere in the body, and that hidden portals are absent.
`loadWorldContent.test.ts` loads the committed file twice and asserts identical row ids.

### world-map-and-portals/006 — The two map surfaces — the public site page and the in-game screen
```yaml
id: world-map-and-portals/006
title: The two map surfaces — the public site page and the in-game screen
epic: world-map-and-portals
state: done
priority: 2
blocked_by: []
estimate: M
created: 2026-08-15
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: site/src/pages/world.astro
```

## What landed

Both draw the same graph from the same endpoint, and both copy the approved design
(`Lor World Map Viewer.dc.html`).

- **The website — `/world`**, linked from the main nav. The site's **first runtime fetch**:
  every other page is build-time markdown, so this needed a visible loading state and an honest
  failure state ("the game server isn't answering" rather than an empty world, which would read
  as "Lor is empty"). Layer filter, pan/zoom, per-area detail panel listing the portals in and
  out with their cells and conditions.
- **In the game** — a native Control overlay in `game/world/client/ui/world_map_screen.gd`,
  opened from the accounts screen, following `account_screen.gd`'s structure exactly. 844x390,
  ball radii at half scale with a 22px floor so no tap target falls under 44px, chrome as
  floating pills, no editing affordances anywhere.
- Shared rules, all data-driven rather than hardcoded: radius from grid size, ring colour from
  the area type, fill from access, position from `map_x`/`map_y` with unplaced areas in a row
  along the bottom, nested areas drawn inside their parent on a dotted hairline, solid lines
  public and pink dashed private, one-way gets an arrowhead, criteria get a midpoint dot, and
  the layer switch **filters** — geometry never moves.
- `_shots.gd` renders the in-game screen from sample data, so the drawing can be checked
  without a running server.

## Deliberately left

Clustering above ~20 areas is specified in Handoff 2. With nine seeded areas there is nothing
to cluster and no way to test it, so the expanded case is built and clustering is a marked
follow-up rather than untested speculative code.

### world-map-and-portals/011 — Seeing where you are — a readable Area name, "You are here" on the world map, pinch zoom, and the identity chip off the title screen
```yaml
id: world-map-and-portals/011
title: Seeing where you are — a readable Area name, "You are here" on the world map, pinch zoom, and the identity chip off the title screen
epic: world-map-and-portals
state: done
priority: 2
blocked_by: []
estimate: S
created: 2026-08-16
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/client/ui/world_map_screen.gd
```

## What prompted it

Kris, playing v3 on the phone: the Area name in the top bar was there but effectively
invisible, and the world map showed the whole world without ever saying which part of it you
were standing in. Small asks — and worth recording that they paid for themselves within the
hour: **012 was only diagnosable because of them.** "It's still William 3rd's Home" is a
sentence you can only say if the screen tells you where you are.

## What landed

- **The Area name is readable.** It was 12px `TAB_INACTIVE` plum-grey over the world's own
  ground — present, unreadable, in the one place it has to be readable. Now 17px Pixelify
  white with a hard black outline and a soft drop shadow.

- **"You are here" on the world map.** A speech bubble over whichever ball you are standing
  in, its point at your approximate spot *inside* the circle — your grid cell as a fraction
  of the Area, capped at 0.62r so the marker never sits on the ring. Tapping the bubble
  collapses it to just that dot; tapping the dot brings it back.

  The ball is matched **by name**. The map speaks slugs, `hello_ack` speaks world ids, and
  neither knows the other — but both carry the same display name, and a fogged area carries
  no name at all, so it can never be matched by accident. That choice is what kept this a
  client-only change with no director deploy.

- **Pinch zoom, in the world and on the map.** In-game it multiplies the camera offset
  (0.55–2.4); on the map it is `screen = world * zoom + pan`, zoomed about the pinch
  midpoint so what is under your fingers stays there (0.4–4.0). Mouse wheel does the same on
  desktop; "Re-centre" resets zoom as well as pan.

  Kris asked for this **only if it was near-zero risk**. It is, because of how it is gated:
  every line of new input handling requires **two fingers down**, so the one-finger path is
  byte-for-byte what it was. That mattered — tap-to-move has broken on Android before.

- **The "Signed in as …" chip stopped appearing on the title screen.** It showed after a cold
  start but not after returning from a world, which is the tell: it was never hidden
  *anywhere*. It just happened to be covered by whatever was added to `ui_layer` last, and on
  boot the chip is built *after* the title. Draw order is not a visibility rule. The top row
  and its Retry button are now explicitly shown only while a world is live.

## Verified

Rendered every screen through `_shots.gd` (both bubble states, and the label over a
grass-green ground). Godot 116 tests. Shipped as APK v4 to Kris's phone, which is where 012
was then found.

### world-map-and-portals/007 — The entity class restructure — ComplexItem, LivingCreature, Player
```yaml
id: world-map-and-portals/007
title: The entity class restructure — ComplexItem, LivingCreature, Player
epic: world-map-and-portals
state: done
priority: 3
blocked_by: []
estimate: M
created: 2026-08-15
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/complex_item.gd
```

## What landed

Kris's structure, adopted with four deviations, all flagged rather than silently taken:

```
ComplexItem            data + serialisation only
  +- LivingCreature    things that move and (later) can be hurt
       +- Player       account, seed, display name
       +- Creature     autonomous, flow-field driven
SimpleItem             a sibling, not a child
```

- **`SimpleItem` is a sibling of `ComplexItem`.** Giving stackable data a tick it never uses
  invites behaviour to leak into it, and "everything is simple or complex" reads as two roots.
- **Inventory belongs to `Player`**, not `LivingCreature`, until a creature carries loot.
- **The base carries no behaviour** — it stays the 68 lines of pure data it always was.
- **Portals did NOT become ComplexItems** (against Handoff 1's suggestion). They are static
  grid metadata sent once at `hello_ack`; making them entities would put them in the 10Hz
  broadcast and every snapshot for no gain. Towers and spawn points are the right first
  non-creature items.

**The wire contract did not change.** `to_dict()` output is byte-identical and `kind` still
says `player` / `creature` — `from_dict` defaults it to `"player"`, so renaming it would
hydrate every persisted creature in production as a player.

`entity_factory.gd` is the one place a wire dictionary becomes a typed object. A spike
confirmed the base-preloads-its-own-subclass cycle *does* work in Godot 4.7.1 headless, so the
separate factory is a design choice — one registration point for a new type — rather than a
workaround.

## Verification

`godot --headless --path game/world --script test_runner.gd` — 116 passing, including
`test_entity_hierarchy()` (round-trips, factory dispatch, a kind-less legacy snapshot, an
unknown future kind falling back to the base, and movement treating players and creatures by
the same rules). The director's 139 tests, which spawn the real Godot world server, are green
throughout.

### world-map-and-portals/008 — Players pile up on the cell they arrive at
```yaml
id: world-map-and-portals/008
title: Players pile up on the cell they arrive at
epic: world-map-and-portals
state: backlog
priority: 3
blocked_by: []
estimate: M
created: 2026-08-16
updated: 2026-08-16
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What

Every player's home portal leads into the same cell of the Village of Lor (its authored
`entry_x`/`entry_y`, currently 20,38), and every `home_area` arrival lands on the same cell
inside that player's own home. Players can currently walk through each other, so they will
simply stack on those cells — two people standing in exactly the same square.

Noted deliberately during `world-map-and-portals/009` rather than solved there, because the
fix depends on something that doesn't exist yet.

## Kris's sketch of the fix (2026-08-16)

> "We will be making it that you can't walk through other players in the future, so the fix
> might be as simple as: you don't actually land on top of a portal but one cell to the side
> of it, AND if that cell is occupied then use the nearest cell that isn't occupied."

That is the shape to build: an arrival resolves to a cell *beside* the entry point, and
walks outward to the nearest free one if it's taken.

## Why it waits

**Player collision doesn't exist.** `movement.gd` checks the grid — walls, soft blocks,
bounds — and nothing checks whether another entity occupies the target cell. Until it does,
"the nearest cell that isn't occupied" has no meaning the server can evaluate, and building
a spreading-arrival rule on top of nothing would be guesswork.

The natural order is: entity occupancy in `movement.gd` first (it already has
`spatial_hash.gd` to make the lookup cheap), then this.

## Definition of done

- Arriving through any portal never places you on the same cell as another entity.
- Arrival is beside the portal, not on it, so you don't immediately re-trigger anything.
- The search for a free cell is deterministic (same inputs, same result) and bounded — a
  full area must not be able to hang a transfer.
- Covered by a test with two players arriving at the same entry cell.

## questions/ (0)

## deliverables (0)
