mud/docs/how/combat.txt
Jared Miller 6173a165c2
Add combat system design documentation
Captures the combat-as-content architecture: moves are TOML files,
engine is a state machine (IDLE→TELEGRAPH→WINDOW→RESOLVE). Documents
move set, counter relationships, damage formulas, and how combat
initiation works without a dedicated fight command.
2026-02-07 20:47:41 -05:00

235 lines
6.6 KiB
Text

combat system — fighting as content
===================================
combat in this MUD is designed around the principle that moves are content,
not engine code. the engine provides a state machine and timing system. moves
are data files that define telegraphs, windows, and counters. this keeps the
combat system extensible without touching python.
core design principles
======================
1. combat moves are CONTENT (TOML files in content/combat/)
2. the engine is a STATE MACHINE that processes timing and resolution
3. direction matters — punch right is countered by dodge left
4. no "fight" command — first attack initiates combat
5. after combat starts, target is implicit until encounter ends
the state machine
=================
IDLE → TELEGRAPH → WINDOW → RESOLVE → IDLE
IDLE:
no active move. attacker can initiate attack. defender can do nothing
combat-specific.
TELEGRAPH:
attacker has declared a move. defender sees the telegraph message.
"goku winds up a right hook!"
defender can queue a defense during this phase.
duration: brief (implementation decides, not move-defined).
WINDOW:
the timing window opens. defender can still queue defense.
if defender queued correct counter during TELEGRAPH or WINDOW, they succeed.
duration: move.timing_window_ms (defined in TOML).
RESOLVE:
timing window closes. check if defense counters attack.
calculate damage based on attacker PL, damage_pct, and defense success.
apply stamina costs.
check for knockouts (PL = 0) or exhaustion (stamina = 0).
return to IDLE.
entity stats
============
each Entity (player, mob, NPC) has:
pl: float = 100.0
power level. acts as both health AND damage multiplier.
when PL hits 0, entity is knocked out.
stamina: float = 100.0
current stamina. each move costs stamina.
when stamina hits 0, entity passes out.
max_stamina: float = 100.0
stamina ceiling. can be affected by training, items, etc (future).
the move set
============
ATTACKS:
punch right (pr)
basic right hook. fast, low cost.
punch left (pl)
basic left hook. fast, low cost.
roundhouse (rh)
big spinning kick. higher damage, tighter parry window.
sweep (sw)
leg sweep. knocks down if not countered.
DEFENSES:
dodge right (dr)
sidestep to the right. counters left-side attacks.
dodge left (dl)
sidestep to the left. counters right-side attacks.
parry high (f)
block high attacks. tighter timing window than dodge.
parry low (v)
block low attacks. tighter timing window than dodge.
duck
crouch under high attacks. counters roundhouse.
jump
leap over low attacks. counters sweep.
counter relationships
=====================
defined in each move's TOML file via the "countered_by" field.
punch right → countered_by: [dodge left, parry high]
punch left → countered_by: [dodge right, parry high]
roundhouse → countered_by: [duck, parry high, parry low]
sweep → countered_by: [jump, parry low]
the engine doesn't know these relationships. it just checks:
"is defender's move in attacker's move.countered_by list?"
damage formula
==============
if defense succeeds (is in countered_by list):
damage = 0
defender gets "you countered the attack!" message
if defense fails (wrong move or no move):
damage = attacker.pl * move.damage_pct
defender.pl -= damage
if no defense was attempted:
damage = attacker.pl * move.damage_pct * 1.5
defender.pl -= damage
defender gets "you took the hit full force!" message
stamina costs are always applied, regardless of outcome:
attacker.stamina -= move.stamina_cost
if defender attempted defense:
defender.stamina -= defense_move.stamina_cost
starting combat
===============
NO explicit "fight" command. instead:
punch right goku
if player is not in combat: starts encounter with goku, pushes "combat" mode
if player is already in combat: error, already fighting someone else
after combat starts, target is implicit:
punch left
dodge right
roundhouse
all commands assume the current opponent.
exiting combat
==============
combat ends when:
- one combatant's PL reaches 0 (knockout)
- one combatant's stamina reaches 0 (exhaustion)
- one combatant flees (future: flee command)
- one combatant is killed (future: lethal combat toggle)
when combat ends, both entities' mode stacks pop back to "normal".
TOML move definition
=====================
example: content/combat/punch_right.toml
name = "punch right"
aliases = ["pr"]
move_type = "attack"
stamina_cost = 5.0
telegraph = "{attacker} winds up a right hook!"
timing_window_ms = 800
damage_pct = 0.15
countered_by = ["dodge left", "parry high"]
move_type: "attack" or "defense"
telegraph: shown to defender during TELEGRAPH phase. {attacker} replaced with name
timing_window_ms: how long defender has to respond
damage_pct: fraction of attacker's PL dealt as damage
countered_by: list of move names that counter this move
future features (NOT YET IMPLEMENTED)
======================================
stuns:
successful parry stuns opponent for 1 tick.
during stun, engine shows combo sequence in shorthand:
"you can follow up with: pr/pl/sw or pr/pl/rh"
player has narrow window to input the sequence.
combos:
if player executes shown sequence during stun window, bonus damage applied.
after successful combo, chance to chain (diminishing probability).
longer chains = exponentially harder timing.
special moves:
moves that require conditions (opponent stunned, player at high PL, etc).
defined via "requires" field in TOML.
lethal combat:
toggle that makes PL=0 mean death, not knockout.
affects NPC encounters in dangerous zones.
multi-combatant:
extend encounter to support more than 1v1.
each attacker/defender pair still follows same state machine.
implementation notes
====================
the engine provides:
- CombatMove dataclass + TOML loader
- CombatEncounter state machine (attack/defend/tick/resolve)
- global encounter list + management functions
- process_combat() called each game loop tick
- command handlers that look up moves and call encounter methods
content provides:
- TOML files for each move in content/combat/
- balance tuning (stamina costs, damage_pct, timing windows)
the command registry makes moves available as commands automatically.
"punch right" is both a move name AND a command name. the command handler
looks up the move definition and feeds it to the encounter.
this keeps the python side generic. adding a new move = add a TOML file,
no code changes. balance changes = edit TOML values, no restart needed
(future: hot-reload content).