# Dragonkeep — Next Steps

> **The test for every feature:** could the player answer it without being here?
> If yes, it is bolted on. The geometry you design has to be the geometry you
> see, hear, and spend bricks against.

---

## Current status

**The game is live and playable, at v0.25.**
<https://ohiomathteacher.github.io/applet-library/dragonkeep/> — public, no sign-in.

Creator and Play both work. You draw a floor plan, six measurements update live
(area, perimeter, volume, wall area, interior surface, maze index), and then you
walk through the prism you drew. Corridors render as nested rectangles with flat
sprites centred in them, the way the 1983 first-person crawlers did it — no
raycasting anywhere.

**The win condition is a minimum cut.** The dragon cannot be fought: strike it,
or come within *k* open steps, and it wakes. You win by walling it in and leaving
while it still breathes. "Is this level winnable?" is therefore the fewest cells
you must turn to rock to separate the entrance from the dragon, counting only
cells outside the wake radius — solved exactly with node-split max-flow, and
reported in the Creator panel.

**There is a way out, and it is a ladder.** `exits{}` is a parallel map keyed by
cell, the shape this file argued for with doors: a way out is a property of a
floor cell, not a replacement for one. It paints from the Brush panel under
Structure, survives save, load and the export bundle, reads from ASCII art as
`=`, and draws on the plan, the map and in the corridor. Standalone, it is barred
while a dragon is loose and open when there is none, so a measurement lesson can
be finished on foot while a dragon level has to be earned.

**Sealing no longer ends the run.** It opens the ladder, and *"you walked out
alive"* happens when you actually climb. A dragon level with no ladder ends on
the seal exactly as it always did, which is why The Deep Keep is unchanged.

**Dungeons connect into worlds.** A world is an ordered set of dungeons plus the
wiring between them. A dungeon numbers its exits by cell index and declares them
as ports; a world's leg says which port you arrive at (`in`) and which carries
you onward (`out`). Connections are two-way: you arrive beside the ladder you
came down, facing into the new dungeon, and climbing it again puts you back.
One world ships, **The Descent** — Approach, Warrens, Cistern.

**The grid is variable**, square, 7 to 31, via `setSize()`.

**Dungeons are authored as ASCII art.** `fromArt()` reads a rectangle of
characters — `#` rock, `.` floor, `@` entrance, `=` ladder, letters for monsters
and items. A maze written as `rect()` calls cannot be reviewed by looking at it.

**Sound is an AY-3-8914**, not samples. Three square channels and one noise
generator, tone frequencies quantised to real 12-bit dividers, noise from an
actual 17-bit shift register. The dragon's breathing is information rather than
atmosphere: its volume tracks path distance, it is audible through mist that
hides the dragon, and it stops the moment the seal closes.

**The page is in standards mode.** It had no doctype until v0.21, so every
browser rendered it in quirks mode — confirmed, not assumed: `document.compatMode`
came back `BackCompat` on both the repo copy and the published one.
`*{box-sizing:border-box}` was absorbing the damage, which made it a trap rather
than a bug. The charset meta is the mojibake fix. The viewport meta switched on
the 860px and 720px breakpoints, which were written but could never fire on a
phone.

### What shipped on 2026-10-07, after the v0.20 hand-off

- **v0.21** — doctype, charset and viewport. The gamepad note: `padSeen` had been
  set on `gamepadconnected` and read nowhere, and the `drawBinds()` call beside it
  was therefore a no-op too. Both halves do something now.
- **v0.22** — the ladder, and three lessons that can be finished.
- **v0.23** — the map stays off the title card. `drawView()` returns early for the
  card, but the layout pass calls `drawMap()` directly and that call had no guard.
- **v0.24** — worlds.
- **v0.25** (written on the ThinkCentre) — the rosters. The black bands either
  side of the picture were carrying nothing; they now hold PRIZES on the left and
  FOES on the right in immersive mode, sized to the band and hidden when it is too
  narrow. An item still in the dungeon is an outline, one you hold is filled, a
  defeated foe goes dark grey, a sealed dragon gets brick laid over it. The title
  card became the front door, shown once per launch and never again, and Play
  while already playing stopped restarting the run. Adventure mode stopped hiding
  its creatures and let you collect them instead.

