DurisMUD is a single-process, event-driven MUD server derived from the DikuMUD
lineage, compiled as C++20 (g++ -std=c++20) from C-style sources. It serves
players over plain telnet, TLS telnet, and WebSocket; durable player state lives
in MySQL, while Redis optionally holds immutable crash-recovery world generations.
Related reading: CODEBASE.md for the module map, DATABASE.md for persistence details, RUNBOOK.md for operations. A visual overview lives in diagrams/duris-server-architecture.html.
Process model
One process (bin/server/dms, staged as bin/server/dms_new). There is no
fork-per-connection, player-save fork, or world-save fork; all socket I/O is
multiplexed in a single select() loop. Concurrency includes:
- One bounded player-load worker that owns a pooled connection and returns typed rows.
- A private player-journal append dispatcher and keyed revisioned-save workers.
- A non-coalescing critical-command coordinator, typed repository workers, and outbox dispatcher for economy, ownership, auction, and gameplay-outcome operations.
- One bounded maintenance worker for staggered recurring database and snapshot work.
- One immutable world-recovery publisher worker when Redis recovery is enabled.
- One bounded best-effort mail worker (libcurl SMTP) for account password recovery. It is
not in the shutdown drain chain, is joined at shutdown (each send is bounded to 10 s
connect / 20 s total, no retry), and is disabled unless
MAIL_ENABLED=TRUE. - Legacy item, scalar, and large-payload queue modules for remaining compatibility producers; they are not the player snapshot or critical-operation authority.
- The main game loop blocking signals (including
SIGSEGV, handled internally) around each iteration so workers cannot interrupt pulse processing.
Legacy hostname lookup may still use a short-lived child. It is unrelated to persistence and never receives player or world snapshot work.
main() (src/net/comm.c:205) parses flags, then game_loop() (src/net/comm.c:704)
runs until shutdown. Exit codes are meaningful - scripts/cycle_mud.sh
interprets them to decide whether to restart (see RUNBOOK.md).
Command-line options
| Option | Effect |
|---|---|
[port] |
Listen port; must be > 1024. Default 7777 (DFLT_PORT, src/core/config.h:21). |
-C |
Copyover boot - recover player sockets from copyover.dat. |
-m |
Mini mode (reduced area set); also disables ferries. |
-z |
Mini mode with the area debugger on. |
-f |
Disable ferries. |
-l |
Disable random encounters. |
-s |
Suppress special-procedure assignment. |
-p |
Allow password change without the old password. |
-d <dir> |
Data directory (default .). |
--migrate-all |
Migration mode. |
--material-rarity-report[=dir] |
Generate material rarity report and exit. |
Ports
| Port | Purpose | Defined in |
|---|---|---|
| 7777 | Plain telnet (production default) | src/core/config.h:21 |
| 7778 | TLS telnet (SSL_PORT) |
src/core/config.h:22 |
| port+1 | TLS telnet when a non-default port is given | src/net/comm.c |
| 4050 by default | WebSocket and HTTP health listener (DURIS_WEBSOCKET_PORT) |
src/net/websocket.c, src/net/websocket.h |
The explicit ENVIRONMENT, DB_NAME, and DB_ALLOWED_TARGETS settings select and
authorize the database. The port is a second safety boundary: production role requires
7777, and a non-default port redirects a production-like database name to duris_dev
before allow-list validation. A development port is not permission to use an arbitrary
target. See CONFIGURATION.md.
Game loop and timing
game_loop() is a classic pulse-based loop:
- Each iteration blocks at most
OPT_USECmicroseconds (250 ms,src/core/config.h:82) - nominally 4 pulses/second. - Per-pulse work is dispatched by pulse counters using
PULSE_*constants (src/core/config.h:84-91): combat rounds every 16 pulses, mobile updates every 30, ships/vehicles every 2, spellcasting every 9, etc. - Socket readiness comes from
select()over input/output/exception sets. - A per-pulse time budget is enforced between event-wheel callbacks (see below). Because the check happens between callbacks, one slow job overruns the pulse regardless of policy - an expensive callback has to be made cheaper or sliced, not merely deprioritized.
Descriptor structures come from a custom pooled allocator (mm_create("SOCKET", ...)) rather than raw malloc.
Event wheel
Deferred and periodic work runs through the event system (src/world/new_events.c,
src/world/nevent_periodic.c, and the callbacks in src/world/events.c): timed callbacks
with absolute deadlines are stored on a 300-bucket wheel and executed inside
the game loop between pulses. Budget telemetry is exposed via NEVENT BUDGET
log lines and src/persistence/latency_trace.c; NEVENT SLOW marks total scheduler work of
at least 50 ms.
EVENTS.md is the mechanism reference — absolute scheduling, the three intrusive lists, typed payloads and handles, cancellation semantics, periodic ownership, catch-up debt, and configuration. The rest of this section records incident-derived constraints.
Each pulse is bounded by a wall-clock budget (NEVENT_BUDGET_USEC_DEFAULT,
25 ms) and a callback count cap (NEVENT_MAX_CALLBACKS_DEFAULT). Both are
overridable at runtime - see CONFIGURATION.md.
The time budget is meant to be the binding limit; a count cap low enough to end
pulses at half the time budget starves the wheel.
These properties of the wheel are load-bearing:
- Deferral covers the whole unscanned suffix. When a pulse runs out of
budget, every remaining due event moves to the next pulse. Future-revolution
records stay in place. All records retain their absolute
due_tick, so an overload cannot silently add a 75-second revolution to a long timer. - Ordering is stable and starvation-resistant. Records sort by absolute deadline, effective priority, and sequence. Player-timed work has priority by default, while ordinary work ages above it after two deferrals or two late ticks. Deferred count, estimated cost, and oldest deadline are repaid through bounded catch-up quotas.
- Character-wide maintenance is sliced.
generic_char_event(handler.c) swept every character in one callback (17.8 ms average, 24.1 ms peak against a 25 ms budget). It runs in four slices, one per invocation, rescheduled at a quarter of the old delay. The slice is a hash of the character's address, so it is stable for the character's lifetime: every character is still visited exactly once per 20 s, none skipped or done twice. Other heavy periodic jobs use one-tick continuations with stable cursors or runtime-ID snapshots.
Command gate
CharWait() controls PLR2_WAIT (CAN_ACT(ch)) and schedules event_wait
to clear it. It handles scheduling refusal, clamps negative delay, and records
an absolute wait_until_pulse deadline with two seconds of grace. The command
sweep clears a stuck wait when its event is absent or that deadline has passed.
This bounds the wait flag when the loop runs; it cannot prevent whole-loop stalls.
The deadline is runtime-only.
Casting has a separate AFF2_CASTING flag. The selective queue permits abort,
petition, and return past blocked type-ahead, but currently selects that queue
only when both casting and action-wait are active. If the wait clears first,
ordinary input can be dequeued and rejected by the interpreter. This known gap is
tracked in #186;
stateful spell/memorization scheduling failures are tracked separately in
#188.
Movement lifetime guard
src/cmd/actmove.c::do_move() snapshots character_removal_generation before
do_simple_move(). extract_char() and free_char() increment this unsigned
64-bit counter before nested work or teardown. If no removal occurred during
that synchronous call, the post-move global membership scan can be skipped.
Otherwise the original char_in_list() check runs before IS_ALIVE() can
read the mover. Removing an unrelated character must trigger the fallback scan,
not suppress valid post-move work.
The counter belongs to the single game-state thread. New movement-reachable
unlink/free paths must invalidate it before releasing storage. This preserves
pointer-membership semantics, including their existing address-reuse limitation;
it is not an object-identity registry. The separate room-procedure guard in
char_to_room() remains. test_movement_liveness_runtime.py exercises the
production movement tail with synthetic lifecycle outcomes under ASan/UBSan;
test_kingdom_contract.py pins the production invalidation hooks.
Boot sequence
boot_db() (src/world/db.c:406) loads, in order: configuration/properties,
command tables, help command attributes, world files generated by the area
toolchain (areas/world.* - rooms, mobs, objects, zones, quests, shops,
triggers), then boots zones via boot_zones() (src/world/db.c:1523). On a fresh
checkout these combined files are produced by rebuilding the make_* helpers
in areas/src/ and running areas/m_slow (see BUILDING.md).
Database compatibility is checked before boot_db() and before listeners. The main
connection establishes the required charset, UTC time zone, READ-COMMITTED isolation,
strict SQL mode, and bounded timeouts; remote targets require verified TLS. Boot then
verifies the immutable migration chain and complete required schema metadata. Only
after that read-only gate passes does one transaction publish a changed race/class
lookup dataset, followed by item UID reservation and pool initialization. See
RUNTIME_COMPATIBILITY.md.
After world boot, two recovery paths may apply before socket input is accepted:
- Copyover recovery (
copyover_boot,src/persistence/copyover.c): listening sockets and live player connections are re-inherited fromcopyover.dat; combat state is restored bycopyover_restore_combat(). - Redis restart recovery: after a graceful restart or an unclean exit, a world generation is restored only after schema, completeness, sequence, checksum, size, and age validation. The generation and bounded binary floor-item trees are combined into one semantic plan; every custody-bearing item UID/root/parent/VNUM/room is reconciled against SQL before rollback-capable materialization. Authenticated reconstructible world-pop objects carry an explicit non-custody marker, and player corpses remain owned by the separate authoritative corpse restore path. NPC inventory and equipment are deliberately omitted because they are not an authoritative identity-safe source. A fenced one-use sequence marker distinguishes a clean restart from a crash. The prior generation remains authoritative until publication ACK, and matching floor deltas are retained until that ACK. A failed restore performs a full normal zone boot.
Player-load initialization fails existing-character login closed. Before listeners, the runtime also initializes revisioned player saves, critical commands/outbox, and the maintenance scheduler. A failed typed pipeline fences its affected operation; it does not silently convert the action to an unrelated raw SQL queue.
Persistence
MySQL/MariaDB with InnoDB is the durable authority, but persistence is not one generic
queue. Each correctness domain has its own ordering, idempotency, and failure boundary.
The shared bounded connection pool (src/sql/sql_pool.c) establishes the same connection
contract as the main connection. When the pool is unavailable, typed load, snapshot,
critical-command, and maintenance routes report unavailable/retryable outcomes or fence
the affected action; only explicitly retained legacy compatibility producers can use
their historical synchronous fallback.
Existing-character login uses src/player/player_load_pipeline.c and
src/player/player_load_repository.c. A worker opens one consistent read transaction, fetches
required player, skill, affect, item-owner, item metadata, and pet graph rows in bounded
sets, and returns owned typed data. The game thread validates request identity, revision,
limits, graph integrity, and materializes in linear time. Any required-component or
stale result fails login cleanly; a partial character is never published.
Player checkpoints are captured into immutable, revisioned DTOs on the game thread. A bounded append dispatcher durably frames them in the typed journal, then keyed workers apply them transactionally with per-PID ordering and exact revision ACKs. Ordinary mutation and checkpoint routes perform no MySQL, Redis, or filesystem I/O on the simulation thread. Terminal transitions drain to a bounded deadline and require the matching durable outcome before live state may be destroyed; a failed terminal save retains the character and inventory for retry. The journal handoff is durable recovery evidence, not a claim that the database already committed.
Redis complements MySQL with floor-delta tracking and immutable world-recovery
generations (src/world/world_recovery_pipeline.c, src/redis/redis.c). World graph capture is
incremental and bounded on the game thread; the publisher receives owned bytes only.
It writes a sequence-keyed payload before atomically advancing the current pointer and
metadata. Restore validates framing and semantics, reconciles all item custody in one
boot-only SQL transaction, creates entities with rollback tracking, atomically hydrates
runtime custody, and applies doors/zones last. Floor and world item trees share the same
12-node bounded binary representation; gameplay performs no recovery network or SQL I/O.
Non-idempotent gameplay effects use a separate critical-command coordinator
(src/persistence/critical_command_coordinator.c). Its immutable, non-coalescing commands carry a
stable 128-bit operation ID and sorted entity-key set. Conflicting key sets execute in
acceptance order, unrelated sets may run concurrently, and exact typed completion is
required to release a gameplay fence. A checksummed local journal preserves accepted
commands through retry and restart. A typed prepared-statement repository applies each
operation through one InnoDB inbox/state/outbox transaction, resolves duplicate or
ambiguous commits by stable operation ID, and classifies retryable database errors. A
bounded at-least-once dispatcher retains typed outbox rows through delivery, retry,
dead-letter, restart, and operator reconciliation. Epic, account/wallet, item movement,
locker, auction, combat, artifact/guild, boon/reward, and zone-touch domains use typed
repositories rather than unrestricted durable raw SQL messages.
Recurring database work uses src/persistence/maintenance_scheduler.c. Stable per-instance offsets
replace aligned modulus spikes; every job has row and time budgets, continuation state,
bounded retry, and game-thread completion. The lifecycle archive slot remains disabled
because the manifest's controller decisions are still pending.
Details and schema management: DATABASE.md.
Networking
- Telnet (
src/net/comm.c): line-based, with MCCP compression support (src/net/mccp.c). - TLS telnet (
src/net/ssl.c): same protocol over TLS; certificate/key expected asduris.crt/duris.keyin the repository root (symlinks recommended). - WebSocket (
src/net/websocket.c): RFC 6455 server onDURIS_WEBSOCKET_PORT(default 4050) with HTTP upgrade for browser clients and a value-freeGET /healthreadiness response. Production binds the WebSocket listener to loopback behind a TLS reverse proxy and applies an exact browser-origin allow-list. Game messages use JSON (src/core/json_utils.c); the privileged DurisWeb peer uses the one-time challenge contract in api/durisweb.md. - GMCP (
src/net/gmcp.c): outboundRoom.Info,Room.Map,Char.Vitals,Char.Status,Char.Affects,Combat.Update,Comm.Channel,Quest.Status,Quest.Map,Group.Status,Ship.Contacts, andShip.Infopackages to capable clients over telnet or WebSocket.Char.SkillsandChar.Itemsnames are reserved but are not emitted. - Hostname resolution and login/nanny flow are in
src/account/nanny.c; interpreter and command dispatch are insrc/cmd/interp.c.
Terminal height is a saved player preference, not a negotiated connection
property. New characters and missing database values use 40 lines; the
toggle screensize command accepts 12 through 48 (0, default, or off
restores the default), and the pager reserves four lines for prompts and
controls. The server defines
the telnet NAWS option name but does not negotiate or consume NAWS dimensions,
so clients must set the preference manually. Existing characters keep their
saved value. The historical SQL column default of 24 is not the runtime
default; changing saved preferences would require an explicit data migration,
not an edit to sealed migration history.
Studio procs (triggers)
Builder-authored behaviors (mob speech/give/death hooks, boot-time triggers)
are data-driven from areas/world.trg and dispatched by the studio-proc engine
(src/mob/studioproc.c, src/mob/studioproclib.c). Engine hooks are four one-line
call sites added to existing code paths; everything else is table-driven.
Design rationale: STUDIOPROC.md. Builder grammar:
src/howto_trg.txt.
Ships
The ship simulation (sailing, cargo, naval combat, NPC crews/shops) is a
self-contained subsystem under src/ships/, built into its own object files
and linked into the main binary. Ship SQL and optional Redis snapshot routes remain
distinct from the revisioned player-save and critical-command authorities.
Help system
In-game help is database-backed: wiki_help() queries the pages table and
renders wiki-formatted text (src/cmd/wikihelp.c), augmented by command attributes
loaded from docs/lib/information/command_attributes.txt. Pipeline details:
HELP_SYSTEM.md.
Output transport lifetime
src/net/comm.c retains actual transport bytes, including compressed output,
in a bounded descriptor-owned queue. Partial plain/TLS sends and temporary
backpressure leave unsent bytes queued for a later pulse; interrupted TLS records
must resume before new data. Fatal writes or queue-limit violations close the
descriptor, whose teardown releases the buffer. Newline/CP437 conversion uses
bounded dynamic storage because combining individually bounded messages can
exceed a fixed stack buffer.
CON_FLUSH closes only after application, transport, and WebSocket/control
queues drain in the output loop. Logout must deliver its goodbye and server EOF
without another client command. test_telnet_output_runtime.py covers partial
writes, retries, bounds and compression fidelity; the full-world journey checks
account logout before and after process restart.