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