A real-time consumer of combat-core (the front-end the spec designs for, §14): pick a foe, manage your single Vigor pool, try not to spend your survival. - combat-core: a HumanController (input-driven, spec §5) behind a shared intent cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g. auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green. - combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the player's ability list, and a flavorful MonsterAI that uses each monster's signature kit (the wraith curses your ceiling) on a coin-flip so big hits space into a readable duel. Own workspace member, kept out of the default host build. - combat-web: an atmospheric battler. The signature dual Vigor bar shows fill, the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue zone in one bar — the squeeze made legible. Floating damage, cast telegraphs, combat log, result overlay. Verified end-to-end in a real browser. - tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed. - Added skeleton / troll / wraith monster stat blocks; bumped the duelist's durability so fights are readable (even-con ~30s) and active play beats every monster while passive auto-attacking loses the hard ones. Re-validated the sim: the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34% at even con). README findings updated to match. Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a 3s active-play victory over the Skeleton Knight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| analysis | ||
| combat-core | ||
| combat-sim | ||
| combat-wasm | ||
| combat-web | ||
| configs | ||
| tools | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
combat — a combined-pool combat system
A standalone experiment in the reikhelm sandbox. It borrows the repo's conventions (pure deterministic core, serde config, ChaCha8 RNG, headless-first, front-ends as consumers) but has no dependency on
reikhelm-core. Its own Cargo workspace; one experiment, one isolated build graph.Design spec:
../.dev/2026-06-02-combat-system/spec/design.md.
The idea
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, Vigor, 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. "Feel" (fight duration, difficulty curve) is not eyeballed — it is measured across tens of thousands of simulated fights and tuned to target shapes.
The spine — two axes and a squeeze
Every combatant has one pool with three derived quantities:
| Term | Meaning |
|---|---|
| Vigor (fill) | Current value. Also your absorb capacity for the stagger cascade. |
| Cap (ceiling) | max_vigor − fatigue. The highest fill can regen to right now. |
| Fatigue | Fight-scoped ceiling loss. Every point that leaves the pool adds some. |
Every combat verb is a point on two axes — cost to fill and cost to ceiling. Offense costs both; auto-attack/defense cost a little ceiling; recovery is the inverse. You don't lose by emptying fill once — you lose because attrition grinds your ceiling down, a lower ceiling caps a lower fill, and a low fill lets the next hit overrun your capacity and trigger the stagger cascade (absorbed → dazed → unconscious → dead). Everything points at the same death.
Layout
combat/
combat-core/ pure deterministic engine — the single source of truth
combat-sim/ batch harness: load config, run sweeps, export CSV/JSON
combat-wasm/ wasm-bindgen bridge — drive the engine from a browser
combat-web/ a playable real-time battler (the front-end consumer, §14)
analysis/ Python (pandas/matplotlib) over the exported data
configs/ the tunable "table" (default.json)
tools/ portraits.py — AI monster art via the LAN Z-Image stack
combat-core has no I/O, no rendering, no real-time. A fight is a pure function
of (rules, seed, actors, controllers), reproducible bit-for-bit. The sim and
(later) any visual front-end are just consumers.
Inside combat-core
| Module | Role |
|---|---|
fixed |
integer fixed-point math — the only place magnitudes round (banker's rounding) |
rng |
vendored order-independent, version-stable deterministic RNG (+ integer chance_bp) |
con |
the level-delta → multiplier con curve |
mitigation |
the ordered "how much lands" pipeline (armor → con → defense → stance → buff) |
effect / ability |
the composable effect library + unified ability model (skills == spells) |
actor |
the symmetric combatant: combined pool, stagger cascade, statuses, competency, loadout |
controller |
pluggable intent — SwingOnCooldown, ScriptedPlayer { skill }, HoldAndPunish |
config |
the full tunable surface, compiled to an integer-only Rules |
event |
the structured event stream + metrics recorder |
engine |
the fixed-tick loop + canonical within-tick resolution order |
Determinism is load-bearing
"Change one knob, observe the isolated delta" only holds if unrelated rolls don't reshuffle and arithmetic doesn't drift. Two guarantees make it true:
- Order-independent RNG forking — every roll is keyed
root.fork("actor{id}:{kind}:tick{t}"), so adding an effect to one ability never shifts another actor's or tick's stream. (Vendored verbatim; a hand-rolled splitmix64 + FNV-1a mix, notDefaultHasher.) - Integer fixed-point magnitudes —
vigor/cap/fatigueand every formula are integer milli-units with one documented rounding rule;f64is confined to config parsing. Bit-exact across compilers / opt levels / machines.
The within-tick order is itself an invariant (reordering changes outcomes), and decisions are simultaneous within a tick — all controllers read the same start-of-tick snapshot, then commit in id order — so a perfect mirror match is fair.
Build & run
# (cargo on PATH: export PATH="$HOME/.cargo/bin:$PATH")
cd combat
cargo test # unit + determinism integration tests
cargo build --release
# Run the sweeps → out/{fights.csv, summary.csv, sample_fight.json}
./target/release/combat-sim --config configs/default.json --out out --seeds 300
# Analyze → plots/*.png + a text read-out
cd analysis
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
./.venv/bin/python analyze.py --data ../out --plots plots
combat-sim runs two sweeps:
- con — player vs. a fixed competent benchmark across level delta × player policy (auto-only, skill 20/50/80/100). The auto-only policy is the §12 anti-turtle guardrail.
- archetypes — a skilled player against golem / rat / battlemage.
Play it (browser)
The same engine, real-time, with AI-rendered monster portraits — pick a foe and
fight. See combat-web/README.md for details.
wasm-pack build combat-wasm --dev --target web \
--out-dir ../combat-web/pkg --out-name combat_wasm # build the bridge → pkg/
python3 -m http.server 8013 --directory . # serve combat/ as root
open http://127.0.0.1:8013/combat-web/
What the data shows (default config)
With the shipped configs/default.json (these numbers are tuned placeholders — the
whole point is that they're a table you edit):
- Duration tent — fights are trivially short at the con extremes (~1–5s) and longest/most-contested at even con (~30s). The intended "trivial → satisfying → lethal" shape.
- Skill matters — at even con, win rate climbs monotonically with policy: auto-only ~1% → skill20 10% → skill50 24% → skill80 29% → skill100 34%.
- Anti-turtle guardrail is green — in the contested band the full kit beats auto-attack-only decisively (≈29% vs ≈1% at even con). Spending on the interesting verbs is worth it, which is the whole thesis.
- Mirror matches are fair — even-con skill-vs-skill is ≈ symmetric.
Getting there was the sim working as designed: the first default numbers produced a one-tick cliff and 82% mutual-KO draws, and revealed the §15 deepest risk live — a poke skill that was strictly worse than the free auto-attack, so auto-only out-won the full kit. Tuning the table (con curve, competency coupling, ability efficiency, damage variance) moved it to the curves above. That loop — sim → measure → tune → re-sim — is the deliverable.
Tuning
Everything balance-relevant lives in configs/default.json. Editing it is the
primary balancing act; only structural rule changes need a recompile. The levers
the feel rides on (spec §15): the con curve shape, the regen vs. drain vs.
spend rates (co-tuned or even-con fights are un-loseable or stagger-locked), and
keeping abilities ahead of auto-attack on damage-per-resource or utility.