Skip to content

Combat Contracts

namespace MassiveSwarmSystem.Runtime.Combat

Three small interfaces connect the swarm's attack layer to your game objects. Implement them on your own health and target scripts when you don't want the ready-made sample components. The swarm resolves them once when a target registers, then calls into whatever it found when a hit lands. There is no global manager and no physics-event dependency.

You may not have to implement anything

The sample components (PlayerCombatTarget, DestructibleCombatTarget, SwarmAgentCombatTarget) already implement these contracts. Reach for the raw interfaces only when you have an existing health system the swarm should call into. For the components, their Inspector fields, and the turret/projectile workflow, see Combat Integration.

How the swarm finds your contract

When a SwarmTarget registers, the manager resolves the contracts once and caches them on the target:

  • GetComponentInParent<IDamageable>(): the receiver of swarm contact damage.
  • GetComponentInParent<ITargetable>(): the swarm's own damage gate. Each step, before an agent swings or lands a contact hit, the manager checks the cached IsTargetable and skips the target when it reads false. Weapons do not use this cache: ProjectileWeapon resolves ITargetable itself on whatever collider it hits.

  • Your component must sit on the SwarmTarget's GameObject or a parent, never a child.

  • The lookup runs at registration (OnEnable). If you add the component after the target is already registered, disable and re-enable the SwarmTarget so the swarm picks it up. Calling RegisterTarget a second time does nothing: it sees the transform is already registered and returns before it re-reads the contracts. From script, UnregisterTarget then RegisterTarget.

Swarm contact damage always calls the context overload

Every swarm hit carries a hit point and a direction, so there is always a context to pass, and the melee/contact path calls ApplyDamage(float, in SwarmDamageContext) and never the plain overload. Both are required members of IDamageable, so you cannot miss it by accident. If your receiver does not care about the metadata, forward the context overload to the plain one and you are done.

Pushback is the part of the context that varies. Swings ship with Swing Pushback Strength at 1, so HasPushback is true, but Contact Pushback Strength ships at 0 on purpose, because a shove on every tick reads as jitter. Handle a context whose HasPushback is false rather than assuming a shove.

DamageableBase is a sample shortcut, not part of the runtime

The StarterKit ships DamageableBase, which implements IDamageable for you along with a health pool and a death event. Subclass it while you are prototyping. It lives in the sample assembly, so it disappears when you delete the StarterKit, and anything shipping in your own game should implement the interface directly. See Samples & Cleanup.

The damage call itself happens on the main thread inside the manager's FixedUpdate. The swarm builds the context from the attacking agent's pose:

// Paraphrased from SwarmManager's attack pass, shown so you know what your
// ApplyDamage override receives.
SwarmDamageContext ctx = SwarmDamageContext.FromHit(
    source: manager,        // the SwarmManager that owns the agent
    hitPoint: agentPos,
    hitDirection: agentForward,
    hitNormal: -agentForward,
    pushbackStrength: attackProfilePushback);

damageable.ApplyDamage(damage, ctx);

IDamageable

public interface IDamageable
{
    bool CanTakeDamage { get; }
    bool ApplyDamage(float damage);
    bool ApplyDamage(float damage, in SwarmDamageContext context);
}

CanTakeDamage is checked before every hit. Return false for i-frames, a dead state, or a not-yet-spawned object. ApplyDamage returns true when damage was actually applied and false when ignored.

Implementing the context overload on your own health component, with knockback straight from the hit data:

using MassiveSwarmSystem.Runtime.Combat;
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class PlayerHealth : MonoBehaviour, IDamageable
{
    [SerializeField] private float m_health = 100f;
    private CharacterController m_controller;

    private void Awake() => m_controller = GetComponent<CharacterController>();

    public bool CanTakeDamage => m_health > 0f;

    // Plain overload: the swarm never calls this one, but the projectile path and
    // your own code might, so forward it to keep one code path.
    public bool ApplyDamage(float damage) => ApplyDamage(damage, SwarmDamageContext.None);

    public bool ApplyDamage(float damage, in SwarmDamageContext context)
    {
        if (!CanTakeDamage || damage <= 0f)
            return false;

        m_health -= damage;

        if (context.HasPushback)
        {
            Vector3 push = context.GetPushbackDirection(transform.position) * context.PushbackStrength;
            m_controller.Move(push * Time.fixedDeltaTime);
        }

        return true;
    }
}
Member Description
IDamageable.CanTakeDamage Gate checked before each hit. false makes the object immune right now.
IDamageable.ApplyDamage(float) Applies damage with no hit metadata. Returns whether it landed.
IDamageable.ApplyDamage(float, in SwarmDamageContext) Same, plus hit point / direction / pushback. The overload the swarm always calls.

