---
id: ground-types/002
title: Storage, hydrate, wire — areas.ground, the hydrate file, hello_ack, protocol 4
epic: ground-types
state: done
priority: 1
blocked_by: []
estimate: L
created: 2026-08-17
updated: 2026-08-18
claimed_by: null
claimed_at: null
delivers: []
review_artifact: land-of-lor/game/director/src/groundPaint.ts
---

## What to do

Give ground a home in the database and carry it to the world process and the client.

- `game/director/src/schema.sql`: `ALTER TABLE areas ADD COLUMN IF NOT EXISTS ground SMALLINT[];`
  — nullable (NULL = never painted), row-major `(layer*h + y)*w + x`, the same indexing as
  `AreaGrid`. Range and length are checked in TypeScript; this schema bans bare
  `ADD CONSTRAINT`.
- `areas.ts`: `getAreaGround(pool, id)` and `setAreaGround(pool, id, cells)` (1..64, length
  `w*h*layers`). **Not** added to `AREA_COLUMNS` / `rowToArea` — every Area query would drag
  ~10 KB per row. `createArea` writes a plain fill; `ensureHomeArea` writes the seeded home
  paint in its size `UPDATE`.
- New `groundPaint.ts` (seeded PRNG, e.g. mulberry32, seed = hash of the area id):
  `paintPlain`, `sprinkleVariants(cells, w, h, seed, density, lower)`, `paintHome`;
  `paintUnpaintedAreas(pool)` called from `db.ts migrate()` — NULL *or wrong length* → homes
  sprinkled grass, everything else plain; log `[migrate] painted ground for N area(s)`;
  idempotent (a second boot logs nothing).
- `areaInstance.ts spawnInstance()`: `ground` in the hydrate object (dropped with a warning if
  the length is wrong, so the world process falls back to plain rather than crashing).
- `area_server.gd`: `apply_ground` right after `build_for_area` and **before** `_hydrate`
  (portals must win over paint); cache the marshalled array once (`_ground_wire`) rather than
  rebuilding it per peer; `_area_payload()` gains `ground`. Annotate the "when authored terrain
  exists it gets a `cells` key right here" comment as closed — don't delete it.
- **PROTOCOL_VERSION 3 → 4** in `protocol.ts`, `protocol.gd` and
  `test/helpers/gameClient.ts` — an old client can't see mountains and would walk into
  invisible walls; the 015 "update needed" path makes the bump survivable. Deploy order (task
  008): director, then web export, then APK.
- `client_main.gd _apply_area`: the dedupe key gains `hash(ground)` — otherwise a same-size
  repaint keeps stale terrain across a respawn.

## Definition of done

- `portal-authored.test.ts`: `hello_ack.area.ground.length == w*h`; an intent into a Mountain
  cell is `intent_rejected`.
- `home-portal.test.ts`: a home's ground has more than one distinct hexagram, all Earth-lower;
  spawn (1,1) and the landing cell (w/2, h−2) are passable.
- `schema.test.ts`: column present; migrate paints NULLs and re-paints wrong lengths; a second
  run is a no-op.
- `stale-client.test.ts` still green at version 4.
- Committed and pushed in `land-of-lor`.

## Where the work lands

`land-of-lor/game/director/`, `game/world/`.

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

- `schema.sql`: `areas.ground SMALLINT[]` (nullable; range/length checked in TS).
- `areas.ts`: `getAreaGround` / `setAreaGround` / `ensureAreaGround` / `paintUnpaintedAreas`
  (not in `AREA_COLUMNS`); `ensureHomeArea` paints the home after sizing it. New
  `groundPaint.ts`: mulberry32 over FNV-1a, `paintPlain`, `sprinkleVariants`, `paintHome`,
  `defaultPaintFor`, `encodeGround` (the one wire codec). `db.ts migrate()` →
  `[migrate] painted ground for N area(s)`. `areaInstance.ts spawnInstance` puts `ground` in
  the hydrate file via `ensureAreaGround`; the loader re-ensures after its size UPDATE.
- `area_server.gd`: `apply_ground` before `_hydrate`, `_ground_wire` cached,
  `_area_payload().ground`; the old "gets a `cells` key right here" comment annotated as
  closed. `client_main.gd`: `hash(ground)` in the `_apply_area` key.
- **PROTOCOL_VERSION 4** in `protocol.ts`, `protocol.gd`, `test/helpers/gameClient.ts`.
- Tests: `schema.test.ts` (column, migrate paints NULL and re-paints a resize, idempotent,
  `setAreaGround` refuses bad input), `portal-authored.test.ts` (ground on hello_ack; a step
  into a Mountain is `BLOCKED`, into Water lands), `home-portal.test.ts` (home scatter,
  deterministic per id); `stale-client`, `loadWorldContent`, `groundTypes` still green.
- **Assumption:** the wire array is plain JSON (~10 KB for 60×60) — `encodeGround` /
  `decode_ground` are the two places to change if that ever matters.