← World map and portals · all tasks

"World" means Area — the rename the schema promised, across protocol, database, GDScript and docs

done priority 1 estimate L

world-map-and-portals/015 · created 2026-08-16 · updated 2026-08-18

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:

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)

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

LayerFromTo
DB tablesworlds, world_membersareas, area_members
DB columnsworld_id, from_world_id, to_world_idarea_id, from_area_id, to_area_id
DB indexesworlds_*, world_members_*, portals_to_world_idxareas_*, area_members_*, portals_to_area_idx
Wire, client↔directorcreate_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 processworld_register, world_registered, world_snapshot, world_snapshot_ack, world_shutdownarea_register, area_registered, area_snapshot, area_snapshot_ack, area_shutdown
Wire, client↔area processworld_update; hello_ack.area.worldIdarea_update; areaId
Every payload fieldworldId, destWorldIdareaId, destAreaId
Protocol version23
TSworldInstance.ts; worlds.tsareaInstance.ts; folded into areas.ts
GDScriptworld_server.gd/.tscn, class_name WorldGridarea_server.gd/.tscn, AreaGrid
CLI / URL--world-id=, ?world=--area-id=, ?area=
EnvPUBLIC_WORLD_*, WORLD_SERVER_DIRPUBLIC_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 INSERTs 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

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.
  4. Fold worlds.ts into areas.ts; rewrite the schema NAMING block to say the rename happened.
  5. 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.
  6. 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.

View source in repository · also available as raw markdown.