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