# Building the Game — one site, the Builder, and the building blocks

## overview.md
```yaml
id: building-the-game
title: Building the Game — one site, the Builder, and the building blocks
area: ops
summary: The public site becomes the live front door to the game's building blocks — elements, ground, creatures, the world and its areas — view-only for everyone, editable by Kris (and later by players in their own areas) through a separate admin service, the Builder, with a way to hand an agent a focused task from any section.
repo: land-of-lor (site/, builder/, game/director for content schemas, tools/) — one repo since 2026-09-03
depends_on: ["world-map-and-portals","ground-types","element-table","server-architecture"]
```

## What this is

Kris, 2026-09-03, by voice and then in conversation: one central, live view of the game's
building blocks — the element table, the same 64 as ground types, the creatures, the world map,
and each Area with its properties — with the existing editors linked together and **zero
duplication of data** so nothing can drift. Then the ask grew into its real shape:

- **The site is the view.** Public and view-only for everyone; editable by Kris when signed in
  with his game account; later editable in specific parts (their own Areas) by other players,
  using the game's own accounts to decide who may edit what.
- **From every section, hand an agent a task** via the AI Task Runner, with that section's
  context (which files, which epic, the current values) already in the prompt. "This is the
  real launching pad for building the game."
- **One repo, one site.** The workspace repo merged into `land-of-lor` (`workshop/` and
  `site/`); the site moved off the task-runner host onto the game droplet at
  **spellgrove.com**; the old host redirects.
- **The Builder is separate from the game.** The admin/authoring API is its own service
  (`builder/`), its own process, port, path (`/builder/`) and database. The game's director
  learns nothing about it; duplicating a little code between them is the price of the game
  standing alone one day, and it is worth paying.
- **The database is canonical** for authored content; the `.json`/`.js` files in the repo are
  snapshots that every publish **always writes**. A **staging** game stack exists, the app gets
  a hidden dev option to point at it, and every publish shows two tick boxes — Staging and
  Live — both on by default, remembered per editor afterwards, and says plainly what goes where.
- **Nothing here may preclude** later finessing of creature properties, ground types or Area
  properties through the same view: the content endpoints return whole objects, the editors
  bind by field name.

The architecture in short:

```
spellgrove.com  (Caddy on the droplet, auto-TLS)
  /            the static site, built on the droplet from site/ by lor-site-build.timer
  /director/   the game (unchanged; lor-server.cocreations.com.au keeps working alongside)
  /builder/    the Builder — admin API, own process, own database lor_builder
staging.spellgrove.com/director/   the staging game stack
```

## The blocks and where each one lives

| Block | Source of truth today | Editor | Public view |
|---|---|---|---|
| Elements (64) | `game/shell/elements.js` (derived JSONs via `game/sync-elements.sh`) | `game/shell/card-editor.html` | coming (`/building/elements`) |
| Ground | the `ground` / `groundVariant` blocks in `elements.js`; rules in `game/shell/ground_rules.mjs` | the area editor's palette | coming (same page) |
| Creatures | no data file — `game/world/entity_factory.gd` (kinds), `pocs/object-gallery/scenes/creatures/` (models) | none | coming (`/building/creatures`) |
| World | `game/content/spellgrove.world.json` → the DB via `npm run load-world` | `tools/hub-ui/` (local) | `/world`, live from the director |
| Areas | the area objects in the world file + `game/content/areas/<slug>.ground.json` | `tools/hub-ui/area.html` (local) | coming |

The local hub (`tools/hub-ui/`, `npm run editor` in `game/director`) already shows all
five from their canonical files on one page; it is the offline mirror of what the Builder
will serve.

## The one exception to "the Builder serves everything"

The public world map stays the director's `/director/world-map.json`. Fogging private Areas,
dropping hidden portals and adding only the caller's own home are *game rules* the in-game map
already depends on; the Builder must not re-implement them. The Builder's `world.json` is the
authoring view — the full graph for a signed-in admin.

