Add multiplayer zmachine design notes

This commit is contained in:
Jared Miller 2026-02-10 16:09:33 -05:00
parent 1b3a3646d6
commit 8288b2535a
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -0,0 +1,622 @@
==========================================
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).