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/
It also runs at https://diku3d.com, and in a terminal as
ssh -p 2222 play.diku3d.com (see From a terminal). ./deploy.sh puts the working tree
there, to the server named in a gitignored .env (see .env.example),
behind Cloudflare; tools/deploy/nginx-diku3d.conf is the site config it
installs.
Every texture is still generated in the browser, in up to eight workers
(src/bakery.js, src/bakeworker.js); IndexedDB keeps what this browser baked
before, about 65 MB, under a SHA-256 of src/textures.js, so any change to that
file bakes everything again; diku.bake says what came from where. The title
is up in the first paint and the world is built behind it, over a still of the
title reel's first shot. The world is ready about 9 s into a first visit and
6.6 s into a repeat one (diku3d.com, headless Chromium on an M-series Mac), and
31.7 s in for a GPU that has never compiled these shaders, 17.7 s of it compiling.
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 twelve 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 97% of exits walkable and three archways; across all 43 stock areas it averages 95.4%.
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
The ten areas around Midgaard are the home zone. Every other stock area is
loaded too — the rules engine runs all 44 in area.lst, resets, wandering
mobiles and all — but each is drawn as a zone of its own (src/zones.js): take
an exit into one (up from the temple into the Mud School, north from Haon Dor
into the High Tower, the West Gate into Moria) and the old zone is taken down
behind a title card while the new one is laid out and built. Only the sky area
cannot be entered yet: its rooms are all sector AIR, which nothing builds.
Every exit the mud names has a doorway in its room, on the wall it names
wherever the graph allows (node tools/exit-check.mjs --strict). An archway is
built at both ends, set in a wall or a corner and closed with a dark threshold;
a way up or down with no wall of its own is a ladder or a shaft. A one-way exit
ends in an iron gate that opens only for someone coming the way the mud allows.
The people in it are the mud's mobiles, dressed by what the area file says
they are: src/people.js reads keywords, flags and shops and picks one of
seventeen archetypes — cityguard, knight, priest, mage, merchant, beggar,
troll, zombie and so on — with a weapon from the mobile's own resets. Animals
and monsters are thirty modelled bodies, bred by coat and proportion into
dogs, wolves, horses, cattle and swans, and into spiders, scorpions, a sand
worm, the dracolich and a Will-O-Wisp that carries its own light over the bog. They wander the way Merc's
mobile_update has them wander, through a random open exit every so often,
and walk the routed street to get there; fights are Merc's fight.c, played
on the beat with the swing's contact frame landing on the blow. Nothing is
named unless you look at it; a foe you cannot see gets a marker telling you
which way to turn. Rooms take their plan, materials and colours, doors, water depth, blood and
wall paintings from their own descriptions: the Temple of Midgaard stands on
its mound behind marble steps, painted with gods and giants, and Haon Dor's
great tree is a hollow trunk you walk into. The Shire is built as the Shire: turf hills with round painted doors, thatched
houses with round windows and flower boxes, fenced gardens and a mill with its
wheel. Shops and inns are furnished by the keeper's trade and the room's own
description — a forge and anvil for the smith, racks for the weaponsmith, a
bar with its back shelves for the tavern. People sit at tables, talk in pairs, stand at the bar and
climb the stairs rather than vanishing at the foot of them. The casters
cast: spec_cast_mage, _cleric, _undead, _adept and the dragons' breath
run on the mobile pulse, with 57 spells out of magic.c — each family drawn
as its own effect, with affects like sanctuary worn as an aura while they last; your own hand gestures when
you cast, and spells leave scorch, frost, acid and cracks on the ground. The rest of Merc is there
too, in src/rules/: area resets, hunger and thirst, corpses that rot and
can be searched, containers, keys and locks, kick, backstab, disarm, rescue
and steal, the janitor, the fido, the thief and the mayor walking his route
opening the gates, and a character saved to the browser to continue from.
node tools/people-check.mjs [--all] # who every mobile is dressed as
node tools/noun-check.mjs --placed f # which nouns in the prose are built
The default world is ten areas and 624 placed rooms: Midgaard, Haon Dor, the Shire, the marsh, the troll den, the graveyard, the sewer under the town (down through the Dump or the guild wells), the Great Eastern Desert (out through the river gate in the east wall) and Raff's Dangerous Neighborhood with its No Man's Land (south from inside the East Gate, down Wall Road), and Miden'nir, which the mud joins to Midgaard twice: east out of the East Gate and south over the bridge off the Dump.
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, open, unlock (with the key you carry), drink, search a body or pick up; E again closes it |
1 – 4 |
dawn, noon, dusk, night |
P |
cycle quality: low, medium, high, max (also grass density and reach) |
F |
frame rate, draw calls, triangles, GPU milliseconds, and the room's vnum, sector and build statistics |
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 |
Z X / wheel, C / right mouse |
mage and cleric: choose a spell, cast it |
Z X C H J L N |
warrior and thief: the skill bar — kick, backstab, disarm, sneak, hide, steal, pick |
tab |
show the room's full description again, or fold it (it folds by itself once there has been time to read it) |
enter |
the mud's own command line: Merc's command table, abbreviations and all 90 socials; esc, ctrl-c or enter on an empty line closes it |
? / F1 |
every control on one screen, with your own class's rows lit and its skill bar read off the class; the same key closes it |
O |
options; O again closes them and takes the mouse back |
esc |
release the mouse; click the view to take it again. The game starts without it, so the title's Enter works even where pointer lock is refused |
?areas=midgaard,school |
which .are files make up the home zone (default the nine listed above); every other area is still its own zone |
?room=3001 |
where to start; a room in another zone (?room=3700) starts you there |
?max=400 |
stop placing after this many rooms |
?time=dusk |
dawn, noon, dusk, night |
?quality=medium |
low, medium, high, max |
?class=mage |
start as warrior, mage, cleric or thief (also on the title screen) |
?cull=off |
draw everything, for comparing against the measured visibility |
?lod=off, ?occlusion=off |
keep every tree a full model; stop hiding what is behind hills — both for A/B comparison |
?fps=30 |
override the preset's frame cap; 0 uncaps it |
?bake=fresh |
ignore the texture cache and bake every surface again (and store the new bake) |
A new character starts in the Mud School in the school's kit, as Merc's nanny
starts one. Try ?room=3001 to start in the temple instead, ?room=7000 for
the sewer or ?room=6500 for the dwarven kingdom.
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.
Outdoor views are now 250–450 draw calls and 1–3.5M triangles: each camera
cell measures what it can see through its openings (src/cull.js), whatever a
depth pyramid of the static world hides is skipped (src/occlusion.js), and
trees past 80–95 m are cards baked from the real models at load
(src/impostor.js). src/quality.js owns the dials: render resolution,
multisampling, the bloom chain, grass reach 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 and sleeps between frames rather than waking at the display's refresh rate; a title screen left in an unfocused window does the same; 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 a few hundred, and the main-thread cost that remains is scene-graph and visibility bookkeeping, not submission. 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 |
sound: committed ElevenLabs clips (assets/audio, src/soundmap.js), synth as fallback |
src/link.js |
the page's game, local or over a WebSocket to server/ |
server/ |
the multiplayer server: Merc's char_list, login, channels, PvP; and ssh.mjs, the terminal way in |
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.
cd server && npm ci && node main.mjs --port 4011
then choose "connect to a server" on the title screen and give
localhost:4011. The same Merc 2.1 rules run for everyone: say, tell, the
channels, shout, follow and group, socials, and Merc's player-killing rules
with KILLER and THIEF flags. The owner becomes implementor with
node server/main.mjs --make-implementor <name>, which asks for the password
on the terminal. Playing alone stays the default and needs no server.
node tools/server-check.mjs logs four players in over real sockets.
On diku3d.com the server runs beside the site: deploy.sh syncs server/,
installs the systemd unit in tools/deploy/ (loopback port 4000, player
files in /srv/diku3d-data) and nginx passes wss://diku3d.com/ws to it.
"connect to a server" with an empty address connects there. It first boots
wizlocked; an implementor opens it with wizlock.
Visitor statistics come from the site's own nginx log, never from the
visitor's browser: the config in tools/deploy/ logs the real address
(Cloudflare's CF-Connecting-IP, trusted from Cloudflare's ranges only) to
/var/log/diku3d/access.log, rotated daily and never thrown away
(logrotate-diku3d.conf), and tools/deploy/stats.sh turns that log, every
day of it, into one GoAccess HTML page and opens it. Like
deploy.sh it reads the host from the gitignored .env; nothing in the
repository names the machine.
node server/main.mjs [--port 4011] [--host 127.0.0.1] [--data DIR] [--seed N]
node server/main.mjs --make-implementor NAME [--class mage|cleric|thief|warrior] [--sex m|f|n] [--data DIR]
--data is where player files, notes.json and site.json (bans and the
wizlock) are kept; it defaults to server/data/, which git ignores.
--make-implementor creates or promotes NAME to level 40, Merc 2.1's
implementor, and asks for the password twice on the terminal (it refuses
without one). Point it at the same --data as the running server; the
server reads player files at login, so no restart is needed. On the public
server that is
ssh -t <host> 'sudo -u diku3d node /srv/diku3d/server/main.mjs --make-implementor NAME --data /srv/diku3d-data'
Logged in as implementor, wizhelp lists the wizard commands (goto,
transfer, restore, stat, advance, snoop, mset/oset/rset, ban/allow, wizlock,
shutdown and the rest). wizlock toggles whether mortals may log in; a
fresh site.json written by deploy.sh starts with it on.
The server does not take a page's word for where its player is, but it
walks where the page walks: across the grass between rooms, from one street
onto another the mud never joined, off a ledge. A report inside the rock,
a jump to a room no open exit leads to, a step through the wall or the shut
door of a walled room, or anything faster than a glide is refused and the
page put back. Notes, the ban list and the wizlock live in
server/data/. A dropped link can be taken up again without a reload, and
a player who dies leaves a corpse with their gear that only they, their
group or an immortal may loot.
ssh -p 2222 play.diku3d.com
is the same game without the page: Merc's greeting, the name and password as comm.c asked them over telnet, the mud's text line by line, and the vitals in the prompt. No SSH key or SSH password is asked for; the account is the game's. Locally:
cd server && (node main.mjs --port 4011 &) && node ssh.mjs --port 2222
ssh -p 2222 localhost
server/ssh.mjs is a gateway beside the game server, not part of it: each
SSH session is one more client of /ws, as a page is. On this server a room
changes when a body walks into it, so a typed direction is walked -- along
the street nav.js routes, at a page's pace, every report put to the server's
own judge first (server/walker.mjs) -- and players on a page see the figure
walk. Without a pty (ssh -p 2222 play.diku3d.com < script, or an agent
driving ssh without -t) it reads and writes plain lines: each question on a
line of its own, no echo, no colour, and a direction holds the lines after it
until it has been walked. A command on the ssh line, forwarding and sftp are
refused. One thing a terminal cannot do yet is walk up to a mobile, so a
backstab, which needs you within 3.2 m of one that is not fighting you, only
reaches a mobile that happens to stand that close. node tools/ssh-check.mjs
plays it end to end.
On diku3d.com deploy.sh installs tools/deploy/diku3d-ssh.service (public
port 2222, the host key kept in /srv/diku3d-data/ssh), and
play.diku3d.com is a DNS-only record, because Cloudflare's proxy carries
only HTTP(S).
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 C mud in
merc21/; multiplayer runs the port's own rules inserver/instead (see Playing together).
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.