## Agent kickoff is browser-side

Kris's call (the Moment pipeline pattern): the signed-in admin pastes their task-runner API key
once into the site (localStorage, never sent to the Builder) and the "Ask an agent" panel speaks
MCP straight to `kris.ai-task-runner.com`. The Builder only serves the section's prompt frame.
No remote-code-execution credential lives on the droplet; non-admin users never see the panel.

## Sequence

001 migration + domain → 002 the Builder's read side and the hub pages → 003 sign-in →
004 publish with staging → 005 ask an agent → 006 other people's areas. 007 is Kris's
own half-finished ask (Dirt and natural occurrence), waiting on the rest of his sentence.

## Supersedes

`workspace-dashboard/003` (authenticated control surface) and `/004` (task kickoff) — their
intent lands here as 003 and 005. `server-architecture/018` (the spellgrove.com domain) —
the domain is live as of 001; moving the game client's base URL onto it is still that task.

## status.md
```yaml
updated: 2026-09-03
parked: false
tasks: {"backlog":1,"needs-input":1,"ready":0,"doing":0,"review":5,"done":0}
open_questions: []
tag: needs-review
next: 001–005 built, live and in review. Kris: sign in on spellgrove.com with your game account (Papa), open Building the Game, try an edit on Staging, and paste your task-runner key into an Ask-an-agent panel for a first small task. 006 (other people's areas) is backlog; 007 waits on the rest of the Dirt sentence.
```

## Task summary

7 tasks, all created 2026-09-03 from the planning conversation with Kris the same day.

- **001 — migration + domain** — `review`. One repo (`workshop/`, `site/`), the site built on the
  droplet and live at spellgrove.com, the old host redirecting, DNS set by Kris. Needs the
  deploy key registered before the timer can pull on its own.
