Add an architectural plan

This commit is contained in:
Jared Miller 2026-02-07 15:54:12 -05:00
parent 2c75d26d68
commit 0f05302b6e
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View 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