mud/docs/how/if-integration.txt

676 lines
25 KiB
Text

interactive fiction integration — hosting z-machine games in a MUD
====================================================================
where this fits. the dreambook calls for IF games as in-world content — a room
in the MUD becomes a portal to a z-machine adventure. players enter IF mode,
play through a self-contained story, and other players see "jared is playing
zork in the arcade." fully isolated from world noise, but visible to
spectators in the same room.
the architecture is ready. we have the mode stack, input routing, editor mode
pattern. the missing pieces:
- z-machine interpreter (either embedded or subprocess)
- room-local spectator broadcasting (others see your game output)
- "terminal" game object concept (a room object that hosts IF sessions)
- save/restore integration (quetzal snapshots to sqlite)
this doc synthesizes research on three z-machine implementations and proposes
integration paths.
the z-machine landscape
=======================
three implementations evaluated:
sussman/zvm (python, pure stdlib):
- versions 1-5 claimed, but only ~40% of opcodes implemented
- interpreter HALTS on any unimplemented opcode — can't gracefully degrade
- excellent pluggable I/O: abstract ZUI, ZScreen, ZInputStream, ZOutputStream
- quetzal save/restore infrastructure exists but opcodes are stubbed
- pure python, zero dependencies — perfect for embedding
- blocking run loop, not async-compatible
- clean API: ZMachine(story_bytes, ui=custom_ui).run()
- python 3 port was a "blind fix" — functional but fragile
- verdict: TOO INCOMPLETE. would need 2-4 weeks of opcode implementation
before any real game runs. the I/O architecture is excellent reference
material but the CPU is not production-ready.
viola (python, pygame dependency):
- versions 1-8, comprehensive modern standard 1.1 compliance
- 120+ opcodes implemented — ALL documented z-spec instructions have
implementations
- clean stream architecture: 5 output streams, each individually addressable
- full quetzal 1.4 save/restore support with undo
- active maintenance (2024 copyright, author engaged)
- dependencies: pygame (for rendering/input), numpy (for image processing)
- bundled ififf submodule for blorb/quetzal/IFF handling
- blocking main loop (execloop), module-level global state
- no tests whatsoever
- clean module separation: zcode/ (VM core) vs vio/ (I/O layer)
- ~8,500 lines in zcode/ + ~2,000 in vio/
- verdict: MOST COMPLETE python z-machine available. pygame dependency is
only in vio/ — replace that module with a text-callback I/O backend and
the core VM is usable. strongest candidate for embedding.
mojozork (C, educational implementation):
- version 3 only (rejects other versions with error)
- ~45 opcodes, complete for v3 (covers ~90% of infocom catalog)
- excellent pluggable I/O via function pointer callbacks
- has runInstruction() for single-step execution — async-friendly by design
- multiple frontends: stdio, libretro, SDL3, and a TELNET MULTIPLAYER SERVER
- MultiZork uses sqlite for multiplayer state persistence
- zlib license (very permissive)
- author: ryan c. gordon (well-known open source dev)
- custom save format (not quetzal)
- C only — would need ctypes/CFFI wrapper or subprocess
- verdict: EXCELLENT REFERENCE for architecture patterns. MultiZork shows
exactly how to embed a z-machine in a multiplayer telnet server. v3-only
is limiting but covers classic infocom games. best studied, potentially
wrapped via C extension.
integration options
===================
three paths forward:
option A: embed viola (python native)
- fork or vendor viola's zcode/ module
- replace vio/ with our own I/O backend: MudZUI class
- wrap the blocking execloop with async bridge (run_in_executor)
- state serialization via viola's quetzal support
- pros: pure python, full z-machine 1-8 support, comprehensive opcodes,
native access to VM state
- cons: no tests in upstream, blocking loop requires thread pool,
global state needs refactoring for multiple concurrent games
option B: subprocess dfrozt (quick prototype)
- spawn dfrotz in a subprocess, pipe stdin/stdout
- asyncio subprocess API handles I/O
- save/restore via dfrotz's built-in quetzal support
- pros: FASTEST PATH TO WORKING. dfrozt is battle-tested, z-machine 1-8,
comprehensive game compatibility
- cons: less control over VM state, harder to implement spectators (need to
tee output), subprocess management overhead, can't introspect game state
for GMCP/rich features
option C: write our own (long-term ideal)
- study mojozork and sussman/zvm architectures
- implement enough opcodes for target games (start with v3)
- async-native from the start
- pros: perfect fit for dreambook philosophy, full control, async-native,
clean integration with MUD internals
- cons: SIGNIFICANT DETOUR. weeks/months before first game runs. opcode
implementation is tedious and error-prone. spec compliance is hard.
recommendation: START WITH B (dfrozt subprocess), PLAN FOR A (viola embedding).
rationale: dfrozt gets IF working in hours. players can play zork today. we
learn what the integration needs (spectator broadcasting, save management,
input routing) with real usage. once the architecture is proven, we can
replace the subprocess with embedded viola for better control and state
access. option C remains on the table if viola doesn't fit, but we don't
invest in a VM until we know what the MUD needs from it.
the "terminal" game object
==========================
a terminal is a room object that hosts an IF session. mechanically:
- a terminal has a z-machine story file (path or blob reference)
- when a player enters the terminal, it pushes IF mode onto their stack
- input from the player routes to the z-machine interpreter
- output from the z-machine routes to the player (and spectators)
- the terminal tracks active sessions (who's playing, current state)
example from a player's perspective:
> look
you stand in a neon-lit arcade. rows of terminals hum quietly.
a large screen on the north wall shows "ZORK - The Great Underground
Empire" in green phosphor text.
jared is standing here, absorbed in a terminal.
sarah is watching jared's game on a nearby screen.
> use terminal
you sit down at a terminal. the screen flickers to life.
[IF mode engaged. type 'quit' to exit the game.]
ZORK I: The Great Underground Empire
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
ZORK is a registered trademark of Infocom, Inc.
Revision 88 / Serial number 840726
West of House
You are standing in an open field west of a white house, with a boarded
front door.
There is a small mailbox here.
>
at this point the player's input goes to the z-machine. the terminal object
manages the interpreter subprocess/instance. other players in the room see
"jared is playing zork" in their ambient messages.
spectator broadcasting
======================
key feature from mojozork's MultiZork: others can watch your game.
in the MUD: if sarah is in the same room as jared, she can see his game
output in near-realtime. not keystroke-by-keystroke, but command and response.
implementation:
- terminal object has a set of active_spectators (player refs)
- when the z-machine sends output to the active player, terminal mirrors
that output to all spectators in the same room
- spectators see: "[jared's game] > n\nYou are in a maze of twisty little
passages, all alike."
- spectators can 'watch jared' to start spectating, 'stop watching' to stop
- spectators are in normal mode, not IF mode. they see their own world
events too.
this creates communal play. someone stuck on a puzzle can have friends watch
and suggest commands. speedruns become spectator events. IF becomes social.
technical: output from the z-machine goes to a broadcast function:
def send_to_player_and_spectators(player, output):
player.send(output)
terminal = player.current_terminal
for spectator in terminal.spectators:
if spectator.location == terminal.location:
spectator.send(f"[{player.name}'s game] {output}")
spectators don't share game state, just output. they can't send commands to
someone else's game.
viola embedding details
========================
if we go with option A, here's how viola integration would work:
replace vio/ with MudZUI:
class MudZUI:
"""viola I/O backend that routes to MUD player sessions"""
def __init__(self, player, terminal):
self.player = player
self.terminal = terminal
self.output_buffer = []
def write(self, text):
"""called by VM to send output"""
self.output_buffer.append(text)
def flush(self):
"""send buffered output to player and spectators"""
output = ''.join(self.output_buffer)
self.terminal.broadcast_output(self.player, output)
self.output_buffer.clear()
def read_line(self):
"""called by VM to get player input"""
# return input from player's IF mode input queue
return self.player.if_input_queue.get()
def read_char(self):
"""single-key input for timed events"""
# viola supports this, we'd need to too
return self.player.if_input_queue.get_char()
wrap the blocking VM loop:
async def run_if_game(player, terminal, story_path):
"""run z-machine in thread pool, bridge to async"""
loop = asyncio.get_event_loop()
# load story file
with open(story_path, 'rb') as f:
story_data = f.read()
# create VM with our I/O backend
zui = MudZUI(player, terminal)
zmachine = ZMachine(story_data, zui)
# run in executor (thread pool) since VM loop is blocking
def run_vm():
try:
zmachine.run()
except GameOver:
pass # normal exit
await loop.run_in_executor(None, run_vm)
state management:
- viola has full quetzal save/restore
- when player types 'save', VM writes quetzal IFF to bytes
- we store that blob in sqlite: game_saves table (player_id, terminal_id,
save_slot, quetzal_data, timestamp)
- when player types 'restore', we fetch the blob and pass to VM
- viola handles all the z-machine state serialization
multiple concurrent games:
- viola uses module-level global state in some places
- need to audit zcode/ and refactor globals into instance state
- each terminal object owns a ZMachine instance
- multiple players can play the same game in different terminals
- each gets their own VM instance with independent state
this is work, but manageable. viola's clean separation of zcode/ (VM) from
vio/ (I/O) makes the I/O replacement straightforward. the async bridge is a
standard pattern. the global state refactor is the biggest risk.
dfrozt subprocess details
==========================
if we go with option B for prototyping, here's the implementation:
spawn dfrozt:
async def run_if_game_subprocess(player, terminal, story_path):
"""run dfrotz as subprocess, pipe I/O"""
process = await asyncio.create_subprocess_exec(
'dfrotz',
'-p', # plain output, no formatting
'-m', # disable MORE prompts
story_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# bridge player input to subprocess stdin
async def input_loop():
while process.returncode is None:
command = await player.if_input_queue.get()
process.stdin.write(f"{command}\n".encode())
await process.stdin.drain()
# bridge subprocess stdout to player and spectators
async def output_loop():
while process.returncode is None:
line = await process.stdout.readline()
if not line:
break
text = line.decode('utf-8', errors='replace')
terminal.broadcast_output(player, text)
# run both loops concurrently
await asyncio.gather(input_loop(), output_loop())
await process.wait()
save/restore:
- dfrotz handles saves via its own commands (SAVE, RESTORE)
- save files go to filesystem
- we could intercept SAVE/RESTORE commands, manage files in a
player-specific directory, copy to sqlite for persistence
- or let dfrozt write to /tmp/ and just copy the files on exit
spectator output:
- subprocess output goes to all (player + spectators) via broadcast
- tee is automatic — we control the output loop
exit/cleanup:
- player types 'quit' or EOF — we send QUIT to process, wait for exit
- process crash — we catch it, log error, tell player "game crashed"
- terminal cleanup — kill subprocess if still running
pros: simple, robust, dfrozt is battle-tested.
cons: less control. can't introspect VM state for GMCP data. can't implement
custom opcodes. subprocess overhead (but negligible for text games).
mojozork patterns to study
===========================
MultiZork is mojozork's multiplayer telnet server. it shows:
1. session management: each player gets a separate VM instance, but the game
world is shared. players see each other's actions. this is DIFFERENT from
our model (each terminal is isolated) but the architecture is instructive.
2. sqlite persistence: MultiZork stores game state in sqlite. saves are just
snapshots of the VM memory. we'd do the same with quetzal blobs.
3. command broadcasting: when one player types something, MultiZork echoes it
to others in the same location. same as our spectator model.
4. pluggable I/O: mojozork's I/O is function pointers:
typedef struct {
void (*print)(const char *text);
char *(*read_line)(void);
void (*save_state)(const uint8_t *data, size_t len);
uint8_t *(*restore_state)(size_t *len);
} IOCallbacks;
clean, simple. viola's ZUI is the python equivalent.
5. tick-based execution: MultiZork runs the VM in a loop, checking for input
each tick. same as our game loop. z-machine opcodes are fast — dozens
execute per tick.
MultiZork source is excellent reference for "how to embed IF in a multiplayer
server." if we write our own VM (option C), MultiZork is the template.
save/restore and persistence
=============================
z-machine has built-in save/restore opcodes. quetzal is the standard format.
in our MUD:
- player types SAVE in the IF game
- VM serializes its state to quetzal IFF (binary format)
- we capture that blob and write to sqlite:
CREATE TABLE if_saves (
id INTEGER PRIMARY KEY,
player_id INTEGER NOT NULL,
terminal_id INTEGER NOT NULL,
slot INTEGER DEFAULT 0,
quetzal_data BLOB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, terminal_id, slot)
);
- player types RESTORE, we fetch the blob and pass back to VM
autosave:
- when player exits IF mode (QUIT command or connection drop), we could
autosave their state
- on reconnect/re-enter, offer to resume from last autosave
multiple save slots:
- z-machine games usually support named saves ("SAVE mysave")
- we could parse the filename and use it as the slot identifier
- or just offer slots 1-10 and map them to quetzal blobs
cross-session saves:
- player saves in session A, restores in session B (different terminal)
- works fine if both terminals host the same story file
- could enable "speedrun mode" — reset to a specific save and time from
there
undo:
- z-machine has UNDO opcode
- viola implements it via a stack of quetzal snapshots
- we could surface undo as a MUD command outside the game (safety net if
player makes a mistake)
if mode on the mode stack
==========================
when player enters a terminal, push IF mode:
player.mode_stack.push(Mode.IF)
IF mode behavior:
- all input goes to if_input_queue, not command dispatcher
- world events (chat, movement, ambient) are buffered, not displayed
- combat can't start (you're isolated)
- on exit, pop mode, show summary of buffered events
commands available in IF mode:
- anything the z-machine game accepts (depends on the game)
- special MUD commands prefixed with / or !:
- /quit — exit the game, pop IF mode
- /save [slot] — trigger VM save
- /restore [slot] — trigger VM restore
- /undo — trigger VM undo
- /help — show IF mode help
input routing:
before IF mode: player types "north" → command dispatcher → cmd_move
during IF mode: player types "north" → if_input_queue → z-machine
the mode stack handles this cleanly. IF mode is just another mode, like combat
or editor.
terminal object implementation sketch
======================================
rough structure:
class IFTerminal:
"""a room object that hosts z-machine games"""
def __init__(self, story_path, name, description):
self.story_path = story_path # path to .z3/.z5/.z8 file
self.name = name
self.description = description
self.active_sessions = {} # player -> IFSession
self.spectators = set() # players watching
async def enter(self, player):
"""player sits at terminal, start IF session"""
if player in self.active_sessions:
player.send("You're already playing this game.")
return
# push IF mode
player.mode_stack.push(Mode.IF)
# create session (VM instance or subprocess)
session = await self.create_session(player)
self.active_sessions[player] = session
# announce to room
self.broadcast_except(player, f"{player.name} sits down at {self.name}.")
async def create_session(self, player):
"""spawn VM or subprocess for this player"""
# option A: return ZMachineSession(player, self)
# option B: return DfrotzSession(player, self)
pass
def broadcast_output(self, player, output):
"""send game output to player and spectators"""
player.send(output)
for spectator in self.spectators:
if spectator.location == self.location:
spectator.send(f"[{player.name}'s game]\n{output}")
async def exit(self, player):
"""player leaves terminal, cleanup session"""
session = self.active_sessions.pop(player, None)
if session:
await session.cleanup()
player.mode_stack.pop() # exit IF mode
self.broadcast_except(player, f"{player.name} stands up from {self.name}.")
terminals are room objects. they could be defined in world data:
- rooms:
- id: arcade
description: "a dimly lit arcade. terminals line the walls."
objects:
- type: if_terminal
name: "zork terminal"
story: "games/zork1.z3"
description: "an old CRT displaying 'ZORK I' in green text"
or created by players (builder commands):
> create terminal "hitchhiker terminal" games/hhgg.z5
You create a new IF terminal.
> describe terminal "A sleek modern terminal. The screen reads:
'The Hitchhiker's Guide to the Galaxy - Don't Panic'"
phased implementation plan
===========================
phase 1: dfrozt subprocess prototype
- define IFTerminal class
- implement IF mode on mode stack
- spawn dfrozt subprocess for a hardcoded game (zork1.z3)
- route player input to subprocess stdin
- route subprocess stdout to player
- /quit command to exit
goal: player can sit at a terminal and play zork. no saves, no spectators.
estimate: 1-2 days.
phase 2: spectator broadcasting
- terminal tracks spectators (players in same room)
- tee game output to spectators with prefix
- 'watch [player]' and 'stop watching' commands
goal: others can watch your game in realtime.
estimate: half day.
phase 3: save/restore via filesystem
- intercept SAVE/RESTORE commands
- manage save files in player-specific directory
- copy to sqlite on exit for persistence
goal: players can save progress and resume later.
estimate: 1 day.
phase 4: terminal as room object
- define terminals in world data (YAML)
- load at startup, spawn on player enter
- multiple terminals, different games
goal: arcade room with multiple games, each on its own terminal.
estimate: 1 day.
phase 5: evaluate viola embedding
- fork/vendor viola's zcode/
- implement MudZUI (I/O backend)
- wrap VM loop with run_in_executor
- test with zork1, compare to dfrozt
goal: decide if viola is better than subprocess.
estimate: 2-3 days.
phase 6: switch to viola if proven
- replace dfrozt sessions with viola sessions
- refactor terminal to use embedded VM
- quetzal saves to sqlite (direct, no filesystem)
- expose VM state for GMCP (current room, inventory, score)
goal: full control over VM, native python integration.
estimate: 2-3 days (assuming viola works).
phase 7: builder tools
- 'create terminal' command for admins/builders
- upload .z3/.z5/.z8 files to server
- terminal object editor (change description, story file)
goal: builders can add new IF games without code changes.
estimate: 2 days.
total estimate: 10-15 days for full IF integration with all features.
if we stop after phase 4, we have working IF-in-MUD with spectators and saves.
that's enough to prove the concept. phases 5-7 are refinement and tooling.
what we learn from this
========================
IF integration exercises several MUD systems:
- mode stack: IF mode is the third mode (after normal and editor). proves
the stack abstraction works for diverse isolation needs.
- session management: each terminal session is a persistent object (VM or
subprocess) tied to a player. different from stateless command dispatch.
- room-local broadcasting: spectator output is the first use of "broadcast
to players in this room." will be useful for ambient messages, weather,
room events later.
- binary blob persistence: quetzal saves are blobs in sqlite. same pattern
will apply to uploaded files, cached terrain chunks, whatever.
- game objects with behavior: terminals are room objects that do things
(spawn VMs, route I/O). pattern extends to doors, NPCs, triggers.
getting IF right means the architecture can handle editor mode, crafting
mini-games, puzzles, dialogue trees, anything with isolated state and
custom input handling.
risks and unknowns
==================
viola's lack of tests:
we'd be trusting ~10k lines of untested code. bugs in opcode implementation
would surface as "game doesn't work" with no clear cause. mitigation: test
with a suite of known games (zork, hhgg, curses, anchorhead) before
committing. if bugs emerge, consider dfrozt as the long-term solution.
async bridge overhead:
viola's blocking loop in a thread pool adds latency. run_in_executor is
designed for this but it's not zero-cost. mitigation: measure latency in
prototype. if it's perceptible (>100ms), consider writing an async-native
VM or sticking with subprocess.
global state in viola:
haven't audited zcode/ for globals yet. if it's pervasive, the refactor to
instance state could be large. mitigation: audit before committing to viola.
if globals are deeply baked in, subprocess is safer.
z-machine version support:
viola claims 1-8, mojozork does 3 only. most infocom classics are v3 or v5.
modern IF (inform 7) compiles to v8. if we only care about classics, v3 is
enough. if we want modern games, need v8. mitigation: decide target game
set before choosing VM.
spectator bandwidth:
if 10 players are watching one game, every line gets sent 11 times (player
+ 10 spectators). that's fine for text but worth monitoring. mitigation:
spectator output could be rate-limited or summarized (show every 5 lines,
not every line).
save file size:
quetzal saves are typically 20-100KB. if player saves frequently, sqlite
grows. mitigation: limit saves per player per game (10 slots?), auto-prune
old saves, or compress quetzal blobs (they're IFF, should compress well).
closing
=======
IF-in-MUD is not a detour. it's a proof that the architecture supports
isolated, stateful, player-driven content. the mode stack, room objects,
save/restore, multiplayer isolation — all tested in a concrete use case.
start with dfrozt subprocess (phase 1-4). prove the concept. if it works,
consider viola for tighter integration. if viola is risky, subprocess is
fine long-term. mojozork shows that C via subprocess is viable.
the dream: player-created IF games, built in the editor, tested in-world,
published for others. that requires a DSL and builder tools. but the
foundation is terminals + mode stack + save/restore. get that right and the
dream is achievable.
code
----
this document docs/how/if-integration.txt
dreambook DREAMBOOK.md
architecture docs/how/architecture-plan.txt