Deterministic combat resolution forkbve
Stats, abilities, effects, cooldowns and damage in integer maths with a seeded RNG — one resolution shared by a real-time client, an authoritative server and a headless balance run. no_std, wasm, and an optional Bevy layer that is never in the way.
The damage is the damage
Given an attacker, a defender, an ability and a source of randomness, the outcome is fixed. resolve is a pure function over integers and a seeded Rng, so the client predicts exactly what the server confirms and a replay replays.
- Crate owns — stats, abilities, effects, cooldowns, resolution maths.
- Caller owns — when to resolve, who is in the cone, what to do with the outcome.
- No world — no positions, no entity ids, no collections, no allocator.
What it gives you
Features
resolve
One function over an ability, an attacker, a defender, Modifiers and a seeded Rng. Returns an Outcome carrying the damage and how it got there — hit, crit, or miss.
Stats and Health
Power, attack, armor, evasion, crit chance; Health with damage, heal and a deliberate revive, plus Resource for whatever an ability spends.
Abilities and casts
Ability, Payload, Shape and Allegiance describe what a thing does; Cast, Readiness, usable and an ordered Denial say whether it may be done yet.
Effects over time
Effect and EffectKind carry a sub-point remainder, so a damage-over-time deals the same total whether it is ticked once a second or sixty times.
One clock
Millis and Timer are the only notion of time. A frame delta and a turn ending are the same step, at different sizes.
Optional Bevy plugin
CombatPlugin, Combatant, AbilityBar, UseAbility and the AbilityStarted / AbilityLanded / AbilityDenied / Died messages, behind the bevy feature.
Integration
Usage
use combat::{Ability, Health, Millis, Modifiers, Rng, Stats, Timer, resolve};
let sword = Ability::swing(20, 250, 90);let attacker = Stats { power: 5, ..Stats::default() };let defender = Stats { armor: 4, ..Stats::default() };
let mut rng = Rng::new(7, 0);let outcome = resolve(&sword, &attacker, &defender, Modifiers::default(), &mut rng);assert_eq!(outcome.damage(), 21);
let mut cooldown = Timer::new(Millis(1500));assert!(!cooldown.tick(Millis(500)));assert!(cooldown.tick(Millis(1000)));
let mut health = Health::new(100);health.damage(outcome.damage());assert_eq!(health.current(), 79);The default build is std and the maths only. features = ["bevy"] adds the components, messages and systems that tick cooldowns, casts and effects; --no-default-features gives no_std, which is what the crate is checked against so the claim cannot rot.
Easy to undo by accident
Design notes
- A connected hit always deals at least 1. Enough armour should make a character tough, never unkillable.
- Evasion is floored at a 5% chance to be hit. A defender who cannot be hit cannot be fought.
- Stacked multipliers are multiplicative and floored. Integer per-mille arithmetic truncates, so without a floor four stacked reductions reach zero and grant immunity.
- Crits multiply before armour is subtracted, so armour scales sensibly instead of being trivialised.
- Effects carry a sub-point remainder, so a damage-over-time deals the same total at any tick rate.
- A cooldown restart keeps whichever duration is longer. Otherwise a shared, shorter cooldown becomes a way to skip a longer one.
- The dead are not healed. Resurrection is
Health::revive, a deliberate act; a stray area heal must not do it by accident. - Denial reasons are ordered, so an ability bar can say the most useful true thing rather than the first one it noticed.
Questions
Frequently asked
What is the combat crate?
combat is a Rust library that owns the resolution maths for a fight — stats, abilities, effects, cooldowns and damage — and knows nothing about frames, turns or space. The caller owns scheduling and the world; the crate answers what a given attacker, defender, ability and seeded RNG produce.
Why does one crate serve both real-time and turn-based combat?
The two disagree about when a resolution happens and how time advances, not about what a resolution is. Every duration is a Millis, so a turn is not a different unit of time, only a larger step of the same one. A turn-based lane advances the clock by a turn's worth and resolves on command; a real-time lane advances it by the frame delta and resolves when a swing connects. Both call the same resolve and get the same answer.
How is determinism guaranteed?
Randomness is an explicit, seeded Rng passed in by the caller and never drawn from the environment, chances are per-mille integers rather than floats, and damage is computed in integers throughout. A server re-simulating a client's attack reaches the client's conclusion, and a replay replays.
Does the crate need std or an allocator?
No. Nothing stores a position, an entity id or a collection — range arrives as a distance the caller measured, and a cone's victims come from the caller's spatial query. It builds with --no-default-features as no_std and on wasm32-unknown-unknown.
What does the bevy feature add?
Components wrapping the pure types, the messages that request and report an ability, and the systems that tick cooldowns, casts and effects. It is deliberately separable — without the feature the crate is the resolution maths and nothing else, which is what lets an authoritative server or a headless balance run share it with the client.