Add an architectural plan
This commit is contained in:
parent
2c75d26d68
commit
0f05302b6e
1 changed files with 372 additions and 0 deletions
372
docs/how/architecture-plan.txt
Normal file
372
docs/how/architecture-plan.txt
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
architecture plan — designing for programmability
|
||||
====================================================
|
||||
|
||||
where we are and where we're going. we have a working telnet MUD with
|
||||
procedural terrain, movement, viewport rendering, effects. combat, persistence,
|
||||
session modes, editor, in-world programming — all ahead of us. this doc
|
||||
captures what we should get right in the foundation so those features don't
|
||||
require rewrites.
|
||||
|
||||
the core lesson from studying the ecosystem: the things that lasted (LPC
|
||||
mudlibs, MOO worlds, evennia typeclasses) all share one trait — game content
|
||||
is data that can be inspected and modified at runtime, not hardcoded logic.
|
||||
the things that didn't last hardcoded everything.
|
||||
|
||||
|
||||
the engine/content boundary
|
||||
============================
|
||||
|
||||
the single most important architectural decision. everything downstream
|
||||
depends on this.
|
||||
|
||||
what the engine provides (python code, changes require restart):
|
||||
- telnet server, protocol handling, I/O
|
||||
- the game loop (tick processing)
|
||||
- terrain generation and world loading
|
||||
- rendering pipeline (viewport, ANSI, effects)
|
||||
- the command dispatcher (not the commands themselves)
|
||||
- the mode stack mechanism
|
||||
- the DSL interpreter/runtime
|
||||
- persistence layer (sqlite)
|
||||
- player session management
|
||||
|
||||
what content provides (data files + DSL, hot-reloadable):
|
||||
- individual commands (move, look, attack, build...)
|
||||
- combat moves, timing windows, damage formulas
|
||||
- NPC behaviors and dialogue
|
||||
- room/area descriptions and triggers
|
||||
- IF games and puzzles
|
||||
- item definitions and interactions
|
||||
- channel definitions
|
||||
- help text
|
||||
|
||||
the boundary: the engine loads and executes content. content never imports
|
||||
engine internals directly — it uses a stable API surface. this is the LPC
|
||||
model adapted for python.
|
||||
|
||||
why this matters now: our current commands are python functions that import
|
||||
player.py and world/terrain.py directly. that's fine for bootstrapping but it
|
||||
means every command is hardcoded. we need to decide: do commands stay as
|
||||
python modules (evennia model) or become data-defined (MOO/LPC model)?
|
||||
|
||||
recommendation: HYBRID. core commands (movement, look, system admin) stay as
|
||||
python. game commands (combat moves, social commands, builder tools, IF verbs)
|
||||
are defined in a DSL or data format. the command registry already exists — we
|
||||
just need it to accept both python handlers AND data-defined handlers.
|
||||
|
||||
|
||||
the content definition format
|
||||
==============================
|
||||
|
||||
commands, combat moves, NPC behaviors, room triggers — they all need a
|
||||
definition format. options:
|
||||
|
||||
YAML/TOML (pure data):
|
||||
works for: item stats, terrain configs, channel definitions
|
||||
doesn't work for: anything with logic (combat timing, triggers, conditionals)
|
||||
|
||||
python modules (current approach):
|
||||
works for: anything
|
||||
doesn't work for: in-MUD editing, hot-reload without restart, non-programmer
|
||||
builders
|
||||
|
||||
a DSL (domain-specific language):
|
||||
works for: logic + data in a safe sandbox
|
||||
this is what the dreambook calls for
|
||||
|
||||
the DSL doesn't need to exist yet. but the content format should be designed
|
||||
so that when the DSL arrives, it can express the same things that data files
|
||||
express now. that means:
|
||||
|
||||
- commands defined as declarations (name, aliases, help text) + a body
|
||||
(what happens)
|
||||
- combat moves as declarations (name, stamina cost, timing window, telegraph
|
||||
message) + resolution logic
|
||||
- triggers as event + condition + action triples
|
||||
- descriptions as templates with conditional sections
|
||||
|
||||
for now: use YAML for declarations, python callables for bodies. the YAML
|
||||
says WHAT something is, python says WHAT IT DOES. when the DSL arrives, the
|
||||
python callables get replaced with DSL scripts. the YAML stays.
|
||||
|
||||
example of what a command definition might look like:
|
||||
|
||||
name: shout
|
||||
aliases: [yell]
|
||||
help: shout a message to nearby players
|
||||
mode: normal
|
||||
body: python:commands.social.shout
|
||||
# later: body: dsl:shout.mud
|
||||
|
||||
example combat move:
|
||||
|
||||
name: roundhouse
|
||||
type: attack
|
||||
stamina_cost: 15
|
||||
telegraph: "{attacker} shifts their weight to one leg"
|
||||
timing_window_ms: 800
|
||||
damage_pct: 12
|
||||
countered_by: [duck, jump]
|
||||
body: python:combat.moves.roundhouse
|
||||
# later: body: dsl:combat/roundhouse.mud
|
||||
|
||||
|
||||
command registry evolution
|
||||
===========================
|
||||
|
||||
current: dict mapping strings to async python functions. simple, works.
|
||||
|
||||
next step: registry accepts CommandDefinition objects that carry metadata
|
||||
(help text, required mode, permissions) alongside the handler. the handler
|
||||
can be a python callable OR (later) a DSL script reference.
|
||||
|
||||
this is NOT a rewrite. it's wrapping the existing register() call:
|
||||
|
||||
instead of: register("shout", cmd_shout, aliases=["yell"])
|
||||
become: register(CommandDefinition(name="shout", aliases=["yell"],
|
||||
mode="normal", handler=cmd_shout))
|
||||
|
||||
the dispatcher checks the current session mode before executing. commands with
|
||||
mode="combat" only work in combat mode. this gives us the mode stack for free.
|
||||
|
||||
loading: at startup, scan a commands/ directory for YAML definitions. each one
|
||||
creates a CommandDefinition. python-backed commands register themselves as
|
||||
they do now. both end up in the same registry.
|
||||
|
||||
|
||||
the mode stack
|
||||
==============
|
||||
|
||||
from the dreambook: normal, combat, editor, IF modes. modes filter what events
|
||||
reach the player and what commands are available.
|
||||
|
||||
implementation: each player session has a stack of modes. the top mode
|
||||
determines:
|
||||
- which commands are available (by filtering the registry)
|
||||
- which world events reach the player (chat, movement, combat, ambient)
|
||||
- how output is formatted/routed
|
||||
|
||||
pushing a mode: entering combat pushes "combat" mode. commands tagged
|
||||
mode="combat" become available. ambient chat gets buffered.
|
||||
|
||||
popping a mode: leaving combat pops back to normal. buffered messages get
|
||||
summarized.
|
||||
|
||||
this maps directly to evennia's cmdset merging, but simpler. we don't need
|
||||
set-theoretic operations — just a stack with mode tags on commands.
|
||||
|
||||
the editor mode is special: it captures ALL input (no command dispatch) and
|
||||
routes it to an editor buffer. the editor is its own subsystem that sits on
|
||||
top of the mode stack. jared's friend is building one with syntax highlighting
|
||||
for telnetlib3 — we'll study that implementation when it's available.
|
||||
|
||||
|
||||
what lives in files vs what lives in the database
|
||||
==================================================
|
||||
|
||||
world definitions (terrain, rooms, area layouts): files (YAML/TOML, version
|
||||
controlled)
|
||||
command definitions: files (YAML + python/DSL, version controlled)
|
||||
combat move definitions: files (YAML + python/DSL, version controlled)
|
||||
NPC templates: files
|
||||
item templates: files
|
||||
|
||||
player accounts and state: sqlite
|
||||
player-created content (IF games, custom rooms): sqlite (but exportable to
|
||||
files)
|
||||
runtime state (positions, combat encounters, mob instances): memory only
|
||||
|
||||
the key insight: files are the source of truth for world content. sqlite is
|
||||
the source of truth for player data. player-created content starts in sqlite
|
||||
but can be exported to files (and potentially committed to a repo by admins).
|
||||
|
||||
working from the repo: edit YAML files, restart server (or hot-reload).
|
||||
standard dev workflow.
|
||||
|
||||
working from in-MUD: edit via the editor mode, changes go to sqlite. admin
|
||||
command to "publish" player content to files.
|
||||
|
||||
both workflows produce the same content format. the engine doesn't care where
|
||||
a definition came from.
|
||||
|
||||
|
||||
the entity model
|
||||
================
|
||||
|
||||
current: Player is a dataclass. no mobs, no items, no NPCs yet.
|
||||
|
||||
the ecosystem shows two paths:
|
||||
- deep inheritance (evennia: Object > Character > NPC, most traditional MUDs)
|
||||
- composition/ECS (graphicmud: entity + components)
|
||||
|
||||
recommendation: start with simple classes, but design for composition. a
|
||||
character (player or NPC) is a bag of:
|
||||
- identity (name, description)
|
||||
- position (x, y, current map)
|
||||
- stats (PL, stamina, whatever the combat system needs)
|
||||
- inventory (list of item refs)
|
||||
- mode stack (for players)
|
||||
- behavior (for NPCs — patrol, aggro, dialogue)
|
||||
|
||||
these could be separate objects composed together rather than a deep class
|
||||
hierarchy. we don't need a full ECS framework — just don't put everything in
|
||||
one god class.
|
||||
|
||||
Player and Mob should share a common interface (something that has a position,
|
||||
can receive messages, can be targeted). whether that's a base class, a
|
||||
protocol, or duck typing — keep it flexible.
|
||||
|
||||
|
||||
the game loop
|
||||
=============
|
||||
|
||||
from the dreambook: tick-based, 10 ticks/second.
|
||||
|
||||
current: no game loop yet. commands execute synchronously in the shell
|
||||
coroutine.
|
||||
|
||||
the game loop is an async task that runs alongside the telnet server:
|
||||
|
||||
every tick (100ms):
|
||||
1. drain input queues (player commands waiting to execute)
|
||||
2. process combat (check timing windows, resolve hits)
|
||||
3. run NPC AI (behavior tree ticks)
|
||||
4. process effects (expire old ones, add new ones)
|
||||
5. flush output buffers (send pending text to players)
|
||||
|
||||
player input goes into a queue, not directly to dispatch. this decouples input
|
||||
from execution and gives combat its timing windows. "you typed dodge but the
|
||||
timing window closed 50ms ago" — that's the tick checking the timestamp.
|
||||
|
||||
this is a significant change from the current direct-dispatch model. it should
|
||||
happen before combat gets built, because combat depends on it.
|
||||
|
||||
|
||||
making combat programmable
|
||||
==========================
|
||||
|
||||
the dreambook describes DBZ-inspired timing combat. the ecosystem research
|
||||
shows most MUDs hardcode combat. we want combat moves to be content, not
|
||||
engine code.
|
||||
|
||||
a combat move is:
|
||||
- metadata (name, type, stamina cost, damage formula)
|
||||
- telegraph (what the opponent sees)
|
||||
- timing window (how long the opponent has to react)
|
||||
- valid counters (which defensive moves work)
|
||||
- resolution (what happens on hit/miss/counter)
|
||||
|
||||
all of this can be data. the resolution logic is the only part that might need
|
||||
scripting (DSL), and even that can start as a formula:
|
||||
|
||||
damage = attacker_pl * damage_pct * (1 - defense_modifier)
|
||||
|
||||
the combat ENGINE processes the state machine (idle > telegraph > window >
|
||||
resolve). the combat CONTENT defines what moves exist and how they interact.
|
||||
builders can create new moves by writing YAML (and later DSL scripts in the
|
||||
editor).
|
||||
|
||||
timing windows, stamina costs, damage curves — all tunable from data files.
|
||||
you can balance combat without touching python.
|
||||
|
||||
|
||||
what to do now (priority order)
|
||||
================================
|
||||
|
||||
1. CommandDefinition object
|
||||
wrap the existing registry with metadata. this is small and unlocks
|
||||
mode filtering, help text, permissions. do it before adding more commands.
|
||||
|
||||
2. game loop as async task
|
||||
input queues + tick processing. do it before building combat.
|
||||
effects system already exists — move its expiry into the tick.
|
||||
|
||||
3. mode stack on player sessions
|
||||
simple stack of mode strings. command dispatcher checks mode.
|
||||
do it before building editor or combat modes.
|
||||
|
||||
4. content loading from YAML
|
||||
scan directories for definition files, create CommandDefinition objects.
|
||||
python handlers still work alongside. do it before there are too many
|
||||
hardcoded commands to migrate.
|
||||
|
||||
5. entity model
|
||||
shared interface for Player and Mob. do it before building NPCs.
|
||||
|
||||
6. combat as content
|
||||
moves defined in YAML, combat engine as a state machine.
|
||||
depends on game loop and mode stack.
|
||||
|
||||
7. persistence
|
||||
sqlite for player data. depends on entity model.
|
||||
|
||||
8. editor mode
|
||||
depends on mode stack. study the telnetlib3 editor implementation.
|
||||
|
||||
9. DSL
|
||||
replace python callables in content definitions.
|
||||
depends on everything above being stable.
|
||||
|
||||
items 1-4 are foundation work. they're small individually but they shape
|
||||
everything that follows. getting them right means combat, editor, persistence,
|
||||
and eventually the DSL all slot in cleanly. getting them wrong means
|
||||
rewriting the command system when we realize commands need metadata, rewriting
|
||||
the game loop when we realize combat needs ticks, rewriting session handling
|
||||
when we realize modes need a stack.
|
||||
|
||||
|
||||
what we'll wish we did differently (predictions)
|
||||
=================================================
|
||||
|
||||
things the ecosystem learned the hard way:
|
||||
|
||||
"we should have separated engine from content earlier"
|
||||
every MUD that survived long-term has this separation. dikumud's zone files,
|
||||
LPC's mudlib, MOO's verb system. we should establish the boundary now.
|
||||
|
||||
"we should have made the command registry richer"
|
||||
help text, permissions, mode requirements, argument parsing — these all get
|
||||
bolted on later if not designed in. CommandDefinition solves this.
|
||||
|
||||
"we should have had a game loop from the start"
|
||||
direct command execution is simple but it doesn't compose with timing-based
|
||||
combat, NPC AI, or periodic world events. the tick loop is fundamental.
|
||||
|
||||
"we should have designed entities for composition"
|
||||
inheritance hierarchies (Item > Weapon > Sword > MagicSword) get brittle.
|
||||
composition (entity with damage_component + magic_component) stays flexible.
|
||||
|
||||
"global mutable state is fine until it isn't"
|
||||
our current global players dict and active_effects list work now. as the
|
||||
system grows, these should migrate into a World or GameState object that
|
||||
owns all runtime state. not urgent, but worth planning for.
|
||||
|
||||
"the DSL is the hardest part"
|
||||
every MUD that tried in-world programming struggled with the language
|
||||
design. MOO's language is powerful but complex. LPC requires learning
|
||||
C-like syntax. MUSH softcode is cryptic. our DSL should be simple enough to
|
||||
learn from a help file, powerful enough to express puzzles and combat moves.
|
||||
that's the hardest design challenge in this project. defer it until we know
|
||||
exactly what it needs to express.
|
||||
|
||||
|
||||
closing
|
||||
=======
|
||||
|
||||
the theme: make things data before making them code. if combat moves are data,
|
||||
they can be edited from the MUD. if commands carry metadata, the mode stack
|
||||
works. if the engine/content boundary is clean, the DSL can replace python
|
||||
callables without touching the engine.
|
||||
|
||||
the architecture should grow by adding content types, not by adding engine
|
||||
complexity. new combat moves, new commands, new NPC behaviors, new IF puzzles
|
||||
— these should all be content that loads from definitions, not python modules
|
||||
that get imported.
|
||||
|
||||
code
|
||||
----
|
||||
|
||||
this document docs/how/architecture-plan.txt
|
||||
dreambook DREAMBOOK.md
|
||||
ecosystem research docs/how/mud-ecosystem.txt
|
||||
landscape research docs/how/mudlib-landscape.txt
|
||||
Loading…
Reference in a new issue