ZOA Combat
One damage payload and one receiver interface that every weapon, projectile, and hitbox flows through.
Combat is a contract package. It holds the single DamageContext struct and the single IDamageReceiver interface that every damage producer in the stack constructs and calls, and nothing else that would give a producer a reason to import a gameplay package to hurt something.
It exists because the alternative had already happened. The AI package had its own DamageContext and IAiDamageReceiver; Nucleon had IDamageHandler and ProjectileHitInfo; a hitscan shot and a melee swing reached a target through different types with different rules about who applied the headshot multiplier. Combat replaces all of that with one payload so producers and consumers stop fragmenting by weapon path.
The design is a struct passed by in reference and a method that returns a float. A producer fills in what it knows, a receiver applies what it wants, and the return value is the damage that actually landed. Everything else in the package sits around that core: an optional impulse wrapper for knockback and ragdolls, a penetration-cost component for pierce chains, a diagnostic flow bus, and one ready-made health receiver for props and targets.
Depends on (1)
How it works
Concepts
The originator owns the multiplier
DamageContext carries three numbers where a naive design would carry one. Amount is the final, authoritative damage with every region, armour, and weapon multiplier already baked in. BaseAmount is what the same shot would have done against a neutral zone. Multiplier is the factor that got from one to the other.
Receivers must never re-apply the region multiplier. A hitbox that resolved a head hit at two times damage bakes the two into Amount before constructing the context and reports 2.0 in Multiplier. A receiver that helpfully doubled again would produce a four-times headshot that nobody authored, which is the class of bug the unified contract was built to remove.
BaseAmount and Multiplier survive so the HUD and telemetry can separate the bonus from the base without a second query. When a producer does not supply BaseAmount, the constructor derives it as Amount divided by Multiplier rather than leaving it at zero.
The region name "Melee" is a legacy signal that the originator has already applied a melee multiplier and the receiver should skip per-zone handling. It is honoured for compatibility with the pre-unification path.
Return value is the mitigation channel
Receive returns the damage actually applied after any receiver-side mitigation: armour, shields, invulnerability frames, a target that is already dead. A receiver that does not mitigate returns the context's Amount; one that fully absorbs returns zero.
Producers get their telemetry from that return without needing a follow-up query. "The weapon dealt twenty-five, the target absorbed fifteen" is one call, and the diagnostic bus records both numbers side by side.
Resolution is a plain GetComponentInParent walk from whatever collider the producer hit, so an implementation can sit on the rig root or on a dedicated damage-routing child and both cases work without the producer knowing which.
Impulse rides alongside the damage payload
Knockback, ragdoll force, and hit reactions stay out of DamageContext because damage and impulse are separable: a hit can deal damage with no physical response, or push a body around after the receiver has mitigated the numbers down to nothing.
DamageImpactContext wraps a DamageContext with a DamageImpulse and two semantic ids, and it implicitly converts from a bare DamageContext so nothing that only wants damage has to know it exists. DamageImpulse itself normalises on construction: a zero magnitude collapses the mode to None, direction is normalised, radius and magnitude are clamped non-negative.
IDamageImpactReceiver extends IDamageReceiver rather than replacing it, and ReceiveImpactBestEffort is the producer's safe entry point. It routes to ReceiveImpact when the receiver understands impulses and falls back to the plain damage call when it does not, so a producer never has to type-test at the call site.
Penetration cost is a property of the target
PenetrationProperties is an optional MonoBehaviour that answers one question: how much penetration power does a round lose passing through this object. It is not a threshold on the receiver: "did this round penetrate" and "should this round continue to the next target" are different questions, and a shared field conflates them.
The cost model is thickness in metres times a density multiplier times a scaling constant of twenty, which puts the results on the same scale as a ballistic profile's armour penetration values. A paper target costs about 0.1, a wood plank about 1, a thin steel plate about 3.2, and a concrete wall about 18. An override field bypasses the two-parameter model entirely for surfaces it does not describe well.
The component also carries exit-decal intent: whether an exit decal should spawn at all, and an optional impact-class override for surfaces whose exit signature differs from their entry, such as a thin steel plate that spalls on the way out.
The flow bus reconstructs a shot as a graph
DamageFlowBus is a static, process-wide log of damage events. A producer calls BeginShot once per trigger pull to allocate a monotonic id, then RecordHit once per Receive call, tagging each with a pellet index and a sequence index.
With those three keys a diagnostic HUD can draw the shape of a shot instead of a list of numbers. The shot id groups every pellet and every pierce step from one trigger pull, the pellet index fans shotgun branches out from a shared muzzle node, and the sequence index chains the hits along one pellet's ray so a round that punched through two targets renders as a connected path instead of two unrelated dots.
History is a ring buffer capped at 256 events, which a HUD reads on enable so it can populate itself with whatever happened before it was visible. The bus is static rather than service-resolved because it fires on hot paths, per pellet and per pierce step, where a registry lookup per event is not worth paying for.
In the editor
Screens
Screenshot pending
/screenshots/combat-health-damage-receiver.png
Inspector for a range-target GameObject showing the Health Damage Receiver component with max health, minimum applied damage, auto-reset on defeat enabled, and the defeated reset fraction slider visible.
Screenshot pending
/screenshots/combat-penetration-properties.png
Inspector showing thickness and density fields set for a steel plate, the override cost left at zero, the exit-decal impact class filled in with a spall variant, and the resulting penetration cost readable alongside.
Setup
Workflow
- 01
Make the target a receiver
Implement IDamageReceiver on the rig root or a damage-routing child, or drop a HealthDamageReceiver on props and range targets and be done. The producer's GetComponentInParent walk finds either placement, so the choice is about where the health state naturally lives.
- 02
Build the context at the point of impact
Fill in the amount and instigator, then whatever else you know: hit point and normal for decals, incoming direction for knockback, the collider for surface resolution, the weapon ids for correlation, and the impact class id so the VFX layer can pick the right decal variant. Bake any region multiplier into Amount and report the factor in Multiplier.
- 03
Add impulse only when something will consume it
Wrap the context in a DamageImpactContext and call ReceiveImpactBestEffort when the hit should also push a ragdoll or trigger a reaction. Receivers that do not implement the impact interface still get the plain damage call, so adding impulse to a producer never breaks an existing target.
- 04
Tag pierce chains for the flow log
Call BeginShot once per trigger pull and reuse that id for every pellet and every pierce step. Increment the pellet index across the pattern and the sequence index along each ray, then read PenetrationProperties on each target to decide whether the round keeps going.
Surface
Key types
IDamageReceiver
interface
The single interface every damage target implements. Producers resolve it with GetComponentInParent from the collider they hit and call Receive with a context they built.
- float Receive(in DamageContext ctx)
DamageContext
struct
The canonical damage payload. Readonly, passed by in reference, with two required constructor arguments and the rest named optionals so most call sites set four to six fields.
- float Amount, BaseAmount, Multiplier
- string HitRegion (empty normalises to "default")
- GameObject Instigator
- DefinitionId WeaponDefinitionId; InstanceId WeaponInstanceId
- Vector3 HitPoint, HitNormal, IncomingDirection; Collider HitCollider
- string ImpactClassId; float PenetrationPower; float TimeStamp
DamageImpulse
struct
Optional physical response attached to a hit. Normalises itself on construction and offers Directional and Explosion factory helpers.
- DamageImpulseMode Mode; Vector3 Direction; float Magnitude
- Vector3 Point; float Radius; Vector3 Torque
- bool HasImpulse
- static DamageImpulse Directional(Vector3 direction, float magnitude, Vector3 point = default)
- static DamageImpulse Explosion(Vector3 origin, float magnitude, float radius)
DamageImpactContext
struct
Wraps a DamageContext with an impulse plus a reaction id and surface id. Implicitly converts from DamageContext, so passing a bare damage payload where an impact is expected is legal.
- DamageContext Damage; DamageImpulse Impulse
- string ReactionId; string SurfaceId; bool HasImpulse
IDamageImpactReceiver
interface
Extends IDamageReceiver for targets that also consume physical impulse. Producers reach it through the extension method rather than by downcasting.
- DamageImpactResult ReceiveImpact(in DamageImpactContext ctx)
DamageImpactResult
struct
What an impact-aware receiver reports back: the damage applied, whether the physical part was consumed, and an optional message.
- float DamageApplied; bool ImpulseAccepted; string Message
- static DamageImpactResult FromDamage(float damageApplied)
DamageImpactReceiverExtensions
class
Holds ReceiveImpactBestEffort, the safe producer entry point that routes impact-aware receivers to ReceiveImpact and falls back to plain Receive for everything else.
- static DamageImpactResult ReceiveImpactBestEffort(this IDamageReceiver receiver, in DamageImpactContext ctx)
HealthDamageReceiver
component
A ready-made health-backed receiver for props, practice dummies, and objectives. Owns the health bookkeeping and raises Damaged and Defeated so scene-specific components can translate defeat into score, VFX, or objective progress.
- float MaxHealth, CurrentHealth; bool IsDefeated
- float Receive(in DamageContext ctx)
- float ApplyDamage(float amount, in DamageContext ctx)
- void Configure(float maxHealth, bool autoResetOnDefeat = true, float defeatedResetHealthFraction = 1f, float minimumAppliedDamage = 0f)
- void ResetHealth(float health); void ResetHealthToMax()
- event HealthDamageEvent Damaged, Defeated
IHealthDamageAuthority
interface
Optional hook found on the same GameObject. Returning true consumes the damage request, which is how a networked receiver relays to a server instead of mutating local health.
- bool TryHandleDamage(HealthDamageReceiver receiver, in DamageContext context, out float handledAmount)
PenetrationProperties
component
Declares how much penetration power a round loses passing through this object, plus exit-decal intent. Sits on any damage-eligible GameObject so the delivery layer can run one deduct-and-continue loop.
- float Thickness, Density; float PenetrationCost
- string ExitDecalImpactClassId; bool SpawnExitDecal
- void Configure(float thicknessMetres, float density, bool spawnExitDecal = true, string exitDecalImpactClassId = null)
DamageFlowBus
class
Static capture of damage events as they happen. Producers allocate a shot id, record each hit, and a diagnostic HUD reads the bounded history or subscribes to the event.
- const int HistoryCapacity = 256
- static event Action<DamageFlowEvent> Published
- static int BeginShot()
- static void RecordHit(int shotId, int pelletIndex, int sequenceIndex, in DamageContext context, GameObject target, float amountApplied)
- static DamageFlowEvent[] History
- static void Clear()
DamageFlowEvent
struct
One captured hit. Carries the full DamageContext plus the grouping keys and the receiver's returned AmountApplied, which differs from the context's Amount whenever mitigation happened.
- int ShotId, PelletIndex, SequenceIndex
- DamageContext Context; string TargetName
- float AmountApplied; float TimeStamp
Usage
Examples
using UnityEngine;
using ZOA.Combat;
void ApplyHit(RaycastHit hit, Vector3 rayDirection, float baseDamage, float zoneMultiplier)
{
var receiver = hit.collider.GetComponentInParent<IDamageReceiver>();
if (receiver == null) return;
// The originator bakes the zone multiplier in and reports it,
// so the receiver must not apply it a second time.
var ctx = new DamageContext(
amount: baseDamage * zoneMultiplier,
instigator: gameObject,
hitRegion: "Head",
multiplier: zoneMultiplier,
baseAmount: baseDamage,
hitPoint: hit.point,
hitNormal: hit.normal,
incomingDirection: rayDirection,
hitCollider: hit.collider,
impactClassId: "bullet",
penetrationPower: 12f);
float applied = receiver.Receive(in ctx);
Debug.Log($"dealt {ctx.Amount}, target absorbed {ctx.Amount - applied}");
}using UnityEngine;
using ZOA.Combat;
public sealed class ShieldedTarget : MonoBehaviour, IDamageImpactReceiver
{
private float _shield = 50f;
private float _health = 100f;
public float Receive(in DamageContext ctx)
{
if (_health <= 0f) return 0f; // dead targets absorb nothing
float toShield = Mathf.Min(_shield, ctx.Amount);
_shield -= toShield;
float toHealth = ctx.Amount - toShield;
_health = Mathf.Max(0f, _health - toHealth);
// Return what actually landed, not what was requested.
return toShield + toHealth;
}
public DamageImpactResult ReceiveImpact(in DamageImpactContext ctx)
{
var damage = ctx.Damage;
float applied = Receive(in damage);
if (ctx.HasImpulse)
GetComponent<Rigidbody>()?.AddForceAtPosition(
ctx.Impulse.Direction * ctx.Impulse.Magnitude,
ctx.Impulse.Point,
ForceMode.Impulse);
return new DamageImpactResult(applied, impulseAccepted: ctx.HasImpulse);
}
}using System;
using System.Collections.Generic;
using UnityEngine;
using ZOA.Combat;
void FirePiercing(Vector3 origin, Vector3 direction, float damage, float penPower, RaycastHit[] buffer)
{
int shotId = DamageFlowBus.BeginShot();
int count = Physics.RaycastNonAlloc(origin, direction, buffer, 200f);
Array.Sort(buffer, 0, count, Comparer<RaycastHit>.Create(
(a, b) => a.distance.CompareTo(b.distance)));
for (int i = 0; i < count && penPower > 0f; i++)
{
var hit = buffer[i];
var ctx = new DamageContext(
amount: damage,
instigator: gameObject,
hitPoint: hit.point,
hitNormal: hit.normal,
incomingDirection: direction,
hitCollider: hit.collider,
penetrationPower: penPower);
var receiver = hit.collider.GetComponentInParent<IDamageReceiver>();
float applied = receiver != null ? receiver.Receive(in ctx) : 0f;
// Sequence index chains the hits so the HUD draws one path.
DamageFlowBus.RecordHit(shotId, pelletIndex: 0, sequenceIndex: i,
in ctx, hit.collider.gameObject, applied);
var pen = hit.collider.GetComponentInParent<PenetrationProperties>();
penPower -= pen != null ? pen.PenetrationCost : 0f;
}
}Read this
Notes and caveats
See also