---
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.