---

## Decisions already made

Recorded so they do not get relitigated.

**A dungeon declares ports; the world does the wiring.** The destination never
lives on the ladder. Storing one there would fix each dungeon's geometry to its
neighbours and make it un-reusable — the whole point is that the same dungeon can
sit at position 2 of one world and 5 of another.

**Connections are two-way, and you arrive beside the ladder, not on it.**
Standing on the thing you must step onto leaves no way to step onto it.

**Inside a world the ladders stay open.** An intermediate dragon is a hazard to
cross, not a gate, because the dragon only ends the last dungeon. In the last one
the ladder you arrived by is a retreat while the dragon breathes and the way home
once it is sealed.

**The party travels together.** Treasure, arms and armour carry. Health refreshes
each dungeon. A helper lost stays lost, and since bricks come from helpers, the
loss follows you down.

**"Tower" is conceptual.** No shared shaft, no cross-plan alignment, no cell that
has to be floor on five levels at once. You leave through the floor and arrive
through the ceiling, and that is the whole of it. An earlier design had a literal
shaft occupying the same (x, y) on every served floor; it was dropped because it
couples the floor plans and destroys reuse.

**Vocabulary.** A **dungeon** is one floor plan. A **world** is a connected set
of dungeons, 5 or 6 of them. A **universe** would be a collection of worlds, and
is deferred until something actually holds two — a teacher handing out a
semester's worth is the case that would justify it. It costs nothing to defer,
because a universe wraps a working world format the same way a world wraps
dungeons.

**Worlds store dungeons by reference, and export inlines them.** Reference keeps
30 dungeons shared across 6 worlds without duplication; inlining on export keeps
the single-file bundle a teacher can hand out. Only the export pays for both.

**Dungeon tags are derived, never typed.** Difficulty, danger and size are
combinations of measurements this page already computes — maze index, area,
`brickBudget() − sealCost()`, `dragonDistance()` from the entrance, mist and wake,
monster load off `mobs{}`, entrance-to-exit path length. Compute them at load,
never store them in the file, for the same reason the warden's questions are
computed: a hand-typed `difficulty: 3` starts lying the first time the floor plan
is redrawn. Hand-tag only the name, the note, and which idea a dungeon teaches.

**The exit sign shows direction only, no distance.** Todd, 2026-10-07: no sign in
the world tells you how many steps. Distance would also hand over more than a sign
in a dungeon should.

**Grids stay square.** It is what keeps the plan canvas square and costs nothing real.

**Dragons go at junctions, not dead ends.** A dragon at the deepest dead end gives
a seal cost of 1, a one-brick win that throws away the point.

**Authored puzzles must not assert facts about geometry.** Keep authored content
for riddles and story.

**Sound is synthesised, never sampled.** The aesthetic depends on the
three-channel constraint.

**Keybindings are a player preference, not level data.** They are not carried in
the exported level JSON.

**The repo is the single source.** `applet-library/dragonkeep/index.html` is
generated. Never hand-edit it.

---

## The pieces, in build order

### 1. Derived tags

Everything feeding them already exists: `metrics()`, `sealCost()`,
`dragonDistance()`, `exitList()`, `mobs{}`. Surface them in the Creator panel,
computed at load.

This comes first because the two pieces after it both depend on it. You cannot
curate 20–30 dungeons without a way to measure them, and you cannot assemble a
world by flavour without the axes to sort on. It also turns the generator's brief
into "produce good dungeons" rather than "produce a campaign", which is the job it
is already good at.

A flavour is a region of that space, not a label: a claustrophobic world is high
index, heavy mist, small area; a gauntlet is high monster load, low index; a
planner's world is low danger with almost no brick slack.

### 2. The exit sign

An arrow pointing along the shortest open path to the nearest exit, computed from
the live dungeon so it can never go stale. Direction only.

