Architecture Decision Records (ADR)
ADR-0001: Record architecture decisions as ADRs
Context
GAME_SPEC.md captures what the game is (rules, FR/NFR). It doesn't
capture why we built it a particular way, and that reasoning otherwise
lives only in chat history or a contributor's head — which the AEA framework
explicitly excludes as shared memory ("Chat is not shared memory";
architecture.artof.link/comparison.html).
Decision
Every non-trivial technical decision (stack choice, engine/UI split,
data format, a reversal of an earlier decision) gets a short ADR in
docs/adr/, numbered sequentially, following TEMPLATE.md. ADRs are never
renumbered or deleted (same ID-freeze discipline as FR/NFR IDs).
Consequences
Anyone — human or AI agent — picking this repo up cold reads AGENTS.md,
then GAME_SPEC.md, then skims docs/adr/ to understand not just the rules
but the reasoning already spent, instead of re-litigating settled questions.
---
ADR-0002: TypeScript monorepo, 2D now, 3D-and-native deferred behind one renderer interface
Context
Target progression is 2D (board-game style) now, 3D later, deployed to
laptop (web), then tablets and phones (Android first for validation).
NFR-1 (determinism) and NFR-4/NFR-10 (fast, isolated, headless testing of
the solver and hero AI) need to hold throughout that progression.
A game engine such as Unity or Godot would also reach web+mobile+3D from one
project, and a Unity license is available if this direction is revisited.
The trade-off: those engines fuse simulation and presentation, which makes
the deterministic, UI-free unit testing NFR-1/NFR-10 ask for harder to keep
cheap in CI (headless render setups, license activation per CI run), and
makes scripts/check-requirements-trace.mjs (linking FR/NFR IDs to plain
test files) awkward against engine-native test frameworks.
Decision
packages/engine: pure TypeScript, no DOM/engine dependency. Owns everyFR-xx/NFR-xx behavior and is exhaustively unit-tested (Vitest) in CI in
seconds, with no build/device step.
apps/web: the Maker, Vite + React. Renders the dungeon on a plain 2Dcanvas now (matches the board-game phase). A future 3D pass swaps in
three.js / react-three-fiber behind the same room-state props — engine
code does not change.
apps/web running directly in a browser.apps/web output withCapacitor to produce an installable Android app with zero UI fork. Revisit
React Native only if Capacitor's WebView performance becomes a real
constraint once 3D lands.
the existing Unity license stays a fallback if the 2D-canvas → three.js →
Capacitor path hits a wall — not adopted now, per the trade-off above.
Consequences
One engine codebase, one UI codebase, for the whole 2D→3D / laptop→tablet→
phone progression. The only two things that change over time are which
renderer is mounted (canvas vs. three.js) and which shell runs apps/web
(browser vs. Capacitor) — packages/engine never has to know.
---
ADR-0003: Multi-agent collaboration across OpenAI, Claude, Grok, AGY, Copilot, Kimi, and DeepSeek
Context
Development on this repository involves multiple autonomous AI agents and coding assistants across distinct providers and frameworks — including Anthropic Claude, OpenAI/Codex, xAI Grok, Google Antigravity / Gemini (AGY), GitHub Copilot, Moonshot Kimi, and DeepSeek.
Without strict architectural constraints:
1. Each tool introduces proprietary configuration formats or instructions that drift out of sync.
2. Agents assume ephemeral chat history represents shared knowledge, leading to forgotten context between different agent sessions.
3. Concurrent agent sessions can rebase or overwrite each other's in-flight work or make simultaneous uncoordinated edits to single-source ledgers (docs/STATUS_LEDGER.md, docs/FINDINGS.md).
4. Cross-platform checkout differences (e.g., Windows CRLF vs. Linux/Mac LF in CI containers) cause diff noise across agents.
Decision
1. Single Source of Truth (AGENTS.md): All agent guidelines reside exclusively in AGENTS.md. Tool-specific entrypoints (CLAUDE.md, GEMINI.md, CODEX.md, OPENAI.md, GROK.md, KIMI.md, DEEPSEEK.md, .github/copilot-instructions.md, .cursorrules) are strict, minimal pointers that redirect directly to AGENTS.md. No tool-specific instructions may diverge.
2. Shared Memory Discipline: Chat history is not shared memory. The default branch, PR branches, and committed docs (GAME_SPEC.md, docs/STATUS_LEDGER.md, docs/FINDINGS.md, docs/journal/) are the sole shared memory between agents.
3. Isolated Workspaces & Branch Convention: Agents work on separate topic branches named agent/<agent-family>/<task-slug> or copilot/<task-slug>. Agents must never rebase over another in-flight branch.
4. Peer Review & Verification: A producer agent does not self-approve or merge its own changes. Pull requests must pass automated CI checks (pnpm test) and governance checks (pnpm governance) before review and merge by another agent or human.
5. Cross-Platform Line Ending Normalization: Enforce eol=lf via .gitattributes so agents operating across heterogeneous environments (Windows, Linux, macOS) produce uniform diffs.
Consequences
AGENTS.md and operates under identical constraints without prompt drift.---
ADR-0004: Deployment architecture, knowledge site, and observability
Context
Following the dual-site architecture of the Adaptive Experience Architecture framework (architecture.artof.link + aea.artof.link) and Café Fausse (knowledge.cafe.artof.link + cafe.artof.link), Zorg's Dungeon Maker requires two distinct public endpoints:
1. Implementation & Playback view (zorg.artof.link): Interactive 2D Maker grid canvas and extermination simulation runner.
2. Knowledge Base (knowledge.zorg.artof.link): Canonical reference for game rules, requirements traceability graph, honesty ledger, coherence findings, and dev journals.
3. Observability (https://zorg.artof.link/grafana/): Public monitoring of host and container metrics, matching https://aea.artof.link/grafana/.
Evaluation between AWS ECS Fargate + ALB vs. AWS Lightsail + Docker Compose revealed:
small_3_0, \$12/mo flat) provides 2 vCPUs, 2 GB RAM, 60 GB SSD, 3 TB egress, and static IP. It hosts a self-contained Docker Compose stack with zero cloud sprawl and matches the existing operational model of cafe.artof.link.Decision
1. Implementation Compute: Deploy zorg.artof.link on AWS Lightsail instance zorg-dungeon-prod (Ubuntu 24.04, small_3_0) in us-east-1a with static IP <LIGHTSAIL_STATIC_IP>.
2. Implementation Stack: Use Docker Compose running:
- web: Multi-stage build of @zorg/engine and @zorg/web served by Nginx.
- caddy: Reverse proxy providing automated Let's Encrypt TLS and reverse proxying /grafana/ to Grafana and / to the web app.
- prometheus: Scrapes node and web metrics with local TSDB persistence.
- grafana: Served at /grafana/ with anonymous viewer access enabled, pre-provisioned Prometheus datasource, and system dashboards.
- node-exporter: Exposes host CPU, RAM, disk, and network metrics.
3. Knowledge Site: Host knowledge.zorg.artof.link on GitHub Pages (artofdream.github.io.) built via GitHub Actions (.github/workflows/knowledge.yml) from markdown files in docs/ and GAME_SPEC.md.
4. DNS Management: Manage records in Route 53 zone <HOSTED_ZONE_ID> (artof.link.):
- zorg.artof.link. $\rightarrow$ A <LIGHTSAIL_STATIC_IP> (TTL: 60)
- knowledge.zorg.artof.link. $\rightarrow$ CNAME artofdream.github.io. (TTL: 300)
Consequences
/grafana/ without credentials required for inspection./grafana/ route is anonymous read-only. Caddy returns 404 for the login form, admin UI and admin APIs, and 403 for any credentialed request (basic auth, bearer token, Grafana session cookie) or any write other than the dashboards' POST /api/ds/query. Grafana basic auth is disabled. Admin access is only over an SSH tunnel to Grafana's host-local port 127.0.0.1:3000 (procedure in CONTRIBUTING.md, "Grafana admin access"). deploy-web verifies the tunnel-path admin login and probes the public surface on every deploy./grafana) to the caddy_logs volume (roll at 20 MiB, keep ≤14 files, ≤14 days); Grafana logs to console and to a file in the grafana_logs volume (daily rotation, 14 days) with request logging on. The logs contain client IPs, so retention is deliberately short (14 days); both survive container recreation. Details in CONTRIBUTING.md, "Production logs".secret_key comes from the GRAFANA_SECRET_KEY repo secret and is delivered like the admin password (SSH stdin → root-only /etc/zorg/grafana.env); a key change is refused while Grafana stores encrypted secrets and is followed by a data-key rotation. The Caddy admin API is bound to localhost inside its container; Prometheus scrapes Caddy metrics on an internal :9180 listener. Repo secrets are interim until the sponsor's 3DX Lab per-project secrets method is adopted.GRAFANA_ADMIN_PASSWORD repo secret, is written by deploy-web to a root-only host env file (/etc/zorg/grafana.env), and is re-applied to Grafana's DB on every deploy (the env var alone only applies when the DB is first created).---
ADR-0005: Authored campaign first, then a Difficulté-banded generator
Context
Sponsor chose authored campaign first, then generator. Phases 0–7 plus the
Difficulté campaign browser already ship the fixture pack. A generator that
invented new room types, Gunner duration, contract gating, or a fantasy
difficulty scale would violate NFR-8 and the deferred rulings (S2, S3, S4,
S6). Chat is not shared memory — the knobs and guarantees need to live next
to the code.
Decision
generateLevel({ difficulty, seed?, caps? }) lives in @zorg/engine (packages/engine/src/generator.ts). Pure library: LevelDef +
serializeLevel text + a suggested Maker layout. No UI dependency.
actually appear on green fixtures). Knobs (room / hero / spell counts, E/O
flavour) stay inside those corpus envelopes (band 4 matches Stupidity
Award's 9-room board). Unspecified deluxe contracts are not a generation
band.
an A → lethal D → … → Z line, first D ≥ hero HP, empty cast sequence.
This slice does not emit mirror worlds or not_solvable-mirror constraints.
C (FR-18 / S2), Gunner(avoids S3 duration), Mechanic / Princess (pathing can break the corridor),
P / T (portals and unpaid tolls), choix, contracts 11–15, FR-4 gating.
Honesty copy: generated = Simulated engine, not Live & Probed.
Consequences
packages/engine/src/generator.test.ts — Simulated, not a productionprobe. No STATUS_LEDGER row is promoted by this decision.
---
ADR-0006: Grounded constraint evaluation, refuse opaque prose
Context
FR-43 and FR-44 sit on top of the Phase 6 runner: worlds already
resolve normal → M′ → M″ (FR-45), and FR-46 can ask whether a world
is solvable. The loader already stores Constraints / Bonuses / Variante as
prose expression strings. GAME_SPEC names the extra win-condition
categories — positional dist equations, death order, HP floors,
gold-at-death, solvability of mirror worlds — but it does not define a
constraint language. Authored fixtures mix those categories with layout
shape, counterfactuals, visit-set comparisons, and setup variants.
Inventing evaluators for the opaque lines would violate NFR-8 (never
silently guess) the same way inventing room C or Gunner duration would.
Decision
scoreLevel in packages/engine/src/constraints.ts is the FR-43 / FR-44 layer. It calls resolveWorlds (FR-45 order), then aggregates:
any world Z-entry is a loss; a win requires every hero in every world
dead and every blocking constraint held.
block the aggregate outcome.
existing engine sensor are classified:
- solvability of named worlds → checkSolvability (budget ⇒ unsupported,
not a proof)
- dist equations / ∃-adjacent / ∃-D-pair → FR-10 Manhattan
- HP-floor / gold-at-death / reverse death-order prose → simulation events
UnsupportedConstraint. An unsupported blocking constraint refuses a win claim (unresolved). The engine does not guess
“square dungeon”, spell-usage, distinct death rooms, immunity
counterfactuals, or C / Gunner duration / FR-4.
simulate still reports the scheduler outcome only. Makerplayback is unchanged.
Consequences
Simulated for the grounded subset. Opaquecorpus lines stay unevaluated — that is a documented gap, not a silent
pass.
plus a test. See STATUS_LEDGER, 2026-09-12, PLAYER_GUIDE.
---
ADR-0007: Sustainable FR/EN i18n (display-only)
Context
The Maker and knowledge companions ship English-first copy for kids (~8) and
helpers. A French audience needs the same surfaces without rewriting engine
tokens, inventing unavailable rules, or forking GAME_SPEC. Prior notes said
“no i18n rewrite” for hatch copy (NFR-7); that was scope deferral, not a
ban on a catalog-based display layer.
Constraints that still bind:
A / Z / D / …, hero typenames in data) stay the legal voice. Companions never override the spec.
C, Gunner shot-duration, or FR-4 contractgating (NFR-8 fail-closed). Honesty disclosure may name those gaps;
playable copy must not claim they are built.
of scope for this decision.
Decision
1. Message catalogs under apps/web/src/locales/{en,fr}.ts hold every
user-facing Maker string. EN keys are the source of truth; FR is a complete
parallel catalog. Display-only: translate words players see (Start/Départ,
Exit/Sortie, Difficulty/Difficulté), keep engine tokens and FR/NFR IDs as
data.
2. Tiny t(key) + locale context in apps/web/src/i18n/. Default locale
is EN. No React Router in this slice — URL strategy is ?lang=fr
(canonical), with document.documentElement.lang, localStorage key
zorg-locale, and a chrome switcher (EN | FR) on landing/Maker. Path
prefix /fr/ is reserved for a later router slice; do not invent Vite
rewrites here.
3. CI missing-key gate: every EN key exists in FR and vice versa (vitest).
4. Knowledge FR companions live under docs/fr/ (LEARN.md,
PLAYER_GUIDE.md, PLAYER_JOURNEYS.md). The knowledge build emits
fr/learn.html, fr/guide.html, fr/journeys.html with EN↔FR nav.
French prose cites the same FR-xx / NFR-xx IDs; if French and the
spec disagree, the spec wins. Honesty / skills / full GAME_SPEC stay
English until Phase C.
5. Maker “Learn the rules” points at the FR learn page when locale is FR.
Consequences
PR or CI fails.
duration / FR-4; path-prefix routing without a router.
---