========================================== multiplayer z-machine — shared world design ========================================== level 4 means multiple MUD players sharing one z-machine instance. one game world, multiple explorers. MojoZork proved this works for Zork 1 with 4 concurrent players over telnet. this doc designs how it works in our Python interpreter, using MojoZork's lessons but improving on its limitations. see ``docs/how/mojozork-audit.rst`` for the full MojoZork technical analysis. see ``docs/how/if-journey.rst`` for the five-level vision (this is level 4). why this is hard ================ the z-machine is a single-player virtual machine. one program counter, one stack, one set of global variables. the game assumes there's one "you" in the world. multiplayer means faking multiple players inside a system that was never designed for it. MojoZork solved this with brute force: swap the entire execution context (PC, stack, globals) every time a different player takes a turn. it works, but it's hardcoded to one build of Zork 1 — 10 specific global variable indices, 6 bytecode patch addresses, one known object layout. our design keeps the same core trick (context swapping) but wraps it in a config-driven system that can support multiple games. the core trick: context swapping ================================ the z-machine blocks on input (``op_sread`` in V3, ``op_aread`` in V5+). when it blocks, the entire interpreter state is frozen: PC points past the read instruction, the stack is stable, globals hold current values. this is the natural swap point. the multiplayer loop:: 1. player sends a command 2. restore that player's saved context (PC, stack, globals, object data) 3. feed the command to the interpreter 4. run until the next READ (interpreter blocks again) 5. capture output, save context back out 6. repeat for next player with input this is what MojoZork does. it's also what we'll do. the z-machine is inherently single-threaded — no way around processing one player at a time. shared vs per-player state ========================== which z-machine state is shared, and which needs per-player copies? ======================== ========= ================ ================================ state shared? per-player copy? notes ======================== ========= ================ ================================ static/high memory yes no code and strings, immutable dynamic memory yes no world state (object tree, etc) program counter no yes each player paused at own READ call stack + locals no yes different execution contexts data stack no yes operand stack diverges player-specific globals no yes location, deaths, flags, etc player object entry no yes parent/attrs/properties touchbits (visited rooms) no yes each player's exploration history PRNG state yes no shared randomness is fine ======================== ========= ================ ================================ this matches MojoZork's split exactly. the key insight: dynamic memory is ONE copy shared by all players. most of the game world (objects, rooms, items, puzzle state) is shared. only the player's execution state and a handful of "this is about me specifically" globals need per-player copies. the consequence: when player A picks up the brass lantern, player B can't pick it up either. the object tree is shared. this is correct — it's the same world. but when player A is in the kitchen and player B is in the cellar, they each have their own location global. the game "sees" each player in their own room during their turn. player object strategy ====================== each player needs a z-machine object to exist in the world — to have a location, to hold inventory, to be visible to other players. **V3 (Zork 1):** 255 max objects, Zork uses ~250. objects 251-255 are available as player slots. this limits V3 to ~5 concurrent players. **V5/V8 (Lost Pig, etc):** 65535 max objects. even a large game uses a few thousand at most. tens of thousands of spare slots. MojoZork virtualizes player objects — stores them outside the real object table and intercepts all object access with function pointer hooks. this is clever but adds complexity and tight coupling to every object opcode. **our approach: use real object table slots.** for V3, claim spare slots at the top of the range (251+). for V5/V8, claim a block starting well above the game's highest object. player objects live in the real object table, initialized with appropriate parent (starting room), attributes (visible), and properties (name, description). advantages over MojoZork's virtualization: - no function pointer hooks on every object access - standard object opcodes work unmodified - properties live in real memory (no fake address ranges) - simpler code, fewer places for bugs disadvantages: - requires knowing the game's max object count (to avoid collisions) - V3 is limited to ~5 players (real constraint, but acceptable) - must write object data into dynamic memory during initialization to determine available slots, the game config specifies a safe range, or we scan the object table at startup to find the highest used object. comparison with MojoZork:: MojoZork our approach ───────────────────────────────── ────────────────────────────────── virtual objects outside table real objects in table function pointer hooks no hooks needed fake address range (0xFE00+) real memory addresses hardcoded to Zork objects 251-254 config-driven range per game 4 player limit (hardcoded) V3: ~5, V8: effectively unlimited identifying player-specific state ================================= the hardest part of supporting a new game: figuring out which global variables need per-player copies. MojoZork hardcodes 10 Zork 1 globals (location, deaths, lit, verbose, etc). that works for one game but doesn't scale. **our approach: per-game TOML config files.** each supported game gets a multiplayer config that maps globals to meanings:: # content/stories/zork1.multiplayer.toml [game] name = "zork1" version = 3 story_file = "zork1.z3" [player_object] original_id = 4 # the game's built-in "player" object start_room = 180 # West of House (object ID) room_container = 82 # parent of all rooms [player_globals] # global variable indices that need per-player copies # index = human-readable name (for debugging/logging) 0 = "location" 60 = "lucky" 61 = "deaths" 62 = "dead" 66 = "lit" 70 = "superbrief" 71 = "verbose" 72 = "alwayslit" 133 = "loadallowed" 139 = "coffin_held" [attributes] invisible = 7 # attribute to hide player during swap touchbit = 3 # room-visited flag (per-player) [player_slots] start = 251 # first available object ID for players max = 255 # last available (V3 ceiling) [bytecode_patches] # addresses where the game hardcodes the player object ID. # during swap, these bytes get patched to the current player's object ID. # found by disassembling and searching for references to object 4. addresses = [0x6B3F, 0x93E4, 0x9411, 0xD748, 0xE1AF, 0x6B88] how to create these configs for new games: 1. **location global** — always global 0 (``read_global(0x10)``). this is standard across all z-machine games. 2. **other player globals** — requires disassembly and analysis. look for globals that change on room entry, death, or mode switches. the ``scripts/zmachine_inspect.py`` tool can help, and we could build a tracing tool that logs global writes during single-player play. 3. **bytecode patches** — search disassembly for literal references to the player object ID. not all games will need these (some reference the player via a global, not a literal). 4. **object slots** — load the game, count objects, pick a safe range above the highest used object. this is manual work per game. but multiplayer is inherently game-specific — each game makes different assumptions about the player. a config file is the honest way to capture those assumptions. future tooling (not in scope for initial implementation): - a tracing mode that logs all global writes during play, flagging candidates for per-player globals - a disassembler that searches for literal object ID references - a test harness that runs two contexts against the same game and detects state conflicts the turn model ============== MojoZork uses implicit round-robin: whoever sends input next gets their turn. there's no explicit queue or scheduling. **our approach: input queue, process one at a time.** :: SharedIFSession ├── input_queue: asyncio.Queue[(player_name, command)] ├── processing_lock: asyncio.Lock └── tick(): while not input_queue.empty(): player, command = input_queue.get() swap_in(player) feed(command) run_until_read() capture_output(player) swap_out(player) when a MUD player types a command in IF mode, it goes into the shared input queue. the session processes queued commands one at a time in FIFO order. latency: in a MUD, players expect immediate response. with context swapping, each player's command takes one z-machine "turn" to process (typically milliseconds — our interpreter runs thousands of instructions per ms). even with 4-5 players, the queue drains in well under 100ms. this is fast enough that players won't notice the serialization. if latency becomes a problem (unlikely), we could process the queue on every MUD tick rather than waiting for it to drain, giving each player one turn per tick in round-robin order. comparison with MojoZork:: MojoZork our approach ───────────────────────────────── ────────────────────────────────── implicit (whoever sends input) explicit FIFO queue blocking telnet read per player async queue, non-blocking single-threaded C async Python (event loop) no fairness guarantee FIFO ordering data structures =============== the core types for multiplayer:: @dataclass class PlayerContext: """per-player execution state for a shared z-machine.""" player_name: str mud_player: Player # reference to MUD player entity # execution state (saved when swapped out) program_counter: int call_stack: list[ZRoutine] # full call frame chain data_stack: list[int] # operand stack # player-specific z-machine state player_globals: dict[int, int] # global index -> value player_object_id: int # our object in the z-machine object_entry: bytes # saved object table entry property_data: bytes # saved property table touchbits: bytearray # visited-room flags # i/o output_buffer: list[str] # accumulated output this turn # session hash_code: str # for reconnection (6 chars) joined_at: float # timestamp class SharedIFSession: """multiplayer z-machine session — one game, many players.""" zmachine: ZMachine # single shared interpreter story_name: str game_config: GameMultiplayerConfig contexts: dict[str, PlayerContext] # player_name -> context current_player: str | None # who's executing right now input_queue: asyncio.Queue # (player_name, command) pairs spectators: set[Player] # watchers who aren't playing # persistence save_path: Path moves_since_save: int the ``GameMultiplayerConfig`` is loaded from the TOML file described above. it holds the per-game mappings (player globals, object slots, patches, etc). the swap in detail ================== **swap_in(player_name)** — restore a player's context before their turn:: 1. load PlayerContext for this player 2. restore PC: opdecoder.program_counter = ctx.program_counter 3. restore stack: stackmanager.restore(ctx.call_stack, ctx.data_stack) 4. restore player-specific globals: for idx, value in ctx.player_globals.items(): memory.write_global(idx + 0x10, value) 5. restore player object entry: write ctx.object_entry to object table at ctx.player_object_id write ctx.property_data to property table address 6. restore touchbits: for each room object, set/clear touchbit attribute from ctx.touchbits 7. apply bytecode patches: for each patch address in game_config: memory[addr] = ctx.player_object_id (low byte) 8. mark other players' objects as invisible (set invisible attribute) **swap_out(player_name)** — save a player's context after their turn:: 1. save PC: ctx.program_counter = opdecoder.program_counter 2. save stack: ctx.call_stack, ctx.data_stack = stackmanager.snapshot() 3. save player-specific globals: for idx in game_config.player_globals: ctx.player_globals[idx] = memory.read_global(idx + 0x10) 4. save player object entry: ctx.object_entry = read object table at ctx.player_object_id ctx.property_data = read property table 5. save touchbits: for each room, ctx.touchbits[i] = get_attr(room, touchbit_attr) 6. clear visibility hacks (remove invisible from other players) the swap is fast. it's mostly memcpy-equivalent operations. the touchbit loop is O(rooms) but rooms are typically < 300. output routing ============== during a player's turn, the z-machine generates text via the UI callback. this text should go to that player. but other players might also need to see certain things (someone entering their room, items being taken, etc). **basic output routing:** all z-machine output during player A's turn goes to player A's output buffer. after the turn, flush the buffer to their MUD connection. **broadcast messages:** when a player takes an action visible to others (enters a room, picks up an item), the MUD layer generates broadcast messages for nearby players. this happens at the MUD level, not the z-machine level. :: after swap_out(player_a): old_room = previous_location new_room = ctx.player_globals[0] # location if old_room != new_room: broadcast_to_room(old_room, f"{player_a} leaves.") broadcast_to_room(new_room, f"{player_a} arrives.") **spectators:** non-playing watchers see all output from all players, prefixed with the player's name. same pattern as current single-player spectator support, extended to multiplex output streams. MUD integration =============== how does a multiplayer IF session look from the MUD side? **entering a shared session:** a terminal object in the MUD world hosts the shared game. players "use" the terminal to join. the first player creates the session; subsequent players join the existing one:: > use terminal [joining Zork I — 2 players already exploring] [you are player 3, starting at West of House] the player's mode stack pushes "shared_if". input routes to the shared session's input queue instead of the MUD command dispatcher. **leaving and rejoining:** players can leave with ``::quit`` (same as single-player). their context is preserved — the player object stays in the world (marked inactive/invisible), and their state is saved. they can rejoin later and resume where they left off. MojoZork uses a 6-char hash code for reconnection. we can do the same, or just use the player's MUD identity (they're already authenticated). **when the last player leaves:** the session enters a dormant state. the shared z-machine state (dynamic memory) is saved to disk along with all player contexts. when someone rejoins, the session is restored. option: keep the session alive for some grace period (5 minutes?) before saving and tearing down, in case players are just briefly disconnecting. **spectators:** work the same as single-player spectators. watchers see multiplexed output from all active players, prefixed with player names. spectators can't send commands to the z-machine. persistence =========== MojoZork saves to SQLite (instance state + per-player state + transcripts). we already have Quetzal save/restore for single-player. for multiplayer we need both the shared state and per-player contexts. **what to save:** - shared dynamic memory (one copy, same as Quetzal CMem/UMem) - per-player: PC, call stack, data stack, player globals, object data, touchbits - session metadata: game name, player list, creation time **format options:** 1. **extend Quetzal** — add custom chunks for per-player data. Quetzal is extensible (IFF format). but it's designed for single-player saves. 2. **separate files** — one Quetzal-like file for shared state, one file per player for their context. simpler but more files to manage. 3. **SQLite** — like MojoZork. one row per session, BLOBs for state. integrates with our existing player database. **recommendation:** SQLite, following MojoZork's proven approach. we already use SQLite for player accounts. add tables for multiplayer sessions and player contexts. BLOBs for binary state (memory, stacks). **autosave:** every N moves (MojoZork uses 30). also save on player join/leave and session dormancy. game candidates =============== not all IF games make sense for multiplayer. criteria: - exploration-heavy (many rooms to discover) - items and puzzles that can be shared or divided - enough physical space for multiple players - not deeply single-protagonist (tight narrative, personal journey) **good candidates:** 1. **Zork I** (V3) — the original. large map, many items, exploration-focused. MojoZork already proved this works. our first target. 2. **Lost Pig** (V8) — single location focus (farm/caves), but funny and interactive. orc protagonist gives natural "party of adventurers" vibe. V8 format means plenty of object slots. 3. **Zork II / Zork III** (V3) — similar to Zork I. would need their own multiplayer configs but the gameplay pattern works. 4. **Enchanter trilogy** (V3/V5) — magic system could make cooperative play interesting (different players learn different spells). **poor candidates:** - tightly scripted narrative (Photopia, Shade) - real-time or timing-sensitive games - games with one-protagonist arcs (most modern literary IF) - Glulx games (out of scope — different VM entirely) **what we have now:** ``content/stories/zork1.z3`` and ``content/stories/LostPig.z8``. Zork I is the obvious first target since MojoZork provides a reference implementation to validate against. implementation plan =================== in rough dependency order: **phase 1: game config and analysis tools** - define the multiplayer TOML config format - write ``content/stories/zork1.multiplayer.toml`` (translating MojoZork's hardcoded values) - extend ``scripts/zmachine_inspect.py`` with object counting and global-tracing modes - analyze Lost Pig for multiplayer config (object count, candidate globals) **phase 2: context swapping** - implement ``PlayerContext`` dataclass - implement ``swap_in()`` / ``swap_out()`` on the interpreter - add ``stackmanager.snapshot()`` and ``stackmanager.restore()`` methods - add player object initialization (write new objects into table) - test: two contexts alternating on Zork 1, verify independent locations **phase 3: shared session** - implement ``SharedIFSession`` (the multiplayer equivalent of ``EmbeddedIFSession``) - input queue and FIFO processing - output routing (per-player buffers) - broadcast messages for cross-player events (room entry/exit) **phase 4: MUD integration** - shared terminal object that hosts multiplayer sessions - join/leave/rejoin flow - mode stack integration (``shared_if`` mode) - spectator support (multiplexed output) **phase 5: persistence** - SQLite schema for multiplayer sessions - save/restore shared state + per-player contexts - autosave logic - dormant session management (save on last leave, restore on rejoin) **phase 6: Lost Pig support** - analyze Lost Pig for multiplayer config - write ``LostPig.multiplayer.toml`` - test with V8 object slots (high-range allocation) - validate context swapping works for V8/V5 (aread vs sread differences) comparison table ================ ================================ ============================== ============================== concern MojoZork our design ================================ ============================== ============================== player objects virtual (outside object table) real (in object table) object access function pointer hooks standard opcodes, no hooks player globals 10 hardcoded indices TOML config per game bytecode patches 6 hardcoded addresses TOML config per game player limit 4 (hardcoded) V3: ~5, V8: hundreds game support one build of Zork 1 config-driven, multiple games turn model implicit (whoever sends input) explicit FIFO queue output routing callback to current connection per-player buffer + broadcast persistence SQLite (memory BLOBs) SQLite (same approach) reconnection 6-char hash code MUD identity (already authed) error recovery longjmp + instance destroy exception handling + save state language C (single file, 2500 lines) Python (multiple modules) ================================ ============================== ============================== known limitations ================= things we're explicitly not solving in the initial implementation: - **no generic multiplayer** — each game needs manual analysis and a config file. there's no way around this; games make game-specific assumptions. - **no concurrent execution** — one player at a time. the z-machine is single-threaded. serialized turns are the only safe model. - **V3 player limit** — roughly 5 players max due to the 255-object ceiling. acceptable for the MUD use case (small group exploration). - **no cross-player item interaction** — if player A drops an item in a room and player B is in that room, B won't see it until their next turn (when the shared object tree is visible to their context). this is a fundamental consequence of turn-based context swapping. - **game-specific bytecode patches** — some games hardcode the player object ID in z-code instructions. these patch addresses must be found manually via disassembly. not all games will need them. - **save_undo** — currently stubbed as "not available" in our interpreter. multiplayer makes undo even harder (whose undo?). punting entirely. - **dictionary injection** — level 3 feature (adding new words to the game). orthogonal to multiplayer but would enhance it. not in scope here. - **Glulx games** — different VM, out of scope. only z-machine V3/V5/V8. open questions ============== things to resolve during implementation: 1. **stackmanager snapshot format** — do we deep-copy ZRoutine objects, or serialize to bytes? deep copy is simpler; bytes are more compact for persistence. probably start with deep copy, optimize later. 2. **initial player setup** — when a new player joins mid-game, where do they start? MojoZork puts everyone at West of House. we could let the config specify a start room, or always use the game's default start. 3. **player object properties** — what name and description does a player object get? MojoZork uses "Adventurer" plus a number. we could use the MUD player's name, but that requires writing a z-machine string into the property table. 4. **item conflict** — if player A is carrying an item and player B tries to take it during their turn (shared object tree), what happens? the game will say "you can't see that here" because the item's parent is player A's object, not the room. this is probably fine — the z-machine handles it naturally. 5. **scoring** — most games track score in a global. is score per-player or shared? MojoZork doesn't address this. probably per-player (add to player_globals config).