It earns its place because it *moves*: lay a brick and the arrow swings, or goes
dark because you just cut yourself off from the last exit. That makes it an
instrument rather than a hint, and it is the visual twin of the breathing —
breathing is distance to danger in audio, the arrow is direction to safety in
vision.

**Build the dark case first.** A player who bricks themselves into a pocket has no
way to find out today. An arrow that goes out the moment no exit is reachable makes
that legible while it can still be undone.

Occasional, at junctions. Signs everywhere and the map stops mattering.

### 3. The dungeon library

20–30 good dungeons, generated and verified, then packaged into worlds 5 or 6 at a
time. A dungeon that sits in a world needs at least two exits unless it is an
endpoint.

### 4. World Creator and the world format on disk

Worlds exist in memory only right now (`WORLDS`, hard-coded). They need a format,
a save, an export that inlines its dungeons, and a UI for wiring ports. Progress
is per-world, not per-dungeon: one dungeon can be leg 2 of one world and leg 5 of
another, and "cleared" means different things in each.

### 5. Cell types — doors, gates, archways

Treasure of Tarmin draws four distinct things where Dragonkeep draws one: green
wall faces, **blue door panels** that block until opened, cyan **barred gates** you
can see through but not pass, and black **open archways** in a side wall.

`exits{}` is the pattern to copy — a parallel map keyed by cell. **`sealCost()`
must learn about them**: a closed door is not a wall for cutting purposes unless it
cannot be opened, and getting this wrong makes the editor report a number that is
not true, which is worse than reporting nothing.

Room shape variety belongs here too. The generator carves only rectangles;
L-shapes and irregular chambers would raise the maze index honestly rather than by
adding corridors.

### 6. Audio tells

**Count your bricks by ear** — one chip click per brick remaining, from the
manual's COUNT ARROWS key. **Per-monster proximity cues**, each with a fixed
signature, because *"A MONSTER ALWAYS BEHAVES ACCORDING TO TYPE."* **Floor tracks**
as a visual tell. **Health as colour**, black strong, blue wounded, red hurt,
applied to helpers on the map.

### 7. The world view

One data structure, two pictures. A world is an ordered graph of dungeons; draw it
as Cloudy Mountain's green-and-olive map, or as a Donkey Kong elevation with floors
as bands and connectors between them. The tower and the overworld are not two
features, so the connective tissue only gets built once.

The property worth stealing from DK is that **you see the whole tower at once**.
Fog stays inside a dungeon; the world's shape is always legible. You know the
building, you do not know the rooms. This is also the exit sign at world scale.

Worth remembering why any of this is needed: of Donkey Kong, Pitfall, Cloudy
Mountain and Treasure of Tarmin, only Tarmin is first person, and Dragonkeep
inherits that. First person takes away the shape of the thing you are standing in.
The map, the exit sign and the world view are all the same compensation wearing
three hats.

Pitfall's contribution, for later: the tunnel you drop into to skip ahead, whose
price is that you cannot see where you are going. That is the skip-link with its
tradeoff already built in.

---

## Known problems

**Worlds cannot be saved, exported or authored.** `WORLDS` is a hard-coded table
with one entry. Piece 4.

**`sealCost()` does not know about exits.** It is a single max-flow from the
entrance to the dragon and never asks whether you can still get out, because when
it was written there was nowhere to get out to. With exits that a player must
reach, the question becomes *cut the dragon off from an exit **and** stay connected
to that exit* — two conditions at once, which one max-flow call does not answer.
Still exactly solvable at these grid sizes, but it is per-exit work plus a
connectivity check.

This is worth doing rather than avoiding. Today bricking is a one-way ratchet and
every brick is free, which is why the seal costs feel thin at 2. Give the player an
exit they must reach and a brick can wall *them* off, so each brick becomes a
decision with two sides.

**The Deep Keep has no ladder**, so it is the only adventure without a way out. It
still ends by sealing or by the minotaur. It is also not in a world, partly because
its minotaur ending would cut a world short.

**The generator cannot hit 17×17 and 21×21.** Larger mazes are more open, cuts get
larger, and the `maxSeal` ceiling fights the `minSeal` floor. Worlds make this less
urgent: a long campaign is now several generatable dungeons rather than one huge
plan.

