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