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