reikhelm/.dev/2026-06-02-combat-system/spec/design.md
Parley Hatch 35c544e931 docs(combat): design spec for combined-pool combat system experiment
New sandbox experiment: a deterministic, fully-parameterized real-time
combat engine (EQ/Daggerfall lineage) built on a single combined Vigor pool
(health+endurance+mana) with ceiling/fatigue attrition, capacity-as-fill
stagger cascade, symmetric Actors + pluggable controllers, one composable
Ability model (skills==spells), and classless three-layer emergent identity.
Sim-first: headless deterministic engine + batch sweep harness + CSV/JSON
export for data-driven balancing. Rust engine (combat-core/combat-sim) +
Python analysis layer, in its own decoupled combat/ workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:50 -06:00

392 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Combat System — Design Spec
**Status:** draft for review · **Date:** 2026-06-02 · **Experiment folder:** `combat/` (to be created)
> A standalone experiment in the reikhelm sandbox. **No dependency on `reikhelm-core`** — it
> only borrows the same conventions (pure deterministic core, serde config, ChaCha8 RNG,
> headless-first, front-ends as consumers). Lives in its own top-level folder per the
> repo's one-experiment-per-folder convention.
---
## 1. Thesis
A real-time (tick-based) combat system in the EverQuest / Daggerfall lineage — auto-attack +
skills + magic — built on one deliberately unusual idea: **a single combined resource pool**
that replaces separate HP / mana / stamina. Everything you do *and* everything done to you
draws on the same bar. Offense literally spends your survival.
It is **a deterministic simulation first, a playable game second.** The "feel" is not
eyeballed — it is *measured* across thousands of simulated fights and tuned to target
durations and difficulty curves. The engine is pure and headless; a batch sim harness and
(later) a real-time visual front-end are both just consumers of it.
### The two-axis model (the spine)
Every combatant has one pool, **Vigor**, with three derived quantities:
| Term | Meaning |
|------|---------|
| **Vigor (fill)** | Current value of the pool. Also **= your absorb capacity** (see §4). |
| **Vigor Cap (ceiling)** | `max_vigor fatigue`. The highest your fill can regen to *right now*. |
| **Fatigue** | Accumulated, fight-scoped reduction of the cap. Recovers only out of combat or via recovery abilities. |
Every combat verb is a point on **two axes** — cost to *fill* and cost to *ceiling*:
| Verb class | Δ fill | Δ ceiling | Example |
|------------|--------|-----------|---------|
| Offense (skills/spells) | `+cost` | `+cost` | kick = (5, 1) |
| Auto-attack & defensive reactions (block/parry/evade) | `0` | `+small` | swing = (0, 1) |
| Recovery (spells/skills/potions) | `+cost` | `restore` | second wind = (8, 5) |
| Damage taken | `mitigated` | `+fatigue_share` | — |
The entire combat vocabulary is different numbers in that grid. **Balancing the game is editing
a table.** This is both the design elegance and the core requirement (§1012).
---
## 2. Goals & Non-Goals
**Goals**
- A combined-pool, mitigation-and-recovery combat model that *feels* tense and fair.
- Fully data-driven: every number is config; nothing balance-relevant is hardcoded.
- Deterministic and headless: a fight is reproducible from `(config, seed, loadouts)`.
- Instrumented: emits a structured event/metric stream; exports CSV/JSON for analysis.
- Batch-simulatable: run 10⁴10⁶ fights across matchup/parameter sweeps to tune balance.
- Symmetric: players and monsters run the *identical* model; only their controller differs.
- Composable abilities: skills and spells are one system built from effect primitives.
- Emergent role identity **without classes and without training points**.
**Non-Goals (this experiment)**
- Rendering / animation / art. (A visual front-end comes later, as a consumer.)
- Movement & real positioning. Range is abstracted to a scalar/band for now.
- Loot, XP curves, character creation, networking, persistence.
- The crafting / discovery / reagent economy that *produces* abilities — separate experiment.
- The progression system that *grows* competency — separate experiment.
---
## 3. The Resource Model
### Vigor, Cap, Fatigue
- `vigor` (current fill), `max_vigor` (level/stat-derived constant), `fatigue` (≥0, starts 0).
- `cap = max_vigor fatigue`. `vigor` is clamped to `[0, cap]`.
- **Regen**: each tick, `vigor += regen_per_tick`, clamped to `cap`. Regen restores *fill toward
the ceiling only* — it never touches `fatigue`. (Optional config: reduced regen rate while in
combat vs. idle.)
- **Fatigue ("spend leaves a mark")**: any point that *leaves* the pool generates fatigue.
Default `fatigue += fatigue_ratio × amount_spent` for damage taken and unlabeled spend;
abilities may override with an explicit authored `fatigue_cost`. Fatigue only decreases via
**recovery abilities** (in combat) or **rest** (out of combat). It does *not* regen.
### Damage in
- An attack's landed damage is **a percentage of the target's `max_vigor`**, set by the **con
curve** (§7) and reduced by the **mitigation pipeline** (§8). Even-con, unarmored ≈ 10%;
+5 levels ≈ 30%; 5 levels ≈ 2% (illustrative anchors; the curve is config).
- Applying damage: `vigor = landed`; `fatigue += fatigue_ratio × landed` (a share of every hit
becomes permanent-for-the-fight ceiling loss). Then resolve stagger (§4).
### The squeeze
You don't lose by emptying your fill once — you lose because **attrition grinds your ceiling
down**, a lower ceiling means a lower fill, and a low fill means the next big hit overruns your
capacity and triggers the stagger cascade. Everything points at the same death.
---
## 4. Stagger Cascade (the death model)
There is no instant "vigor = 0 → dead". **Capacity = current `vigor`.** A landed hit is compared
to capacity *at the moment it lands*:
| Landed hit vs. capacity | Result |
|-------------------------|--------|
| `hit ≤ vigor` | Absorbed normally. `vigor = hit`. |
| `vigor < hit < 1.5 × vigor` | **Dazed / knocked down** (temporary status). Regen rate reduced. |
| `hit ≥ 1.5 × vigor` | **Unconscious.** Regen zeroed until recovered. |
Once unconscious, regen can't save you and follow-up hits grind you to 0 → **dead**. The
thresholds (`1.0×`, `1.5×`) and the dazed regen penalty are config.
**Consequence that makes it sing:** spending offense lowers your fill, which lowers your
capacity, which raises your stagger risk *on the very next hit*. "Fire the kick at 60%, eat an
add's swing, get staggered" is a real mechanical cascade, not flavor.
---
## 5. Actors & Symmetry
**Every combatant is the same `Actor`** — same Vigor/Cap/Fatigue, same stagger cascade, same
ability resolution. Variety comes from two independent dials:
- **Stat block** → archetype. Golem = huge `max_vigor`, big hits, *slow* regen, long attack
delay. Rat = tiny pool, fast cheap pecks. Same model, different numbers.
- **Controller** → *intent*. A pluggable decision layer that emits actions (`attack / use
ability / wait / recover`). The Actor doesn't know who drives it:
- **Player controller** — driven by input (interactive front-end) or a scripted policy (sim).
- **AI controllers** — v1: swing-on-cooldown. Later: *hold-and-punish* (wait, watch the
target's fill, unload the haymaker exactly when they've spent low to overrun capacity),
kite, heal. The AI plays the same timing meta-game the player does.
Controllers are swappable with zero changes to combat math — critical for the sim (scripted
policies of varying skill let us measure the gradual→brutal difficulty curve).
---
## 6. Abilities — Unified Skills + Spells
**Skills and spells are one thing: an `Ability`.** "Kick" = melee delivery + physical effect +
potency. "Fireball" = projectile delivery + fire effect + potency. Both resolve through the same
two-axis cost + effect grid.
### Composition
An ability is composed from:
- **Delivery / shape** — `self / touch / melee / projectile / beam / aoe(n)`. Sets targeting and
a base cost.
- **Effect(s)** — one or more, drawn from the **effect library** (§6.1). Each adds cost.
- **Potency** — scalar multiplier on effect magnitude *and* cost.
- **Target count** — 1..N. Multiplies cost.
`cast_cost = f(delivery, Σ effects, potency, targets)` → an **absolute** `(fill_cost,
fatigue_cost)`. Higher potency / more effects / more targets ⇒ more expensive. The 6-target,
potency-10 AoE "cosmic nuke" costs ~90% of a *full* bar — leaving you at ~10%, one swing from the
cascade. **Power is paid in vulnerability.**
### Cost is absolute; pay-at-start
- **Affordability gate:** you cannot begin an ability whose `fill_cost > current vigor`. Heavy
abilities are gated behind a healthy bar; as your ceiling grinds down over a fight, you
progressively **lose access** to your big hitters.
- **Pay at cast-start; fizzle or interruption = paid for nothing.** One rule covers both fizzle
risk (§9) and getting staggered mid-cast: you spent the cost and the ability never fired.
### Cast time
- The only structural axis separating "skill" from "spell": skills are mostly instant; spells
have a **wind-up** (cast time in ticks) during which you are exposed and interruptible. Cast
time is per-ability config.
### 6.1 Effect library (both axes)
Effects target *either* fill *or* ceiling — and the ceiling-attacking ones are the most on-thesis
content the system can have:
- **Fill damage** — % of target `max_vigor` (con + mitigation applied).
- **Ceiling damage / curse** — drains target `fatigue` directly (attacks the real life total).
- **Regen sabotage** — reduces target regen for a duration.
- **Fatigue amplification** — raises target's fatigue-per-spend for a duration.
- **DoT / HoT** — fill drain / restore over ticks.
- **Mitigation debuff / buff** — feeds the mitigation pipeline (§8).
- **Recovery** — `fatigue` (raises ceiling) at a `+fill` cost; the inverse verb.
Effect types are a small enum/registry; adding one is a library entry, not an engine change.
### 6.2 Defensive abilities
Block / parry / evade are abilities with `(0 fill, +fatigue)` and a brief **active window**:
incoming hits landing during the window are mitigated/negated (they feed the §8 pipeline). They
cost endurance, never your guard. (Reactive timing vs. the attacker's wind-up is the defensive
counterpart to timing your offense — see Designed-For §14.)
---
## 7. The Con Curve (shared level-delta mapping)
A single config-driven curve maps **level delta** to a magnitude multiplier, reused in *both*
directions:
- **Incoming damage %**: `delta = attacker_level defender_level` → base damage % of pool.
- **Outgoing effect resist/effectiveness**: `delta = caster_effective_level target_level`
(caster_effective_level folds in competency, §9) → fraction of the effect that lands.
Anchors (config, illustrative): even-con baseline; ≈3× at +5; ≈0.2× at 5; roughly monotonic.
Represented as a parametric function or a small lookup table — TBD in tuning, but it is *one*
curve doing double duty.
---
## 8. Mitigation Pipeline
All "how much of this hit actually lands" reduction flows through **one ordered pipeline** so new
sources are additive contributors, never reworks:
- **Armor** — passive attrition reduction (shrinks the damage %). Does *not* protect against
stagger; it just makes you bleed fill slower so you stay in the safe band longer.
- **Con** — the level-delta curve (§7).
- **Active defenses** — block/parry/evade windows (§6.2).
- **Stances** *(designed-for)* — toggled mitigation trades.
- **Buffs/debuffs** — temporary contributors from effects.
Player skill = *which* abilities and *when*, not gear stat-checks. Armor and con set the
backdrop; timing decides the fight.
---
## 9. Progression & Identity
### Competency (soft gates)
Each Actor has a **competency value per school** (e.g., Invocation, Fire). Competency is mostly
*not* a hard cast wall; it drives two independent checks:
- **Fizzle — "does it go off?"** `f(competency, ability difficulty)`, where difficulty is driven
by the same factors as cost (potency, #effects, #targets). Low competency vs. high difficulty ⇒
frequent fizzle (and per §6, a fizzle means you *paid for nothing*).
- **Resist / effectiveness — "how much lands?"** the con curve (§7) with caster competency folded
into effective level. Even a sneaked-off potency-10 cast gets shrugged off by an over-con
target.
A few **hard school gates** are allowed for structure (e.g., a school locked until competency ≥ X).
### Two independent brakes on power
- **Cost** gates *affordability* (can you pay it?).
- **Competency** gates *reliability + effectiveness* (will it fire, will it land?).
You cannot craft a god-spell at level 1 and skip the game: you'll mostly fizzle, and what lands is
resisted to a trickle. Progression is *earned*, not bought.
### No classes, no training points — three-layer emergent identity
1. **Competency = who you are over time.** Your per-school profile *is* your identity; earned by
use, never allocated.
2. **Loadout / memorization = who you are this fight.** Your known set can be large, but you commit
a *limited slotted set* per encounter (re-slot only on rest / out of combat). Even an
omni-competent veteran must choose a role for this pull — breadth of knowledge ≠ breadth of
power.
3. **Shared pool = who you are this second.** One bar means you can't be nuker *and* healer at
once; the resource economy forces a moment-to-moment role with no extra rules.
Pure classes are rigid and fight the composable model; pure à-la-carte waters down because
everyone converges on one optimal kit. The loadout commitment + competency lag keep roles sharp
with zero class definitions and zero skill points.
**Optional knob (ship OFF):** gentle competency **atrophy** — unused schools drift toward a floor —
as a firmer long-term brake *if* veterans start homogenizing.
### Seam
**Combat consumes competency values and emits "used <school> successfully" events.** *How*
competency grows (use-based skill-ups, etc.) is a separate progression experiment. We build the
hook, not the leveling curve.
---
## 10. Time Model & Determinism
- **Fixed tick** (default 10 ticks/s, config). *Every* duration — attack delay, cast time, regen,
status durations, active windows — is expressed in **ticks**. Headless: ticks advance as fast as
the CPU allows. Real-time front-end: ticks advance on a wall clock.
- **Determinism:** seeded **ChaCha8** RNG with per-roll sub-streams (mirrors `reikhelm-core`), so
tweaking one parameter doesn't reshuffle unrelated rolls. A fight =
`(config, seed, loadout_A, loadout_B)` → a *reproducible* outcome + full event log. This
reproducibility is what makes the analysis valid: change one knob, observe the isolated delta.
---
## 11. Configuration — the "table"
A single serde-(de)serializable `CombatConfig` holds **all** tunables, loadable from JSON.
Categories:
- Tick rate; regen rates (idle vs in-combat); dazed regen penalty.
- `fatigue_ratio`; stagger band thresholds.
- Con curve (parameters or table).
- Mitigation: armor tiers, stance values.
- Cost formula coefficients (delivery base costs, per-effect cost, potency scaling, per-target
multiplier); auto-attack `(0, small)` default.
- Fizzle & resist curve parameters.
- Actor stat blocks & ability/effect library definitions (data, not code).
Nothing balance-relevant is hardcoded. Editing config is the primary balancing act; structural
rule changes are the only thing requiring a recompile.
---
## 12. Instrumentation, Metrics & Balancing
### Event stream
The engine emits a structured event per meaningful tick: `{tick, actor, action, fill_before,
fill_after, ceiling, fatigue, stagger_state, fizzle?, target, landed, outcome?}`. A **recorder**
rolls events into per-fight summary metrics.
### Per-fight metrics (export to CSV/JSON)
Time-to-resolution (ticks & seconds); outcome (win/loss/draw/timeout); #staggers, #dazes,
#unconscious; #fizzles; total fill damage dealt/taken; total ceiling damage; min fill reached;
ceiling-decay curve; near-death recoveries; effective DPS; vigor/ceiling time series (optional,
sampled).
### Batch harness & "feel" as acceptance criteria
A harness runs **N fights across a matchup/parameter sweep** (level-delta × armor tier × loadout ×
controller-skill) and exports the aggregate. The user's "feel" failure modes become *measurable
guardrails*:
- *"slow player knocked every 2s quits"* → **stagger-frequency ceiling**: from a full bar, an
even-con opponent must not stagger-lock you in under *N* hits.
- *"gradual but scales to brutally difficult"* → a **target time-to-kill curve by con-tier**:
under-con trivial, even-con a satisfying ~X-second fight, +5 winnable only with skilled play,
+8 lethal. Sweep parameters until the curve matches the intended shape.
### Analysis layer
A Python package (`combat/analysis/`, own `.venv`) consumes the exported CSV/JSON via pandas:
sweep orchestration, difficulty-curve plots, parameter search toward target durations.
---
## 13. Architecture & Layout
```
combat/ # own Cargo workspace — independent of root reikhelm workspace
Cargo.toml # [workspace] so the parent workspace never absorbs it
combat-core/ # pure deterministic engine: Actor, Ability, tick loop, RNG, events
# no I/O, no rendering, no real-time. The single source of truth.
combat-sim/ # binary: load config + scenarios, run batch sweeps,
# record metrics, export CSV/JSON
analysis/ # Python: pandas/plots over exported data (own .venv, own .gitignore)
configs/ # JSON configs / sane defaults
README.md
# (later) combat-wasm/ + a web playable, mirroring the reikhelm-web pattern
```
- **Decoupling:** root `Cargo.toml` uses explicit `members` (no globs), and `combat/Cargo.toml`
declares its own `[workspace]`, so `combat/` is a self-contained experiment that neither
pollutes nor depends on the existing crates.
- **Deps (to be created):** `combat-core``rand_chacha = "0.10"`, `serde = { "1", features =
["derive"] }`; `combat-sim``serde_json`, plus a CSV writer (e.g. `csv`). Edition 2021,
Rust 1.96.
- **gitignore additions:** `combat/target/`, `combat/analysis/.venv/`, exported data
(e.g. `combat/**/out/`, `*.csv` under combat output dirs).
---
## 14. Scope — v1 Vertical Slice vs. Designed-For
The full vision above is the *design*. To find the fun before building all of it, **v1 proves the
core loop and the balancing capability** — which, for a data-first build, means the headless sim
matters more than visuals.
**v1 vertical slice (build this first):**
- `combat-core`: Actor + Vigor/Cap/Fatigue + stagger cascade + regen + mitigation (armor + con) +
fixed-tick loop + seeded RNG + event emission.
- **34 hardcoded abilities** (auto-attack, a cheap skill, a heavy skill, a recovery) — not the
composer yet; just enough to exercise the two-axis grid.
- **One dumb-AI controller** (swing-on-cooldown) + a scripted player policy.
- `combat-sim`: run a matchup sweep, export per-fight metrics CSV.
- `analysis`: a first notebook plotting TTK and stagger-frequency by con-tier.
- **Success = the duration/difficulty curves are tunable via config to the intended shape.** If
managing one pool against the stagger cascade is tense in the data, we layer the rest on.
**Designed-for / explicitly deferred (nothing dropped):**
- The **ability composer** + data-driven effect library (v1 hardcodes; the model is built so this
slots in).
- **Competency / fizzle / resist** as data-driven progression inputs.
- **Loadout / memorization** mechanic.
- **Telegraphs & reactive defense** (block/parry/evade timed vs. wind-up) — nearly free once
abilities have cast-time windows.
- **Stances** (mitigation knob), **ceiling-attacking effect content**, DoT/HoT/buff/debuff breadth.
- **WASM build + real-time visual playable** (another consumer of `combat-core`).
- **Crafting / discovery / reagents** (separate experiment) and **competency advancement**
(separate experiment) — combat exposes the seams.
- Smarter AI controllers (hold-and-punish, kite, heal).
---
## 15. Open Questions / Risks
- **Con curve shape** is the balance lever the whole feel rides on; it will need real sweep data
to settle. Start parametric, validate against target curves.
- **Regen vs. drain vs. spend rates** must be co-tuned, or even-con fights are either un-loseable
(regen dominates) or stagger-locked (drain dominates). The sim exists precisely to find this
balance; treat default numbers as placeholders.
- **Fizzle-cost severity**: pay-at-start + fizzle-for-nothing is brutal by design; verify it
doesn't make low-competency casting *never worth attempting* (which would stall progression).
- **Scripted "player skill" policies** for the sim are themselves a modeling choice — the
difficulty curve is only as meaningful as the policies are representative. Document the policies
used for any published balance numbers.
```