Adversarial review found no blockers but several serious issues; fixed: - determinism: require vendoring reikhelm-core's order-independent, version- stable Rng::fork (not DefaultHasher) + integer fixed-point internal state with documented per-stage rounding; carve out chance()'s IEEE-deterministic f64 compare as the sole hot-path exception - pin a canonical within-tick resolution order (incl. step-0 controller decisions in actor-id order) as a determinism invariant - close internal-consistency dead-ends: fatigue clamp [0,max_vigor], cap=0 as the explicit attrition-collapse trigger, regen floor + no-soft-lock rule, fix inverted ceiling-damage verb - enrich instrumentation (pre-mitigation damage, per-stage mitigation, blocked- action + fail-reason, fill-flow accounting) + anti-turtle guardrail metric Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
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 (§10–12).
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(starts 0).fatigueis clamped to[0, max_vigor];cap = max_vigor − fatigue(socap ∈ [0, max_vigor]).vigoris clamped to[0, cap].- Regen: each tick,
vigor += regen_per_tick, clamped tocap. Regen restores fill toward the ceiling only — it never touchesfatigue. Config carries an idle and a (lower) in-combat rate, and a non-zero floor: regen is reduced when dazed and by regen-sabotage effects but never reaches 0 except when unconscious — so an actor can always claw back toward affording an action (no soft-lock; see §6). - Fatigue ("spend leaves a mark"): any point that leaves the pool generates fatigue.
Default
fatigue += fatigue_ratio × amount_spentfor damage taken and unlabeled spend; abilities may override with an explicit authoredfatigue_cost. Fatigue only decreases via recovery abilities (in combat) or rest (out of combat). It does not regen. - Ceiling collapse is the real loss condition. Because every incoming hit adds fatigue,
even pure defense erodes your ceiling over time — turtling is not viable. When
fatiguereachesmax_vigor,cap = 0, capacity is 0, and the actor collapses (→ unconscious → dead per §4). Attrition kills by collapsing the ceiling, not by a single drain.
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.
Reconciling with "no instant death": "no instant vigor = 0 → dead" means a single fill
subtraction never kills you outright. You are lost only when (a) a burst overruns capacity
(the cascade above) or (b) attrition collapses your ceiling (§3: fatigue → max_vigor, so
cap → 0). When cap = 0, capacity is 0 and the next hit trivially clears 1.5 × 0 →
unconscious — that is the intended attrition-death path, fully consistent with the rule.
Reaching cap = 0 is itself the collapse trigger (treated as an instantaneous self-overrun →
unconscious), so an actor who self-fatigues to zero in a lull collapses immediately without waiting
for an incoming hit — the §3 state-threshold and this §4 mechanism describe the same moment.
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.
- No soft-lock. Auto-attack is always affordable (
0fill), so an actor is never fully action-locked — at worst reduced to auto-attack-and-regen (a losing position, not a frozen one). Recovery abilities are authored cheap (low or zerofill_cost) so they stay reachable when you most need them; together with the regen floor (§3), pay-at-start/fizzle can put you in danger but cannot strand you in a permanently unrecoverable state.
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 — raises target
fatiguedirectly, lowering their cap (attacks the real life total). The inverse of Recovery, below. - 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+fillcost; 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.
Caveat on the "double duty": the curve takes a delta, but its two callers feed
differently-shaped domains — incoming damage uses raw attacker_level − defender_level (bounded
by the level range), while outgoing effectiveness uses a competency-inflated effective-level
delta (potentially wider). Keep the curve's input domain explicit and range-check the
competency-derived delta against it; if competency inflation pushes the effective range past where
the damage-tuned curve behaves, the two callers may need to split into separate curves. v1 has
no competency (§14), so this won't surface until the progression layer lands — write the curve call
so a later split is cheap, not a rewrite.
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
- Competency = who you are over time. Your per-school profile is your identity; earned by use, never allocated.
- 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.
- 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.
Reproducibility (load-bearing for all analysis)
A fight = (config, seed, loadout_A, loadout_B) → a bit-reproducible outcome + event log.
"Change one knob, observe the isolated delta" only holds if unrelated rolls don't reshuffle and
arithmetic doesn't drift. Two hard requirements — neither is satisfied by "ChaCha8" alone:
- Order-independent, version-stable RNG forking. Reuse
reikhelm-core'sRng::forkcontract verbatim (vendorreikhelm-core/src/rng.rsas a module): a hand-rolled integer mix (splitmix64- FNV-1a),
&self(non-mutating) and order-independent — explicitly notstd::collections::hash_map::DefaultHasher, whose output is version-unstable. Sub-streams are keyed by a stable string, e.g.fork("actor{id}:{roll_kind}:tick{t}"), so adding an effect to one ability never shifts another actor's or another tick's stream. This property — not ChaCha8 itself — is what makes the isolated-delta guarantee true.
- FNV-1a),
- Integer fixed-point internal state.
vigor,cap,fatigue, and all magnitudes are stored as scaled integers (e.g. milli-units); every formula (con %, potency multiply,fatigue_ratio × amount, each mitigation stage) is evaluated in integer arithmetic with documented rounding at each pipeline stage. This mirrors reikhelm-core's integer-space discipline and gives bit-exact results across compilers / opt-levels / machines — IEEE-754f64multiply chains do not (ChaCha8 gives a portable random stream, not portable arithmetic). All magnitude arithmetic stays integer;f64is otherwise confined to config parsing. One deliberate exception: the vendoredRng::chance(p: f64)does its roll as au64 → f64unit-interval divide-and-compare (rng.rs:106-117) — a single division + compare, which is IEEE-deterministic across platforms — so probabilistic checks (fizzle, §9) may use it. That single compare is the only f64 allowed in the hot path; if even that's unwanted, add an integer-thresholdchancevariant (compare the rawu64bits againstpscaled tou64). Either way, no f64 ever touches a magnitude. Avoid iteration-order-dependent containers (HashMap) in the hot path; use ordered/BTreeMaporVeckeyed by actor-id.
Canonical within-tick resolution order (a determinism invariant)
Several things resolve on one tick and the order changes outcomes (regen-before-damage yields fewer staggers than regen-after; sequential hits differ from batched), so it is fixed, not an implementation detail:
- Controllers decide, in ascending actor-id order, before anything else resolves. Starting a cast or auto-attack pays its cost here (cast-start payment, §6) and opens cooldowns/windows; instant abilities enqueue their effects for step 2. Pinning this first, by actor-id, is what makes the affordability gate and stagger checks see a deterministic state.
- Expire finishing statuses and active-defense windows (a window covering tick t is inclusive of hits landing on tick t).
- Resolve completing casts → enqueue their hits/effects for steps 3–4.
- Apply incoming hits sequentially in ascending actor-id order (sequential, not batched: hit 1
lowers
vigor, raising hit 2's stagger odds), each followed immediately by its stagger check (§4) and its fatigue add (§3). - Tick DoT/HoT and other per-tick effects.
- Apply regen (clamped to
cap).
Determinism is part of the contract — any reordering (including controller-decision timing, step 0) is a breaking change to recorded data.
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. Outcomes alone can't tell you why a
config feels bad, so the schema records causes, not just results:
{tick, actor, action, fill_before, fill_after, ceiling, fatigue, stagger_state, target, damage_pre_mitigation, mitigation_by_stage[armor, con, defense, buff], landed, fail_reason ∈ {none, competency_fizzle, interrupted}, action_blocked_reason?, outcome?}.
A recorder rolls events into per-fight summary metrics. Each added field is load-bearing for
tuning:
- Pre-mitigation damage + per-stage mitigation deltas — you cannot tune the con curve (§7, the
balance lever) from the final
landednumber alone; you need each stage's contribution. action_blocked_reason(cant_afford/on_cooldown/dazed/ …) — a "wanted to act but couldn't" signal. Without it, a fight where an actor spent 40% of ticks unable to afford anything is indistinguishable from one where it chose to wait, and the soft-lock/turtle modes are invisible.fail_reasonsplits competency-fizzle from interruption — otherwise "competency too low" and "wind-ups too long" collapse into one signal you can't separate.
Per-fight metrics (export to CSV/JSON)
Time-to-resolution (ticks & seconds); outcome (win/loss/draw/timeout); #staggers, #dazes, #unconscious; #competency_fizzles, #interruptions; blocked-actions by reason; total fill damage dealt/taken; total ceiling damage; min fill reached; ceiling-decay curve; near-death recoveries; effective DPS; fill-flow accounting (gained-by-regen vs. spent-on-actions vs. lost-to-damage) — §15 names regen-vs-drain-vs-spend as the make-or-break co-tuning, so the sums must be visible; vigor/ceiling time series (optional, sampled).
Anti-turtle guardrail
Absorbed hits still erode the ceiling (§3), so passive-forever loses — but auto-attack-only can still dominate if abilities aren't efficient enough (favorable damage-per-resource, or utility auto-attack lacks). Track it explicitly: fraction of fights won by an auto-attack-only policy. If that's high, the interesting verbs are a tax players opt out of — contradicting the thesis — and the fix is a damage-per-resource/utility edge, not a tuning nudge.
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.tomluses explicitmembers(no globs), andcombat/Cargo.tomldeclares its own[workspace], socombat/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/,*.csvunder 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.- 3–4 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.
- Auto-attack-only dominance (see §12 guardrail) — the deepest balance risk: if abilities don't beat auto-attack on damage-per-resource or bring utility it lacks, the optimal policy is to never use the interesting verbs. Structural, not a number; watch the guardrail metric from day one.
- Fixed-point rounding bias (§10) — integer rounding at each pipeline stage can accumulate a systematic skew (e.g. always-floor quietly favors the defender). Pick a rounding rule deliberately and confirm it isn't biasing outcomes over long fights.