Walk through Midgaard. The world is read at load time from the stock Merc 2.1
.are files — the same text DikuMUD has been booting off since 1991 — and built
into a town you can walk around, and fight in, in a browser. Rooms, mobiles,
objects, doors and their descriptions all come out of the area files; the combat
comes out of Merc's own C; every surface is noise baked into a texture at boot;
and the buildings are glTF models generated by the Blender scripts in
tools/blender/. Nothing is downloaded and nothing is modelled by hand.
python3 serve.py # http://localhost:8173/
There is no build step. Three.js is vendored under vendor/; the app is plain
ES modules.
A Diku world is a graph, not a plan. North-then-east does not have to land where east-then-north does, and in Midgaard it never does: of the 24 places you can test that on the Market Square, all 24 disagree. So the layout does not try to be faithful to geometry that isn't there. It does this instead:
- Place — breadth-first from the Temple of Midgaard (
#3001), each room taking the cell its exit asks for, or the nearest free one that satisfies the most of its other exits. - Relax — sweep back over the result offering every room the free cells nearby, keeping any move that joins up more exits than it breaks.
- Spread — rooms keep the even coordinates, so every cell between two rooms is free to build a street through.
- Route — each exit is walked through those free cells, at most six of them, setting off in the direction the exit claims to go.
What survives is a passage you walk. What doesn't is a stone archway with a shimmer in it that puts you where the exit says — honest about the fact that the mud's geography folds. For Midgaard that leaves 93% of exits walkable and eight archways; across all 43 stock areas it averages 94%.
A cell is thirteen metres, which is far too wide for a street, so an open-air city room does not pave all of it: every side with no way out of it brings the frontage behind it forward, and the corners between two ways out are built on too. Four exits leaves a crossroads with a block on each corner, two opposite exits leaves a lane between two terraces, and either way the facades finish 6.6 m apart. Squares are exempt, by the mud's own name for them.
node tools/layout-check.mjs # every area
node tools/layout-check.mjs 3001 midgaard.are # one, from a given room
node tools/parse-check.mjs # the reader, over everything
Anything still unreachable — an exit into an area you haven't loaded — becomes a barred gate with a sign saying which vnum is on the other side.
W A S D |
walk, shift to run, space to jump |
| arrow keys | take the exit north, east, south or west, and turn to face that way — the mud's own navigation, so north is north whichever way you were looking. Your pace carries over: step east while walking and you keep the same speed, now heading east |
page up / page down |
take the exit up or down, leaving you looking where you were |
| mouse | look, E to examine what you're looking at or open a door; E again closes it |
1 – 4 |
dawn, noon, dusk, night |
P |
cycle quality: low, medium, high, max |
F |
frame rate, draw calls, triangles, GPU milliseconds |
V |
noclip |
M |
sound |
G |
force every lock in the world |
| left mouse | attack what you are facing |
I B K R T Q |
inventory, trade, skills, rest, take, recall |
esc |
release the mouse |
?areas=midgaard,school |
which .are files to load (default midgaard) |
?room=3001 |
where to start, and what the layout is built around |
?max=400 |
stop placing after this many rooms |
?time=dusk |
dawn, noon, dusk, night |
?quality=medium |
low, medium, high, max |
?fps=30 |
override the preset's frame cap; 0 uncaps it |
Try ?areas=moria&room=7000, ?areas=sewer&room=7100, ?areas=haon&room=6500
for somewhere that is not a town. Loading two areas that connect (midgaard,school)
opens the gate between them.
Midgaard alone is a town with its edges cut off. Several of its exits lead into
other area files, and with only midgaard loaded those say #3504 is in an area not loaded. They are real ways out of the city, not dead ends:
?areas=midgaard,midennir,sewer
opens the Dump's south exit onto the South Bridge and its down exit into the
sewer, at 323 rooms — still inside the default ?max=400.
A room drawn next to another on the minimap is not an exit. The map is the layout grid, and the layout is a non-Euclidean graph forced flat, so cells end up adjacent that the mud never joined. Park Road sits directly south of the Dump on the map and there is no way to walk between them.
Under the map is a mariner's card compass. The rose is fixed to the world and turns beneath the index at the top, so whatever point sits under the index is the way you are facing, and it reads upright there whichever one it is. North is the fleur-de-lis. The card is sprung, so it settles rather than snaps.
The scene is around 1300 draw calls on the Market Square — still not much, and
most of the cost is pixels rather than submissions. src/quality.js owns all four dials:
render resolution, multisampling, the bloom chain, and the frame cap. It also
measures the result on the GPU with EXT_disjoint_timer_query_webgl2 rather
than inferring it from wall time, and drops the render scale a step if a frame
threatens its budget.
Measured on an M4 Max at 1400×900 CSS, standing on the Market Square, with the render loop halted so nothing else is drawing:
| preset | renders at | ms/frame | fps | GPU busy |
|---|---|---|---|---|
| low | 1400×900, no bloom, no shadows, no AO | 1.0 | 60 | 6% |
| medium | 1750×1125, half-res bloom, 2048 shadows | 2.5 | 60 | 15% |
| high (default) | 2450×1575, SMAA, GTAO | 12.1 | 30 | 36% |
| max | 2800×1800, full-res bloom, GTAO, shafts | 18 | uncapped | pegged |
high was 5.8 ms before the streets were given two sides. Bringing the
frontages forward roughly doubled the geometry — 2.0M triangles against 631k —
which is a third of the 30 fps budget and worth every millisecond.
Release the mouse or switch tabs and it drops to 10 fps; hide the tab and it stops drawing altogether. The sun's shadow map is snapped to a six-metre grid and only redrawn when you cross a line or the time of day changes, instead of every frame.
F shows the live GPU figure. On a lightly loaded frame it reads higher than
the table above, because the GPU drops to a low clock between frames and the
same work takes longer — which is the point.
WebGPU would not help here. Its win is CPU-side driver overhead across thousands of draw calls; this scene submits ~300 in well under a millisecond. The port is also not free: the flame, water and sky shaders and the whole post-processing chain are WebGL-only and would have to be rewritten as TSL node materials.
src/are.js |
reader for the Diku/Merc area format, and the reset table |
src/layout.js |
room graph → grid: place, relax, spread, route |
src/textures.js |
every material, baked from noise into albedo/normal/roughness |
src/build.js |
cells → geometry, merged per chunk per material |
src/actors.js |
mobiles, objects, fire, foliage, doors, signage |
src/player.js |
first-person movement over platforms and boxes |
src/quality.js |
frame budget: resolution, bloom, shadows, frame cap, light pool |
src/render.js |
sky-based image-based lighting, ambient occlusion, light shafts |
src/assets.js |
loads the Blender models and instances them per chunk |
src/game.js |
Merc 2.1's combat, experience, loot, shops and gate wardens |
src/game-ui.js |
the game's own overlay: vitals, target, combat log |
tools/blender/ |
the scripts that generate assets/*.glb |
src/hud.js |
room text, minimap, compass |
src/audio.js |
wind, footsteps, doors, a bell — synthesised, no files |
merc21/ |
Merc 2.1 as released on 1 August 1993, unmodified |
window.diku is exposed in the console: diku.goto(3014), diku.look(x,y,z,yaw),
diku.applyTime('night'), plus layout, built, world, game and quality.
Set diku.state.benchmark = true to halt the render loop before measuring.
The Merc server in merc21/ still compiles. On macOS it needs two flags and no
source changes:
cd merc21/src
make CC=cc NOCRYPT="-DNOCRYPT -Dunix" L_FLAGS="-O"
cd ../area && ../src/merc 4400 # telnet localhost 4400
-Dunix because Apple clang defines __unix__ but not bare unix, which is
what merc.h tests when it picks /dev/null to hold a spare stream handle.
-DNOCRYPT plus dropping -lcrypt because macOS has no libcrypt — passwords are
then stored in the clear, which is fine on loopback and nowhere else.
- Mobiles fight, die, drop what the reset table gave them and trade at their shop's own margins, but they do not walk their routes or run their spec_funs.
- The judge that reviews the screenshots does not pass this build. What is left on its list: interiors are all 10.26 m square with a 5.2 m ceiling and are empty of the furniture their own descriptions promise, the sun is still the only shadow-casting light, water reflects nothing and the river is built from stepped slabs with visible seams, the half-timbering is one module repeated, and the trees are three ellipsoids on a stick. The last two need the Blender scripts rerun, not a code change.
- Two staircases in one room share a single opening in the ceiling.
- An exit whose room has no free wall left ends up as an archway standing in the middle of the floor.
- The viewer never talks to the running mud. A websocket-to-telnet bridge would make that possible; nothing here assumes it.
DikuMUD is copyright 1990, 1991 Hans Henrik Stærfeldt, Katja Nyboe, Tom Madsen,
Michael Seifert and Sebastian Hammer, of DIKU, the department of computer
science at the University of Copenhagen. Merc 2.1 is by Furey, Hatchet and Kahn,
released 1 August 1993. Their licences are in merc21/README and
merc21/doc/, and both forbid commercial use — that applies to this too.
The code in this repository is otherwise MIT (LICENSE); the vendored
three.js in vendor/three/ carries its own MIT notice, and the merc21/
submodule keeps the DikuMUD and Merc terms.