# 3. Hitboxes and Hurtboxes Part of [[Combat Feel and Hit Feedback]]. Prev: [[2. Animator Attack Strings]]. Next: [[4. Hit-Stop Knockback and Feedback]]. A **hurtbox** is the volume that *receives* a hit (usually a slightly generous capsule around the body). A **hitbox** is the volume that *deals* a hit (the blade, the fist, the shockwave), and it exists only during the active window. If you use the same collider for both, you will eventually punch yourself, or your own sword will block your hurtbox, or a persistent weapon collider will tick damage every frame. This is the same layer-and-query discipline as [[3. Layers Matrix and Queries]]: the **Layer Collision Matrix** (`Edit > Project Settings > Physics`) decides which pairs can generate trigger callbacks. Query `layerMask` bits are independent of that matrix - a query can still find a layer that does not *physically* collide with you. Use both on purpose. ## 3.1 Recommended layers Create four gameplay layers (names are yours; be consistent): | Layer | Collides with (matrix) | Role | |---|---|---| | `PlayerHurtbox` | `EnemyHitbox` only | Player body that can be hit | | `PlayerHitbox` | `EnemyHurtbox` only | Player weapon, on only while active | | `EnemyHurtbox` | `PlayerHitbox` only | Enemy body | | `EnemyHitbox` | `PlayerHurtbox` only | Enemy weapon / bite | Movement colliders stay on `Player` / `Enemy` / `World` and do **not** participate in hit detection. CharacterController or capsule movement must not be the hurtbox: a `skinWidth` tweak should not change combat ranges. Both hitboxes and hurtboxes are **triggers** (`Is Trigger` on the collider). They need a Rigidbody on the same object or a parent to generate trigger messages when they move; a kinematic Rigidbody on the actor root is enough. If you detect with queries instead of messages (recommended during the active window), you still want the collider present so the query has something to find. ## 3.2 Hurtbox ```csharp using System.Collections.Generic; using UnityEngine; /// <summary> /// Body volume that can receive hits. Lives on a child collider, not on the movement capsule. /// NOT compiled in Unity. /// </summary> [RequireComponent(typeof(Collider))] public sealed class Hurtbox : MonoBehaviour, IHitResponder { [SerializeField] MonoBehaviour damageableBehaviour; // must implement IDamageable [SerializeField] int rememberedHits = 8; IDamageable _damageable; Collider _collider; readonly Queue<int> _recent = new(); readonly HashSet<int> _recentSet = new(); public IDamageable Damageable => _damageable; public Collider Collider => _collider; void Awake() { _collider = GetComponent<Collider>(); _collider.isTrigger = true; _damageable = damageableBehaviour as IDamageable; if (_damageable == null) Debug.LogError(quot;{name}: Hurtbox needs an IDamageable on damageableBehaviour.", this); } public void SetInvulnerable(bool invulnerable) { // Disabling the collider is the most reliable i-frame: queries and triggers both miss. _collider.enabled = !invulnerable; } public bool TryRegisterHit(int attackInstanceId) { if (_recentSet.Contains(attackInstanceId)) return false; _recent.Enqueue(attackInstanceId); _recentSet.Add(attackInstanceId); while (_recent.Count > rememberedHits) _recentSet.Remove(_recent.Dequeue()); return true; } } ``` `TryRegisterHit` is how one swing hits a target once even if the overlap lasts twelve frames. The id comes from the hitbox, which mints a new one each time it turns on. ## 3.3 Hitbox: prefer a query while active `OnTriggerEnter` misses the case where the blade is already overlapping the hurtbox on the frame it enables (no "enter"). A FixedUpdate **overlap query** while the hitbox is active does not have that hole. This is the same NonAlloc pattern as [[3. Layers Matrix and Queries]]. ```csharp using UnityEngine; /// <summary> /// Weapon volume enabled only during the active window. /// NOT compiled in Unity. /// </summary> public sealed class Hitbox : MonoBehaviour { [SerializeField] TeamId team = TeamId.Player; [SerializeField] Transform attackerRoot; [SerializeField] LayerMask hurtboxMask; [SerializeField] int bufferSize = 16; Collider _collider; readonly Collider[] _buffer = new Collider[16]; AttackDefinition _attack; int _instanceId; bool _active; int _nextInstance = 1; public bool IsActive => _active; void Awake() { _collider = GetComponent<Collider>(); _collider.isTrigger = true; _collider.enabled = false; if (_buffer.Length != bufferSize) Debug.LogWarning("Resize _buffer to match bufferSize, or allocate in Awake.", this); } public void Activate(AttackDefinition attack) { _attack = attack; _instanceId = _nextInstance++; _active = true; _collider.enabled = true; } public void Deactivate() { _active = false; _collider.enabled = false; _attack = null; } void FixedUpdate() { if (!_active || _attack == null) return; var box = _collider as BoxCollider; int count; if (box != null) { Vector3 worldCenter = box.transform.TransformPoint(box.center); Vector3 halfExtents = Vector3.Scale(box.size * 0.5f, box.transform.lossyScale); count = Physics.OverlapBoxNonAlloc( worldCenter, halfExtents, _buffer, box.transform.rotation, hurtboxMask, QueryTriggerInteraction.Collide); } else { // Fallback: use the collider bounds as an AABB (less precise for rotated capsules). Bounds b = _collider.bounds; count = Physics.OverlapBoxNonAlloc( b.center, b.extents, _buffer, Quaternion.identity, hurtboxMask, QueryTriggerInteraction.Collide); } for (int i = 0; i < count; i++) { var hurt = _buffer[i].GetComponent<Hurtbox>() ?? _buffer[i].GetComponentInParent<Hurtbox>(); if (hurt == null || hurt.Damageable == null) continue; if (!hurt.Damageable.IsAlive) continue; if (hurt.Damageable.Team == team) continue; if (hurt.Damageable.IsInvulnerable) continue; if (!hurt.TryRegisterHit(_instanceId)) continue; Vector3 point = _buffer[i].ClosestPoint(transform.position); Vector3 from = attackerRoot != null ? attackerRoot.position : transform.position; Vector3 planar = point - from; planar.y = 0f; if (planar.sqrMagnitude < 0.0001f) planar = attackerRoot != null ? attackerRoot.forward : transform.forward; planar.Normalize(); Vector3 knock = (planar + Vector3.up * _attack.knockbackUp).normalized * _attack.knockbackSpeed; var info = new DamageInfo { amount = _attack.damage, knockback = knock, contactPoint = point, contactNormal = -planar, hitStopScale = _attack.hitStopScale, hitStopDuration = _attack.hitStopDuration, attackInstanceId = _instanceId, sourceTeam = team, source = attackerRoot != null ? attackerRoot.gameObject : gameObject, attack = _attack }; hurt.Damageable.ApplyDamage(info); } } } ``` Pass `QueryTriggerInteraction.Collide` or you will miss trigger hurtboxes when the project-wide default is "ignore triggers." Mint `_instanceId` in `Activate`, not in `Awake`. A sword that stays enabled and only changes id once will never restrike on swing 2. ## 3.4 Animation events turn the box on and off Plant `Anim_HitboxOn` at the first active frame and `Anim_HitboxOff` at the last. Callbacks may take `AnimationEvent` or, in Unity 6, `AnimationEventInfo` (a ref struct that avoids allocating the `AnimationEvent` object). Either works; prefer `AnimationEventInfo` if your editor plants events the new way. ```csharp using UnityEngine; public sealed class AttackHitboxEvents : MonoBehaviour { [SerializeField] Hitbox hitbox; [SerializeField] MeleeComboController combo; public void Anim_HitboxOn() { var attack = combo != null ? combo.CurrentAttack : null; if (attack != null) hitbox.Activate(attack); } public void Anim_HitboxOff() => hitbox.Deactivate(); // Allocation-free path (6000.5 AnimationEventInfo). Same method name as above // cannot overload by AnimationEventInfo vs void - pick one signature per event. public void Anim_HitboxOn_Info(AnimationEventInfo info) { Anim_HitboxOn(); // info.animatorStateInfo.normalizedTime is available if you need to log it. } } ``` Event function names must match exactly, and the component must live on the same GameObject as the Animator (or a child that the event is configured to target - confirm in your clip's event inspector). See [[6.Animation Events Reliable Patterns]] for the "event fired after a CrossFade skipped the time" pitfall: if you blend in *past* the on-event, the box never enables. Either keep combat blends short, or also activate from a StateMachineBehaviour on Enter / a normalized-time fallback. ## 3.5 Exercise Draw the Layer Collision Matrix on paper before you click it. Then in Play Mode, turn on Gizmos (or a tiny `OnDrawGizmos` that draws the box when `IsActive`) and confirm the blade volume exists for a handful of frames only. Stand inside an enemy hurtbox, start a swing, and confirm you still get exactly one hit - that is the overlap-on-enable case `OnTriggerEnter` would miss. ## Sources - Layer-based collision detection (6000.5): https://docs.unity3d.com/6000.5/Documentation/Manual/LayerBasedCollision.html - `Physics.OverlapBoxNonAlloc` (6000.5): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Physics.OverlapBoxNonAlloc.html - `AnimationEventInfo` (6000.5): https://docs.unity.com/en-us/engine/6000.5/script-reference/unityengine/animationeventinfo - [[3. Layers Matrix and Queries]]