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

20 KiB
Raw Blame History

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.
  • Controllerintent. 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 / shapeself / 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).
  • Recoveryfatigue (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 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-corerand_chacha = "0.10", serde = { "1", features = ["derive"] }; combat-simserde_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.