SwarmDamageContext

A small struct describing how a hit happened. Read the Has* guard before reading the matching field, since an empty context (SwarmDamageContext.None) has them all false.

Member Type Description
Source UnityEngine.Object What caused the damage (the SwarmManager for contact hits, your weapon for projectiles).
HitPoint / HasHitPoint Vector3 / bool World-space impact position.
HitDirection / HasHitDirection Vector3 / bool Normalized direction the hit travelled.
HitNormal / HasHitNormal Vector3 / bool Surface normal at the impact.
PushbackStrength / HasPushback float / bool Push magnitude from the attack profile; HasPushback is true when it's positive.
HasSource bool true when Source is set.
None SwarmDamageContext A shared empty context: all guards false.
FromHit(source, hitPoint, hitDirection, hitNormal, pushbackStrength = 0) SwarmDamageContext Builds a populated context, normalizing the directions for you.
GetPushbackDirection(receiverPosition, flattenToXZPlane = true) Vector3 Resolves a push direction from whatever data is present: HitDirection, else receiver-minus-HitPoint, else -HitNormal. Flattens to XZ by default so receivers don't launch upward. Returns Vector3.zero if nothing usable.

SwarmHitPoint

public static Vector3 Resolve(in RaycastHit hit, Vector3 referencePoint);
public static Vector3 OnCollider(Collider hitCollider, Vector3 referencePoint);

Resolves a usable contact point for the HitPoint above. Run every cast hit through it before it reaches a context.

The reason is a Unity behavior that this asset walks into constantly: a cast that begins already overlapping a collider returns distance 0, point Vector3.zero, and a normal that just mirrors the sweep direction. Feed that straight into FromHit and the impact VFX and floating damage number appear at the world origin. A melee arc inside a packed crowd starts overlapped on most frames, so this is the common case here rather than a corner one.

Resolve keeps hit.point for a genuine sweep and, for the zero-distance case, falls back to the closest point on the collider to referencePoint (pass the sweep origin). OnCollider is that fallback on its own, for overlap queries that have a Collider but no RaycastHit. Box, sphere, capsule and convex mesh colliders answer exactly; concave meshes and terrain echo the input back from Collider.ClosestPoint, so those degrade to the collider bounds and finally to its transform position.

Both SwarmProjectile and RotatingWeapon call it, so copying either one carries the fix along.

ITargetable

public interface ITargetable
{
    bool IsTargetable { get; }
    Vector3 TargetPoint { get; }
    Transform AimTransform { get; }
}

Implement this to make an object a valid auto-target for ProjectileWeapon and any custom targeting you write.

Member Description
IsTargetable Whether this object is in play right now. Return false when it is dead or otherwise out. It gates two things: whether a weapon may pick it, and whether swarm swings and contact hits land on it. Note that it does not gate target selection, so agents still walk toward a target that reports false, they just cannot hurt it.
TargetPoint World-space point a weapon aims at: the body center, not the pivot, for a character.
AimTransform The transform used to resolve hierarchy ownership and avoid self-hits.

Body radius lives on SwarmTarget, not here

ITargetable only governs auto-targeting. How wide a target's body is, meaning what the swarm surrounds and stops around, comes from the SwarmTarget component on the same object. The two are independent.

Quick reference in your IDE

Key members carry a short XML summary, so hovering them in Visual Studio or Rider shows a one-line description while you code. This page has the full detail.

  • Combat Integration: the ready-made components, their Inspector fields, and the turret/projectile workflow.
  • SwarmTarget: registering a target and where the body radius comes from.
  • Attack Profile: what drives damage and PushbackStrength, and how reach is measured.