Health System — System Guarantees Matrix¶
This document defines the explicit behavioural guarantees of the RevFramework Health System.
It mirrors the structure used in other systems (e.g., Currency) and is intentionally strict.
This is the contract
If something is not listed here, it is not guaranteed.
1. Core Health¶
| Aspect | Guarantee |
|---|---|
| State ownership | Per-GameObject Current and Max values owned by the health host |
| Units | External HP state is integer-based |
| Internal math | Internal rule math may use floats before final rounding |
| Rounding mode | Half-to-even (banker's), not half-up — 2.5 lands as 2, 3.5 as 4 |
| Threading | Main-thread usage |
| Mutation surface | Public APIs only |
| HP bounds | Always clamped [0..Max] |
| Death handling | Idempotent |
| Cross-component atomicity | ❌ Not guaranteed |
2. Damage Pipeline¶
| Aspect | Guarantee |
|---|---|
| Guard order | Authority → State Guards (Dead → Invincible → DamageLock) |
| PRE rule order | Ascending Priority |
| Rule bypass | Specific rule families may be skipped via RuleBypass |
| DamageAttempted | Fires once per evaluated attempt (applied or rejected) |
| Damage evaluation | (RawAmount * Multiplier) + FlatDelta |
| Extreme amounts | ✔ Saturate at int.MaxValue rather than overflowing — int.MaxValue damage lands as a kill, on every platform |
| Shield position | After PRE rules, before HP mutation |
| Shield bypass | Respects BypassShields |
| POST observers | Run once for attempts that reach POST evaluation (implementation-dependent for early rejections) |
| Death trigger | Only after HP ≤ 0 |
| Cross-attempt atomicity | ❌ Not guaranteed |
3. Heal Pipeline¶
| Aspect | Guarantee |
|---|---|
| Guard order | Authority → Dead → Amount ≤ 0 |
| PRE heal rules | Ascending Priority |
| Healing modifiers | Multiplicative (IHealingModifier) |
| Zero-apply heals | Treated as non-applied (no events guaranteed) |
| Extreme amounts | ✔ Saturate at int.MaxValue rather than overflowing, both in the scale step and when added to current health |
| HP mutation | Clamped to Max |
| Overheal spill | Optional spill into OverhealShield when enabled |
| POST observers | Run once per successful heal |
| Lifecycle bypass | Revive / SetCurrent bypass the rule pipeline |
| Cross-op atomicity | ❌ Not guaranteed |
4. Shields¶
| Aspect | Guarantee |
|---|---|
| Execution slot | After PRE damage rules |
| Mutation | Shields never mutate HP directly; they only modify incoming damage |
| Full absorb | HP unchanged |
| Partial absorb | Remainder applied to HP |
| Preview seam | Accurate preview requires IShieldPreview |
| Shield selection | Defined by ShieldChain when present; otherwise first enabled shield |
| ShieldPool absorption | ❌ Not a shield |
5. Rules¶
| Aspect | Guarantee |
|---|---|
| PRE rules | May mutate context or cancel |
| POST rules | Observe finalized outcome |
| Rule ordering | Deterministic by Priority |
| Disabled rules | Skipped — both stages, PRE and POST |
| Exception safety | Exceptions caught; pipeline continues |
| Direct HP mutation | ❌ Not supported through rule contracts |
| Cross-rule atomicity | ❌ Not guaranteed |
A rule component that is unticked in the Inspector, or whose GameObject is inactive, does not run. Re-ticking it takes effect on the very next evaluation — no Refresh() needed, because the check is made when rules run rather than when they are discovered.
Changed in 1.2.0
Disabled PRE rules used to run anyway: rules were discovered with GetComponents, which includes disabled components, and nothing consulted enabled. The POST stage already honoured the tickbox, so the two halves of one pipeline disagreed. If a project relied on a disabled-but-attached PRE rule still applying, that rule now needs to be enabled or removed.
6. Authority¶
| Aspect | Guarantee |
|---|---|
| Opt-in gating | requireAuthority |
| Denied mutation | Deterministic block |
| Error surface | AuthorityDenied event + LastAuthorityError, plus one console warning per component (suppressible with logAuthorityDenials) |
| Throwing authority | ⚠ The exception propagates out of the mutation, before any write, event, LastAuthorityError or warning — so neither surface above reports it. Not converted to a denial; Currency is the only system that converts. Inside the before-death seam a throw is caught as handler isolation and death proceeds |
| Read-only access | Allowed |
| Disabled or destroyed authority | ✔ Stops answering on the next mutation; resolution reopens. True of a custom IHealthAuthority as well as of the shipped binder |
| Swapped binder | ✔ Takes effect on the next mutation |
| Automatic discovery | ✔ Any IHealthAuthority, nearest first — object, parents, scene. Changed in 1.3.0; it was the HealthAuthorityBinder component type only |
Custom IHealthAuthority | ✔ Found in the scene like the shipped binder, and subject to the same active-and-enabled test — at resolution and on every later mutation |
SetAuthorityResolver | ✔ Still takes precedence over anything resolved from the scene |
| Replication | ❌ Not provided |
| Ownership model | ❌ Not defined |
Discovery matches the interface — it did not before 1.3.0
Every resolution step now looks for IHealthAuthority, which is what the other four discovering systems have always done. Until 1.3.0 every step looked for the concrete HealthAuthorityBinder, so a custom implementation left in the scene was never found and the public interface had no reachable caller at all.
Nearest wins, and it wins whether or not another component resolved first. The two hierarchy steps — the object being gated, then its parents — run ahead of the scene cache and their answer is never written into it, so an actor carrying its own IHealthAuthority is governed by that one and nothing else. Only the scene-wide steps cache, and the entry they write answers for every HealthSystem that has no authority of its own.
Until 1.3.0 the cache was consulted first and hierarchy hits were cached, so "nearest wins" held only until some other component in the scene resolved: after that an actor's own denying authority was never asked, and an actor's own granting one leaked to every actor that had none.
SetAuthorityResolver still wins over anything resolved from the scene, and remains the way to install a rule the scene cannot express. See the Authority section of the Health Overview.
7. Lifecycle¶
| Aspect | Guarantee |
|---|---|
| Death pipeline | IBeforeDeathHandler → IHealthDeathHandler → UnityEvents → C# event |
| Cancellation | Only via IBeforeDeathHandler |
| Revive | Clears dead state |
| Revive atomicity | ✔ Single health mutation operation |
| Multi-handler atomicity | ❌ Not guaranteed |
8. Regeneration¶
| Aspect | Guarantee |
|---|---|
| Delay after damage | ✔ |
| Smooth/tick modes | ✔ |
| Ceiling enforcement | ✔ (when enabled) |
| Authority respect | ✔ |
| Frame-rate independence | ✔ Both modes carry the remainder across frames |
| Heals per frame | Smooth: up to Max Smooth Heals Per Frame. Tick: one |
| Catch-up after a stall | ⚠ Bounded at one interval in tick mode |
| Multi-source coordination | ❌ Not guaranteed |
MaxHealthModifierStack entries persisted | ❌ No — see below |
Frame-rate independence, precisely. Both modes accumulate: smooth carries a fractional heal, tick carries the overshoot past the interval. So the long-run rate is the configured rate at any frame rate, and two clients running at 144 Hz and 30 Hz agree.
Tick mode heals at most once per frame, which is what makes it discrete. A frame longer than a whole interval therefore cannot be paid off inside it, and the carried debt is capped at one interval rather than banked — a stall costs you the ticks it swallowed instead of discharging them as a burst when the frame rate recovers. With the default one-second interval this is unreachable; with the 0.01 s minimum it is every frame. Smooth mode has no such cap and heals up to Max Smooth Heals Per Frame.
Corrected in 1.3.0
This row read "✔ (accumulator-based)" and tick mode was not accumulator-based: it reset its timer to zero after each tick, discarding the overshoot, so the effective interval was interval + E[overshoot] — a function of frame time. Measured at the defaults over 600 s: 595 heals at 120 fps against 590 at 60 fps, where 600 was due. The remainder is now carried, and HealthRegenerationFrameRateTests compares the tick count at two frame rates.
MaxHealthModifierStack is not a save participant. Neither its entries nor its captured baseline is written to a save; the Health section persists HealthSystem and nothing else. A load therefore restores the effective max the stack had produced and knows nothing about the stack.
This is the ordinary shape for a modifier stack — what buffs an actor is normally rebuilt by whatever applies it — but it has a consequence worth stating: Reapply() after a load recomputes from the Awake baseline, so a game that does not re-push its modifiers on load will see the first reapply overwrite the restored max. Re-push your modifiers before anything triggers a reapply, or call CaptureBaselineFromCurrent() after the load.
9. Invincibility¶
| Aspect | Guarantee |
|---|---|
| Damage block | ✔ While active |
| Tick ownership | HealthSystem drives Tick |
| Disable edge | Disabling while active emits end event once |
| Scaled/unscaled | Supported |
| Retroactive block | ❌ No |
Persistence¶
| Aspect | Guarantee |
|---|---|
| Snapshot type | Absolute state |
| Restore behaviour | Silent |
| Event emission | ❌ None |
| Shield persistence | ❌ Not automatic |
| Cross-owner atomicity | ❌ Not guaranteed |
11. Preview¶
| Aspect | Guarantee |
|---|---|
| No health / shield / death mutation | ✔ |
| No events | ✔ |
| No death | ✔ |
| Rule state left alone | ✔ for every shipped rule — see below |
| Sees rules the hit will see | ✔ |
| Invincibility considered | ❌ Ignored |
| Authority checks | ❌ Ignored |
| Damage lock considered | ❌ Ignored |
| Shield accuracy | Only if IShieldPreview implemented |
| Deterministic ordering | ✔ (structural ordering only) |
Preview runs the live rule components, which is what makes it agree with the hit it is predicting. Each is told through DamageContext.IsPreview / HealContext.IsPreview that nothing will be committed; CritRule and AntiHealRule do not roll when they see it, and no other shipped rule holds state to advance.
A custom rule that ignores IsPreview still mutates
This guarantee covers the rules RevFramework ships. It cannot cover a rule of your own that advances a counter, cooldown or RNG without checking the flag — preview calls straight into it, at whatever rate your UI repaints.
12. Events¶
| Aspect | Guarantee |
|---|---|
| Death event | Raised once per death |
| DamageAttempted | Fires per attempt |
| HealthChanged | After HP mutation |
| Event ordering | Deterministic within a single health mutation pipeline |
| Event emission | Occurs after evaluation; mutation-dependent events fire only on successful state changes |
| Listener isolation | Exceptions caught |
| Cross-system ordering | ❌ Not guaranteed |
13. Public API¶
| Aspect | Guarantee |
|---|---|
| Stable contracts | ✔ (DamageContext, HealContext, etc.) |
| Internal namespaces | ❌ Not supported |
| Composition safety | ✔ |
| Subclass safety | ❌ Not supported |
Non-Guarantees¶
The Health system does not guarantee
- ❌ Multi-operation atomicity across rules + shields + handlers
- ❌ Multiplayer replication
- ❌ Rollback determinism
- ❌ Cross-scene authority safety
- ❌ Cross-owner atomic mutation
- ❌ Network prediction
- ❌ Automatic shield/effect persistence
- ❌ Deterministic RNG unless explicitly seeded
- ❌ Shield stacking behaviour without
ShieldChain
Summary¶
Health guarantees
- Deterministic ordering
- Explicit extension seams
- Idempotent death handling
- Event isolation
- Clear responsibility boundaries
Health does not attempt to be:
- A netcode framework
- A transaction engine
- A persistence manager
- A prediction system
- A gameplay brain
If it’s not listed above, it is not guaranteed.