622 lines
25 KiB
ReStructuredText
622 lines
25 KiB
ReStructuredText
==========================================
|
|
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).
|