Skip to content
yhavingaPublic

About

Walk through Midgaard. The world is read at load time from the stock Merc 2.1 .are files — no build step, every texture and sound generated at boot.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

diku3d

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.

What you are looking at

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:

  1. 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.
  2. Relax — sweep back over the result offering every room the free cells nearby, keeping any move that joins up more exits than it breaks.
  3. Spread — rooms keep the even coordinates, so every cell between two rooms is free to build a street through.
  4. 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.

Controls

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

URL parameters

?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.

Keeping the laptop cool

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.

Layout of the source

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.

Playing together

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.

Running a server, and becoming its implementor

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.

From a terminal

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).

Running the mud itself

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.

Known limits

  • 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 in server/ instead (see Playing together).

Credits

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.

About

Walk through Midgaard. The world is read at load time from the stock Merc 2.1 .are files — no build step, every texture and sound generated at boot.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages