← World map and portals · all tasks
"World" means Area — the rename the schema promised, across protocol, database, GDScript and docs
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:
- 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.jsonis correct. The/worldpage 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:
epics/server-architecture/overview.md:53,59(2026-08-12): “terminology fixed”; “Renaming code and schema toareais follow-up work.”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.”tasks/003-areas-and-portals-in-the-database.md, section “Why theworldstable 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 thangame/godot/(which would name the tool, not the domain). The folder is the World; the process inside it serves one Area, soworld_server.gd→area_server.gdandclass_name WorldGrid→AreaGridstill 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 theworld-traversal-and-groundepic 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:
- The rename block must precede
CREATE TABLE IF NOT EXISTS areas. Otherwise boot #1 creates an emptyareasbeside the populatedworlds, the guard seesareasalready exists, skips the rename, and the director serves an empty world to 26 accounts. - Indexes do not follow a table rename — they keep their own names. Without explicit
ALTER INDEX … RENAME, theCREATE INDEX IF NOT EXISTS areas_*statements below would create duplicates alongside the survivingworlds_*.
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:
- It modernised the pre-rename
INSERTs and theto_regclassassertions insidearea-rename-migration.test.ts— the one file whose old names are load-bearing. The test went green while asserting nothing. - The hyphenated CLI key
world-idslipped through a\bworld_id\bword boundary, so the director sent--area-id=while GDScript still readworld-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
Honest failure for stale builds — ships as APK v7 + web, on its own.Done.- Recon (read-only against production): row counts, confirm no
areas/area_membersalready exist, and check whether anystate->'portals'blob still carriesdestWorldId(if none, the JSONB data step is dead code and can be skipped). - 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.
- Fold
worlds.tsintoareas.ts; rewrite the schema NAMING block to say the rename happened. - 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, anddocs/context-architecture.md:46points at one in CLAUDE.md that no longer exists. - 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.