---
id: ground-types/001
title: The model — a hexagram is a ground type, in every runtime
epic: ground-types
state: done
priority: 1
blocked_by: []
estimate: M
created: 2026-08-17
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/world/ground_types.gd
---

## What to do

Put the hexagram at the centre of the code, the way the spec asks, before any storage or
rendering exists. Read `land-of-lor/docs/specs/GROUND_TYPES.md` first (it is human-owned;
read, don't edit).

**Step 0 — fix the world file.** `game/content/spellgrove.world.json` is invalid today: a
world-editor session on 2026-08-16 (`05fcdd0`) shrank `beginner-arena` to 20×10 and left two
portals out of bounds (`village-to-arena` toCell (0,10), `arena-to-badlands` fromCell (19,19)).
`validateWorldContent` returns two errors and `npm run load-world` refuses the file. Everything
in this epic rides on the loader. Assumption: keep the 20×10 and move both portals to the
bottom edge — (0,9) and (19,9). Say so in the session report.

**Then the model, in three runtimes from one table:**

1. `game/shell/elements.js` — add `ground` to each of the eight `LOR_TRIGRAMS` entries
   (`{ name, kind: "flat"|"sunken"|"mountain", passable, colour, pattern }`; earth = Grass,
   wind = Sand, thunder = Snow/Ice, water = Water (sunken), fire = Lava (sunken), mountain =
   Mountain (impassable), lake = Deep Water (impassable), heaven = Stone/tiles) and
   `groundVariant` (`{ label, colour, pattern }`) to each of the 64 `LOR_ELEMENTS` (the
   Earth-upper one is the plain variant; kw 56, Fire over Mountain, is the volcano). Inside
   the two existing globals, not a third one — `card-editor.html` regenerates this file from
   exactly `LOR_TRIGRAMS` and `LOR_ELEMENTS`. Placeholder colours are fine; task 006 tunes them.
2. `game/sync-elements.sh` also writes `game/director/src/data/elements.json`; the director's
   `build` script copies `src/data` into `dist/` the way it already copies `schema.sql`.
3. New `game/world/ground_types.gd` at the project root (pure `RefCounted`, `preload`-only,
   loads the same `elements.json` via `load()` — no hand-copied tables): `kw_of(upper, lower)`,
   `lower_of(kw)`, `variant_index(kw)`, `is_passable(kw)`, `is_sunken(kw)`, `is_mountain(kw)`,
   `DEFAULT_KW = 2`, `plain_kw_for(lower)`. `client/ui/element_db.gd` gains
   `ground_type(kw)` / `ground_variant(kw)` and documented stubs for the spec's other getters
   (`getCreatureModel`, `getSize`, `getColour`, `getBrain`, `getNature`, `howManyStackInCell`,
   `getSpellKind`, `getPowerValue`, `rarity`) — the shape is visible, no values are invented.
4. New `game/director/src/groundTypes.ts` with the same API, reading `data/elements.json`.
5. `game/world/grid.gd` — `CELL_TERRAIN_BLOCK = 4`; `ground: PackedInt32Array` filled with 2
   in `_init`; `ground_at()`, `apply_ground(PackedInt32Array) -> bool` (length must match;
   sets `CELL_TERRAIN_BLOCK` for impassable hexagrams but never overwrites `CELL_PORTAL`);
   `decode_ground()` as the one codec function; `from_area_payload` reads `ground`;
   `permits_squeeze()` is false for a terrain block; `set_portal` warns if it lands on one.
   Retire the "an authored area is open ground (no ground types yet)" test line.
6. Write the ground content-file format into `game/README.md` now, so tasks 005–007 can start
   from it: `game/content/areas/<slug>.ground.json` = `{ "$comment", "slug", "width",
   "height", "layers", "rows": ["02 02 20 02 …", …] }` — `height × layers` row strings,
   two-character zero-padded, space-separated, one row per line.

## Definition of done

- `test_runner.gd`: kw ↔ (upper, lower) is a bijection matching `elements.json`;
  `apply_ground` respects portal precedence and rejects a wrong length; `is_walkable` /
  `permits_squeeze` on terrain blocks; `from_area_payload` with, without and with a short
  `ground`.
- `npm test`: `groundTypes.test.ts` (64 → 8 × 8, kind rules) and the loader suite green again
  after step 0.
- `sync-elements.sh` writes both JSON copies; both are committed.
- Committed and pushed in `land-of-lor`.

## Where the work lands

`land-of-lor` — `game/shell/`, `game/director/src/`, `game/world/`, `game/README.md`.

## What was done (2026-08-17, laptop session)

- Step 0: `spellgrove.world.json` — beginner-arena's two portals moved to (0,5) and (19,9);
  `validateWorldContent` is clean again. **Assumption:** the 20×10 resize was intended; the
  portals were moved, not the size restored.
- `elements.js`: `ground` on all eight trigrams, `groundVariant` on all 64 elements
  (placeholder colours; task 006 tunes them). `sync-elements.sh` writes the director copy;
  `npm run build` carries it into `dist/`.
- `director/src/groundTypes.ts` + `test/groundTypes.test.ts` (5 tests).
- `world/ground_types.gd` (root, pure), `element_db.gd` getters and stubs, `grid.gd`
  (`ground`, `CELL_TERRAIN_BLOCK`, `set_ground`, `apply_ground`, `decode_ground`,
  `ground_at`, `is_sunken`, `is_mountain`; `from_area_payload` reads `ground` before
  portals). `test_runner.gd`: 143 checks green.
- `game/README.md`: "The ground — every cell is a hexagram", incl. the content-file format.