- **002 — the Builder's read side + hub pages** — `review`. Live at `/builder/` and `/building`.
- **003 — sign-in and roles** — `review`. Live; Kris is admin.
- **004 — publish, DB canonical, staging** — `review`. Staging live, the exporter, the Builder's writes proven live to staging, the editors on the site, APK v22.
- **005 — ask an agent** — `review`. The panel on every section; frames served by the Builder; the runner's CORS opened.
- **006 — other people's Areas** — `backlog`, after 004.
- **007 — Dirt and natural occurrence** — `needs-input` (Kris's cut-off voice note).

## Open questions

None tracked — the one thing needing Kris (the deploy key) is a step, not a decision.

## Next

Kris: sign in, try an edit on Staging, paste the runner key and ask for one small thing. Agent: 006 when Kris wants it.

## tasks/ (7)

### building-the-game/002 — The Builder's read side, and the hub pages that show every block live
```yaml
id: building-the-game/002
title: The Builder's read side, and the hub pages that show every block live
epic: building-the-game
state: review
priority: 1
blocked_by: ["building-the-game/001"]
estimate: L
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: builder/README.md
```

## What to do

Stand up `builder/` (TypeScript, the director's toolchain, its own `package.json`) as a
separate process on the droplet: `lor-builder.service`, Caddy `spellgrove.com/builder/*` →
its port, `/etc/lor/builder.env` (`DATABASE_URL` for `lor_builder`, `GAME_DATABASE_URL` as a
**read-only** Postgres role on `lor_game`, `CONTENT_CHECKOUT=/opt/lor/content`). Bare
`http.createServer`, no framework, the director's test conventions (embedded Postgres).

Public reads, CORS `*`, ETag + 60 s memo (copy the pattern from
`game/director/src/publicMap.ts`, do not import it):
`GET /builder/content/elements.json` (evaluated from the checkout's `game/shell/elements.js`),
`ground.json` (the meanings, via `game/shell/ground_rules.mjs`), `creatures.json` (kinds parsed
from `game/world/entity_factory.gd`, models listed from disk), `world.json` (authored fields
from the game DB, public Areas only), `areas/<slug>.json` (properties, rules, portals, ground —
public Areas only; private ones name/type/size/position).

Site: `src/lib/builderApi.ts`; pages `building/elements.astro` (element table + ground table,
rows lower trigram, columns upper; a cell opens a card), `building/creatures.astro`,
`building/areas/[slug].astro`; the `/building` landing shows live counts; `/world` balls link
to Area pages; the nav's *soon* entries become links.

The local hub (`tools/hub-ui/`) is the offline mirror: its routes and this task's
endpoints should agree on shapes.

## Definition of done

`builder/test/content.test.ts` green; `curl https://spellgrove.com/builder/content/elements.json`
returns the table; changing a name in the checkout shows on `/building/elements` with no site
rebuild; the site build still passes.

## Built (2026-09-03)

`builder/` is live at `spellgrove.com/builder/` (`lor-builder.service`, port 8090, database
`lor_builder`, read-only role `lor_builder_ro` on `lor_game`, `/etc/lor/builder.env`), 13
tests green against embedded Postgres loading the real world through the director's loader.
The site's `/building` landing shows live counts and the public areas; `/building/elements`
draws the 64 twice (elements, ground) with a card per cell; `/building/creatures`;
`/building/areas/<slug>` with the ground drawn from the database; the world map's panel links
to an area's page. Checked in headless Chrome against the live Builder.

Interpretations worth a glance: portals whose far end is private are omitted rather than
blanked; `/health` answers 503 when the game DB is unreachable; an `elements.js` edit shows
within the 60 s memo, not instantly.

### building-the-game/001 — One repo, one site: the workspace merged into land-of-lor, the site on spellgrove.com
```yaml
id: building-the-game/001
title: One repo, one site: the workspace merged into land-of-lor, the site on spellgrove.com
epic: building-the-game
state: review
priority: 1
blocked_by: []
estimate: L
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: workshop/epics/building-the-game/tasks/001-migration-and-domain.md
```

## What was done (2026-09-03, laptop session)

- **The `lor-workspace` repo merged into `land-of-lor`** with `git subtree add` (history kept):
  its root became `workshop/` (epics, deliverables, decisions, brand, historic-docs,
  `marketing/` — was `marketing-and-communication/` — `WHY.md`, its `README.md` and
  `CLAUDE.md`); its `site/` became `site/` at the repo root; `godot-setup-for-pocs/` became
  `tools/godot-setup/`. `setup-subrepos.sh`, `deploy-watch.sh`, `ssh_droplet.py` and the
  disabled GitHub workflow were dropped from the moved tree. The workspace repo itself is now a
  thin coordinator: README, a pointer `CLAUDE.md`, `setup-subrepos.sh`, `.gitignore`.
- **Every path the site reads re-pointed** (`astro.config.mjs`, `content.config.ts`,
  `brand.ts`, `taskPath.ts`, `about`, `PICKUP.md.ts`, `design-brief.md.ts`,
  `validate-epics.mjs`); `repoLink.ts` maps the old workspace roots onto the new folders so
  older epic prose keeps linking; `export-web.sh`'s embed path follows the site; the build
  output default is `site/dist` (the droplet sets `OUT_DIR`).
- **Nav:** "The World" became the **Building the Game** submenu — The Building Blocks
  (`/building`, the landing), The World, and Elements / Creatures marked *soon*.
- **The site is built on the game droplet**: content checkout `/opt/lor/content` (origin =
  GitHub over the deploy key `~lor/.ssh/lor_deploy_key`), `/opt/lor/bin/site-build.sh` run by
  `lor-site-build.timer` every 2 minutes (fetch, compare origin/master to the last-built SHA,
  ff-only pull, `npm ci && npm run build` into `/var/www/spellgrove.new`, swap; log at
  `/opt/lor/site-build.log`; `--force` builds the local checkout even when the fetch fails).
  Caddy serves `spellgrove.com` (site + `/director/` + `/game/<port>` + `/download/`),
  `www` redirects to the apex, `staging` answers a placeholder. A 2 GB swapfile was added
  (the box had none; the Astro build wants headroom). Certificates issued by Let's Encrypt
  on first request.
- **The old host** `lor.kris.ai-task-runner.com` now serves a redirect page to spellgrove.com
  at every path that existed (its final build is archived on that host as
  `/root/lor-public-site-final-20260903.tar.gz`); the host cron entry `lor-deploy-once` was
  removed.
- **DNS:** Kris set the three A records (`@`, `www`, `staging` → 209.38.29.22) on Namecheap.
- Also folded in: the local authoring hub (`tools/hub-ui/index.html`) and hexagram 2
  renamed **Dirt**.

## Waiting on Kris

- **The deploy key.** `~lor/.ssh/lor_deploy_key.pub` on the droplet (label
  `lor-game-server-01-deploy-readonly`) must be added at
  github.com/krisrandall/land-of-lor/settings/keys **with write access**. Until then the
  timer's fetch fails (logged, harmless) and a rebuild needs a bundle pushed by hand plus
  `site-build.sh --force`; after it, every push to `master` is live within two minutes.

## Verify

```bash
ssh -i .secrets/lor_runner_key lor@209.38.29.22 tail -5 /opt/lor/site-build.log   # "build OK"
curl -sI https://spellgrove.com/ https://spellgrove.com/building https://spellgrove.com/world | grep HTTP
curl -sI https://www.spellgrove.com/ | grep -i location                           # -> https://spellgrove.com/
curl -s https://lor.kris.ai-task-runner.com/ | grep -o 'spellgrove.com'           # the redirect
```

## Assumptions

- `workshop/` as the folder name for the merged workspace; `site/` at the top level because it
  is the game's face now, not the workshop's.
- Older epic prose keeps its workspace-relative paths; the site's link mapper translates them
  rather than rewriting history.
- The game client keeps `lor-server.cocreations.com.au` for now (server-architecture/018).

### building-the-game/003 — Sign in on the site with a game account; Kris is admin
```yaml
id: building-the-game/003
title: Sign in on the site with a game account; Kris is admin
epic: building-the-game
state: review
priority: 2
blocked_by: ["building-the-game/002"]
estimate: M
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: builder/README.md
```

## What to do

`POST /builder/auth/login` (email + password checked against the game DB's `auth_identities`
through the read-only role, with the Builder's **own copy** of the scrypt verify — the game's
tables gain no columns), minting a `web_sessions` row (token hash, 7-day `expires_at`,
revocable); `POST /builder/auth/logout`; `GET /builder/auth/me`. `Authorization: Bearer` only,
never a query token. Token buckets on every auth and write route. `roles` table
(account id → `admin` | `player`), seeded by `npm run set-role -- <email> admin` on the droplet.

Site: `components/SignIn.astro` modelled on `RepoLinkDialog.astro`; session in
`lor:session` (the footer's "clear site settings" wipe doubles as sign-out); a header chip with
name and role. Admin-only UI is hidden client-side, and every write is enforced server-side.

## Definition of done

Tests: login, expiry, revoked, wrong role 403, 429. Kris signs in on spellgrove.com and
`/builder/auth/me` says `admin`.

## Built (2026-09-03)

Live. `POST /builder/auth/login` (game email + password, checked read-only against the game's
`auth_identities` with the Builder's own scrypt verify; 10 tries per 10 minutes per IP, 20 per
hour per email), `POST /auth/logout`, `GET /auth/me`; bearer web sessions, 7 days; `roles`
table; `npm run set-role -- <email> admin`. Kris's account (Papa, kris.randall@gmail.com) is
admin. The site: a "Sign in" chip in the header opens the dialog; the token lives in
`lor:session`; `<html data-role>` gates editing controls client-side while the Builder enforces
every write. 10 tests. Not yet exercised with a real password — that is Kris's own check.

### building-the-game/004 — Publish: the editors on the site, the database canonical, snapshots always written, a staging stack
```yaml
id: building-the-game/004
title: Publish: the editors on the site, the database canonical, snapshots always written, a staging stack
epic: building-the-game
state: review
priority: 2
blocked_by: ["building-the-game/003"]
estimate: L
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: builder/README.md
```

## What to do

- **Staging stack** on the droplet: `lor_game_staging` (restored from a live dump),
  `lor-director-staging.service` (its own ports, `WORLD_SERVER_DIR` shared), Caddy
  `staging.spellgrove.com/director/`; the game client's hidden dev option (title screen: tap
  the version line seven times → a server picker, remembered) to target it.
- **Builder writes** `PUT /builder/authoring/world | areas/<slug> | areas/<slug>/ground |
  elements`, validated with the game's own Zod schemas (`game/director/src/worldContent.ts`),
  `ground_rules.mjs`, and a `checkGround` lifted from `tools/hub-ui/server.mjs`.
- **Publish** `{files: always, staging, live}`: write the snapshot files in the content
  checkout (`spellgrove.world.json`, `areas/<slug>.ground.json`, `elements.js` +
  `sync-elements.sh`), commit, push over the deploy key; then `loadWorldContent` against the
  staging pool and/or the live pool. A `publish_log` row per publish. **Elements are the honest
  special case**: they are compiled into the game, so "Live" for an element edit means a
  build, and the dialog says so.
- **DB→file exporter** `npm run export-world` (the inverse of `loadWorldContent`); round-trip
  equality with the committed snapshot is its acceptance test.
- **Editors on the site**: `tools/hub-ui/` → `tools/hub-ui/` as ES modules with a backend
  adapter (the local `server.mjs`, or the Builder + bearer); the site copies the folder in at
  build. The publish dialog: two tick boxes, Staging and Live, both on by default, remembered
  in `lor:publish:<editor>`, and plain words about what goes where. `server.mjs` stays as the
  offline shim of the same routes.

## Definition of done

Write → export → equals the committed file; a Staging-only publish changes staging and not
live; `verify-world-map` and `verify-home-door` 10/10 on live after a live publish; a Godot
client lands in a re-saved Village; the snapshot commit appears on GitHub.

## Built (2026-09-03)

- **Staging stack** live: `lor_game_staging` (a copy of live), `lor-director-staging.service`
  on 8091/8092, `staging.spellgrove.com/director/`; the client's hidden picker (seven taps on
  the title footer → Live / Staging / this build's default, remembered per device) shipped in
  the web build on `/play` and APK **v22**; `/play?director=` rides through to the game.
- **Exporter** `npm run export-world` in `game/director` (DB → world file + ground files,
  `--check` for drift; round-trip tests, ground files byte-identical).
- **The Builder's writes**: `GET/PUT /builder/authoring/world`, `…/areas/<slug>/ground`,
  `…/elements`, `GET /authoring/publishes`; every publish writes the snapshot files in
  `/opt/lor/content`, commits as `Builder <builder@spellgrove.com>` naming the signed-in
  account, pushes over the deploy key, then loads into each ticked database; serialised;
  14 tests. **Proven live** with a throwaway admin: a world and a ground publish to Staging
  only landed as commits `036cb67` and `097ce02` on GitHub (the one-time re-sort of the world
  file by slug, and a comment line), applied to staging and not live; a bad ground → 422.
- **Editors on the site**: `tools/hub-ui/` (one codebase, two backends — the local
  `server.mjs`, or the Builder with the site's sign-in) copied to `spellgrove.com/hub/` at
  build; the publish dialog with the two remembered ticks; the card editor's Publish button
  (files + sync + push; "Live" for elements means a build, and it says so). Admin-only
  "Edit the world →", "Paint the ground →", "Edit the element cards →" links on the Building pages.

Not yet done by a human: Kris publishing something himself through the site's editors.

### building-the-game/005 — Ask an agent from any section — browser-side kickoff via the AI Task Runner
```yaml
id: building-the-game/005
title: Ask an agent from any section — browser-side kickoff via the AI Task Runner
epic: building-the-game
state: review
priority: 3
blocked_by: ["building-the-game/003"]
estimate: M
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: site/src/components/AskAgent.astro
```

## What to do

Kris's call: the browser holds the task-runner key (`lor:taskrunner`, pasted once by a signed-in
admin, never sent to the Builder) and speaks MCP streamable HTTP straight to
`https://kris.ai-task-runner.com/` — `initialize` (keep the `Mcp-Session-Id` header) →
`notifications/initialized` → `tools/call submit_task {project: "lor", prompt}`; the id comes
back in the text as `Task queued: <id>`; `task_status` polling for the task list.

Builder: `GET /builder/context/<section>.md` — the section's prompt frame (canonical files, the
epic, the PICKUP protocol, the current values summarised) that the panel pastes above the
typed instruction.

Site: `src/lib/taskRunner.ts`, a one-time "connect your task runner" dialog, an **Ask an
agent** panel in every Building section (admin only), a task list with live status, and on
each epic page a "pick up this epic" button (this closes `workspace-dashboard/004`).

Outside the repo: the task runner's hard-coded CORS allow-list (`server.py`, Moment origins +
localhost) must gain `https://spellgrove.com`.

## Definition of done

One real kickoff whose task touches only a scratch file and shows up in `list_tasks`.

## Built (2026-09-03)

Browser-side, as Kris asked: `site/src/lib/taskRunner.ts` speaks MCP (streamable HTTP) to
`kris.ai-task-runner.com` with a key pasted once into the browser (`lor:taskrunner`); the
"Ask an agent" panel (`AskAgent.astro`) sits on every Building section, the world map and
every epic page (that one is the "pick up this epic" button workspace-dashboard/004 wanted),
visible to admins only. The Builder serves the section's frame at
`GET /builder/context/<section>.md` from `builder/prompts/`. The task runner's CORS
allow-list now includes `https://spellgrove.com` (repo `claude_task_runner`, branch `slim`,
and patched in place in the running `atr-kris` container).

Not yet done: a real kickoff — that needs Kris's own key pasted into his browser. The first
one should ask for something small and reversible.

### building-the-game/006 — Other people editing their own Areas
```yaml
id: building-the-game/006
title: Other people editing their own Areas
epic: building-the-game
state: backlog
priority: 3
blocked_by: ["building-the-game/004"]
estimate: M
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What to do

No new tables. Area edit rights = `admin`, or the Area's `owner_id` / `area_members` in the
game DB (read-only). A non-admin "New Area" creates an Area they own, placed on the map by an
admin later. Creature and ground *property* finessing arrives as more fields on the same
endpoints and editors — nothing has to be rebuilt for it.

## Definition of done

A second account edits the ground of an Area it owns and cannot touch the Village.

### building-the-game/007 — Dirt, and the things that naturally occur in an Area
```yaml
id: building-the-game/007
title: Dirt, and the things that naturally occur in an Area
epic: building-the-game
state: needs-input
priority: 3
blocked_by: []
estimate: S
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: null
```

## What was heard

Kris, by voice, 2026-09-03 — the recording hit its limit mid-sentence:

> I would like the item dirt to be created. That's gonna be earth over earth. And I would like
> you to set up a mechanic so that in places where there is grass, where this is a new thing
> that needs to be added, where the area itself has the little flag of things that naturally
> occur in there…

## Done so far

Hexagram 2 (Earth over Earth) is named **Dirt** with `origin: terrain` in
`game/shell/elements.js` (synced to the two derived JSONs).

## Waiting on Kris

The rest of the sentence: how the "naturally occurs here" flag on an Area works, and what
happens on a grass cell in such an Area (dirt collectable? spawning? the ground changing?).
The likely landing place is an Area property `naturallyOccurring: [kw…]` in the world file and
the area editor, with the rule itself in the Area server — but that waits on the words.

## questions/ (0)

## deliverables (0)
