A first-person dungeon crawler in the spirit of the early-1980s cartridge era, built around one idea: a floor plan extruded to a ceiling height is a right prism over an irregular polygon. You draw the prism, the editor measures it, and then you walk through it.
Single file, no build step, no dependencies. Open index.html in a browser.
Creator. An 11×11 grid — the original's maze footprint. Carve floor out of solid rock, place arms, treasure, monsters and puzzles, set the ceiling height, mist, wake radius and party size. Six measurements update live:
| Floor area A | cells of floor |
| Perimeter P | cell edges facing rock or the outside |
| Volume V | A · h |
| Wall area | P · h |
| Interior surface S | 2A + P · h |
| Maze index | P ÷ 4√A |
The perimeter is drawn thick on the plan, so the number in the panel is also a thing you can see.
Play. Corridors render as nested rectangles with flat sprites centred in them — the way the original did it, not raycast. Two parallel health tracks, War and Spiritual, with two matching weapon classes: swords and bows hit War, books and fireballs hit Spiritual.
Three misconceptions fall out of the design rather than being quizzed:
A=18, P=24. Two Rooms Joined is the same eighteen cells pushed together: A=18, P=18. Six units of perimeter vanish, because a shared wall stops being surface.The maze index is bounded below by 1.00, since a square of area A has perimeter 4√A and nothing beats it. Near 1.00 plays open and safe; above 2.00 is blind corners.
The dragon cannot be fought. Strike it and it wakes. Come within k open steps — path distance through floor, not straight line — and it wakes. Either way you die.
You win by walling it in with the bricks your party carries, then walking out while it still breathes. Which makes "is this level winnable?" a minimum vertex cut: the fewest cells you must turn to rock to separate the entrance from the dragon, counting only cells outside the wake radius. The Creator panel solves it exactly with node-split max-flow and reports the number.
Bricks come from the party: 2 base plus 2 per helper. Helpers also step in front of one monster blow each, and then they are gone.
Because bricking only ever removes floor, path distances can only grow — so laying a brick can push a previously fatal cell out of the wake radius. You build your way to safety.
Sealing the dragon does not end the run. It opens the ladder, and "you walked out alive" happens when you actually climb one. A level with no ladder still ends on the seal, which is why the older adventures play exactly as they did.
A ladder lives in exits{}, a parallel map keyed by cell — a way out is a property of a floor cell, not a replacement for one. Paint it from the Brush panel under Structure, or write it as = in ASCII art. 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.
A world is an ordered set of dungeons and the wiring between them. The dungeons do not know they are in one: a dungeon numbers its exits by cell index and declares them as ports, and a world's leg says which port you arrive at and which carries you onward. That is what lets the same dungeon sit at position 2 of one world and 5 of another — storing a destination on the ladder would fix each dungeon's geometry to its neighbours.
Connections run both ways. You arrive beside the ladder you came down, facing into the new dungeon, and climbing it again puts you back where you were. Beside it, not on it: standing on the thing you must step onto leaves no way to step onto it.
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. There is no title card between dungeons — the climb is meant to be seamless.
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 that one the ladder you arrived by is a retreat while it breathes, and the way home once it is sealed.
One world ships: The Descent — The Approach, The Warrens, The Cistern.
The warden asks questions computed from the live dungeon, so they can never go stale. It deliberately asks for things the play HUD withholds: the HUD gives volume, wall area and height, never perimeter, so answering needs wall = P · h and a division.
Authored puzzles are records, with the board holding references to them. Several cells can point at one puzzle. Only the lock role is wired today; clue and mechanism exist in the data shape as groundwork for distributing one puzzle across several locations.
The Creator panel reports what references cost you: which puzzles are unplaced and so can never be met, which have no answer and so can never be solved. Keep authored content for riddles and story — anything asserting a fact about the geometry should stay generated, or it will quietly start lying the first time the floor plan is redrawn.
Rebindable: click any binding in the Controls panel and press a key. Two presets ship.
8 is forward and 2 is back.1 2 3 across the top — vertically mirrored against a numpad.. lays a brick. The on-screen keypad is generated from the live bindings, so it cannot drift out of sync.
Pressing Play goes straight to full screen, because pressing Play means playing. Esc steps back one level at a time: full screen first, then out of Play to Creator. F toggles full screen directly. While playing, the masthead is hidden either way; in Creator, Hide title does the same thing and is remembered.
The chip modelled here is an AY-3-8914: three square-wave channels and one noise generator. No samples, no filters, no envelopes beyond a 4-bit volume you wrote yourself. The synth is built from exactly those parts, which is why it sounds right rather than merely retro.
Two details are emulated rather than approximated. Tone frequency was the 1.79 MHz clock divided by 16 and by a 12-bit divider, so the available pitches were quantised — ayFreq() rounds to a real divider. And the noise came from a 17-bit shift register tapped at bits 0 and 3, which has a coarser grain than white noise; ayNoiseBuffer() runs the actual register.
The one concession is a lowpass at 7 kHz, standing in for the television speaker the chip was heard through. Without it the squares are harsh in a way no 1981 living room ever was.
The dragon's breathing is information, not atmosphere. It is the noise generator with a slow swell, and its volume tracks path distance through open air — audible from about eight steps, loud when it is near enough to wake. You hear it through mist that hides it. When the seal closes it goes silent, so you know you have won before the message says so.
M mutes, and the choice is remembered. Browsers hold the audio clock suspended until the page is interacted with, so it starts silent and wakes on your first key or click. That is unavoidable, not a design decision.
Save & share exports a level as JSON — floor plan, contents, settings and every puzzle — and imports one back. Import validates before touching live state and refuses a malformed bundle outright rather than half-applying it.
The working level also autosaves to localStorage, which is per-browser and per-machine. Use the JSON bundle to move a level anywhere that matters.
launcher/make-icon.py generates the icon at nine sizes from a 16×16 grid that lives in the script. The dragon is the data; edit GRID and re-run. No dependencies — the PNG encoder is thirty lines of zlib and struct, so it runs on Mint and on a Mac that has never seen pip. Scaling is nearest-neighbour, because a pixel dragon smoothed with interpolation stops being a pixel dragon.
Linux / Cinnamon — launcher/linux/install.sh, no sudo, everything under $HOME. Lands in the menu under Education.
Three things a bare chromium --app= gets wrong, and what fixes each:
| Problem | Fix |
|---|---|
| The window shows Chromium's icon | --class=Dragonkeep plus StartupWMClass=Dragonkeep in the .desktop. Both halves, or you still get the browser icon. |
| A second launch opens a second copy | flock on fd 9, held across exec for the life of the browser. A second launch raises the first with wmctrl. |
| The window joins the running browser session | Its own --user-data-dir, which also keeps the game's localStorage separate. |
Confirmed with xprop rather than assumed:
WM_CLASS = "home_todd_Repos_dragons-keep_index.html", "Dragonkeep"
--class sets the second field. --name is silently ignored — Chromium derives the instance name from the file path, so StartupWMClass must match the class, which is the half that stays stable across machines.
macOS — launcher/macos/make-app.sh, run on the Mac since it needs iconutil. It builds a Dragonkeep.app with the dragon icon, its own profile and one-instance-only.
It cannot fix the Dock icon, and says so when it finishes. A Mac app's identity comes from the bundle owning the running executable, and that executable is inside Chrome.app. There is no --class equivalent. For the dragon on the live window, use Chrome's Install page as app, then paste the icon in Get Info — the script prints the steps.
NEXT-STEPS-2026-10-07.md carries current status, the decisions already settled, the four pieces queued up, and the known problems. Read it before adding anything.
All art is original. The rendering palette is restricted to a sixteen-colour set, defined in IV at the top of the script.
The name is a pun, and it is also the whole design. A keep is a fortress, but to keep something is to look after it. You never kill the dragon. You keep it — away from the townspeople, and the townspeople away from its teeth.