**Seal costs are low** — 2 bricks on all three shipped dungeons. A larger wake
radius pushes the cut outward into open ground where it costs more; the constraint
is that the entrance must stay outside that radius.

**Lessons don't carry settings.** Loading a lesson keeps whatever wake, helpers and
mist the last level set. Adventures apply their own via `applyLevelOpts()`.

**Playtesting found three real bugs in one sitting**, which is the argument for
doing more of it. Todd played it on 2026-10-07 and hit: no way out of a lesson, the
map sitting on the splash screen, and the game-over screen appearing between
dungeons. None of those were visible from reading the code. The same sitting
produced v0.25's rosters, title-card and collection changes.

**Work written on one machine can sit uncommitted on it.** v0.25 was written on the
ThinkCentre and nearly lost: `git pull` on MacGuffey reported nothing because
nothing had been pushed. Finding it meant checking every box in
`linux-setup/docs/machines/machines.jsonl` over Tailscale. `todd-gpt-fedora` still
carries a clone at v0.20; it is clean, so it is harmless, but it is four releases
behind and editing there without pulling first is how today's work would get
clobbered.

---

## How to work on this

**Verify dungeons, do not eyeball them.** Slice `metrics()` and `sealCost()` out of
`index.html` and run those rather than reimplementing the geometry — a maze passes
because the shipped code says it passes.

**Drive the real game, do not read it.** The 2026-10-07 checks appended a probe
script to a byte-identical copy of `index.html` under `/tmp`, then walked the party
with the actual `step()` and `attack()` and reported what happened:

```bash
cp index.html /tmp/probe.html
cat >> /tmp/probe.html <<'EOF'
<script>setTimeout(()=>{ /* drive the game, write findings into #__probe */ },1800);</script>
EOF
head -c $(wc -c < index.html) /tmp/probe.html | cmp - index.html   # the copy must be unmodified
chromium --headless=new --disable-gpu --virtual-time-budget=9000 \
  --dump-dom "file:///tmp/probe.html" | grep -o 'id="__probe">[^<]*'
```

Two traps this caught, both in the harness rather than the game: a walker that
forgets `P.dir` walks in whatever direction it was already facing, and a walker
that BFSes through a dragon's cell stands there punching it forever. Route around
dragons and set the facing.

**Assert before you mutate.** Any script that edits `index.html` must count its
anchor and fail loudly when it is missing. `str.replace()` reports success by doing
nothing.

**Lint before pushing.** Zero errors is the bar; the ~18 warnings are unused
`catch(e)` bindings.

```bash
node --check <extracted script>          # syntax
npx eslint --rule '{"no-undef":"error"}' # no typo'd names in one 2,600-line scope
```

**Smoke test:**

```bash
chromium --headless=new --disable-gpu --virtual-time-budget=4000 \
  --enable-logging=stderr --dump-dom "file://$PWD/index.html" 2>&1 \
  | grep -iE 'uncaught|is not defined|TypeError'
```

**Bump `VERSION` with every release** (top of the main script, shown bottom-right).
If the Creator's default panel layout changes, also bump `PANEL_DEFAULTS_V`.

**Then sync and push both repos:**

```bash
cd ../applet-library && python3 sync-dragonkeep.py && git add -A && git commit && git push
```

`sync-dragonkeep.py --check` says whether the published copy is stale.

**Read the live site back after pushing.** A push is not a deploy; Pages takes a
minute or two, and the only way to know is to fetch the URL and look at `VERSION`.

**Watch for the host-stylesheet trap.** Creator and Play both rendered at once
because the `hidden` attribute loses to any author `display` rule, and the artifact
host injects `[hidden]{display:none!important}` while a plain file does not. The
rule is in the page now, but the lesson generalises.

**A doctype is in the document, not the headers.** GitHub Pages sends
`charset=utf-8` and cannot send a doctype, so the published copy was in quirks mode
for exactly as long as the source was. The generated file opens with a banner
comment before the doctype; a leading comment is harmless, and that was checked in
Chromium rather than read off the spec.
