MUD Programming: How to Build and Script a Text Game
A MUD is not merely a command-line adventure: it is a long-running multiplayer server whose world continues while many players issue commands concurrently. The safest way to learn MUD programming is to build one narrow vertical slice—connect, enter a room, move, speak, save, and reconnect—before adding combat or content tools. This guide uses TypeScript-flavored examples, but the boundaries apply equally to established C, LPC, Python, Java, and Go codebases.

Define the server boundaries before the game rules
Separate transport, sessions, commands, world state, persistence, and content scripts. The transport turns bytes into complete lines; a session owns authentication and output; commands request domain actions; the world applies those actions. This division keeps Telnet negotiation or a future WebSocket gateway out of combat and room code.
export interface Session {
id: string;
characterId?: string;
send(text: string): void;
}
export interface CommandContext {
session: Session;
world: World;
}
export type Command = (ctx: CommandContext, args: string[]) => Promise<void>;⚠ Common Pitfalls
- •Letting socket objects leak into world entities makes offline tests difficult
- •Starting with classes, races, crafting, and combat before a player can reconnect creates a wide but unusable prototype
Build a line-oriented connection loop with explicit limits
Accept connections, buffer partial input, split complete lines, and cap both line length and commands per interval. Telnet is a byte protocol with option negotiation, so a production server should use a tested Telnet parser rather than treating every received chunk as one command. Begin locally with plain lines, then put protocol handling behind the transport boundary.
const MAX_LINE = 1024;
function receive(chunk: string) {
buffer += chunk.replace(/\r\n/g, '\n');
if (buffer.length > MAX_LINE * 4) return disconnect('input overflow');
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line.length > MAX_LINE) return disconnect('line too long');
enqueueCommand(session.id, line);
}
}⚠ Common Pitfalls
- •Assuming one network chunk equals one command loses split commands and merges batched commands
- •Rendering untrusted control characters back to players enables terminal escape abuse
Parse commands without embedding game logic in the parser
Normalize the verb, preserve arguments, resolve only unambiguous abbreviations, and dispatch through a command registry. Authorization belongs in each command or a shared guard. Keep the parser deterministic so quoted speech, aliases, and future client protocols can be tested as data.
const commands = new Map<string, Command>();
export async function dispatch(ctx: CommandContext, input: string) {
const [rawVerb = '', ...args] = input.trim().split(/\s+/);
const verb = rawVerb.toLowerCase();
const matches = [...commands.keys()].filter(name => name.startsWith(verb));
if (matches.length !== 1) return ctx.session.send(matches.length ? 'Be more specific.\n' : 'Unknown command.\n');
await commands.get(matches[0]!)!(ctx, args);
}⚠ Common Pitfalls
- •Choosing the first prefix match makes abbreviations change meaning when commands are added
- •Passing raw player text into eval, a shell, SQL, or format strings creates critical injection paths
Represent the world as stable IDs and validated exits
Store rooms and characters by stable identifiers, not direct object references in serialized data. Movement should be one atomic world operation: verify the exit, update membership, and then emit departure, arrival, and room views. Validate at load time that every exit target exists.
type Room = { id: string; name: string; description: string; exits: Record<string, string> };
function move(characterId: string, direction: string) {
const actor = characters.get(characterId)!;
const from = rooms.get(actor.roomId)!;
const destinationId = from.exits[direction];
if (!destinationId || !rooms.has(destinationId)) return false;
actor.roomId = destinationId;
emit({ type: 'character.moved', characterId, from: from.id, to: destinationId });
return true;
}⚠ Common Pitfalls
- •Saving in-memory pointers makes migrations and reloads fragile
- •Updating a character before validating the destination can strand it in a nonexistent room
Serialize mutations through an event loop
A single ordered queue is a strong starting model for a small MUD. Network callbacks enqueue intents; the loop processes them sequentially and schedules timers against a monotonic clock. Slow database or HTTP work must not freeze the loop, but its completion should return as another event whose target is revalidated.
const queue: Array<() => Promise<void>> = [];
let draining = false;
export function enqueue(job: () => Promise<void>) {
queue.push(job);
if (!draining) void drain();
}
async function drain() {
draining = true;
try { while (queue.length) await queue.shift()!(); }
finally { draining = false; }
}⚠ Common Pitfalls
- •Starting overlapping tick handlers introduces duplicate attacks and inconsistent inventories
- •Holding entity references across awaited work lets stale actions affect characters that moved or disconnected
Persist accounts and characters with versioned data
Hash passwords with a purpose-built password hashing algorithm supplied by a maintained library; never encrypt or hash them with a fast general-purpose digest. Save character state transactionally, add a schema version, and keep world definitions separate from player progress. Test migrations using copies of real-shaped data.
type CharacterRecord = {
schemaVersion: 1;
id: string;
accountId: string;
name: string;
roomId: string;
createdAt: string;
};
await db.transaction(async tx => {
await tx.characters.upsert(character);
await tx.audit.append({ type: 'character.saved', characterId: character.id });
});⚠ Common Pitfalls
- •Writing whole JSON files in place risks truncation during a crash
- •Persisting session tokens, socket state, or plaintext passwords turns routine backups into security liabilities
Expose a narrow, capability-based scripting API
Content scripts should receive approved operations such as send, spawn, schedule, and query—not the database, filesystem, process, or network. Put scripts behind time and resource limits and pin their API version. If you cannot sandbox the language safely, use declarative triggers or run scripts in a separate constrained process.
export interface RoomScriptApi {
sendToRoom(roomId: string, message: string): void;
move(characterId: string, direction: string): boolean;
schedule(delayMs: number, event: ScriptEvent): void;
}
// Content receives only this frozen capability object.
runScript(source, Object.freeze({ sendToRoom, move, schedule }), { timeoutMs: 25 });⚠ Common Pitfalls
- •JavaScript eval and Node VM contexts are not a security boundary for hostile code
- •Allowing scripts to keep live world objects across reloads creates memory leaks and stale state
Test transcripts, invariants, and failure recovery
Unit-test parsing and movement, then run transcript tests that feed commands through a fake session and compare normalized output. Add invariants: every character occupies one valid room, inventory ownership is unique, and failed commands do not mutate state. Finally, kill the process during a save and verify recovery from the last committed state.
it('does not move through a missing exit', async () => {
const before = snapshot(world);
await dispatch(fakeContext, 'north');
expect(snapshot(world)).toEqual(before);
expect(output).toContain('cannot go that way');
});⚠ Common Pitfalls
- •Snapshotting timestamps and unstable IDs makes transcript tests noisy
- •Testing handlers directly while bypassing the real queue misses ordering bugs
Deploy defensively and observe the live loop
Run as an unprivileged user behind a firewall, expose only the intended port, automate backups, and test restore procedures. Log structured session and command outcomes without passwords or private messages. Track queue depth, event-loop delay, active sessions, command errors, save latency, and restart count; graceful shutdown should stop new logins, drain bounded work, save, and exit.
Release check:
- restore the latest backup into a staging instance
- run schema migrations and smoke transcripts
- cap connections, input length, and command rate
- verify graceful shutdown and restart
- alert on queue depth, loop delay, save failures, and disk space⚠ Common Pitfalls
- •Logging raw credentials, private messages, or tokens creates a secondary data breach
- •Backups that have never been restored are not a recovery plan
What you built
The useful milestone is not a sprawling feature list; it is a recoverable server where two players can connect, move through validated rooms, communicate, disconnect, and return to consistent state. Once that slice has transcript tests and operational metrics, add one system at a time behind the same boundaries. Established codebases remain valuable references, but understanding transport, ordered state changes, persistence, and constrained scripting will make any MUD codebase easier to extend safely.