Quick answer: OnTriggerExit doesn’t fire when the other collider is disabled, destroyed, or returned to a pool mid-overlap. Track overlapping colliders in a HashSet on the trigger and clean it up from the disabling collider’s OnDisable / OnDestroy.
Player walks into a damage zone. Enemy spawns in. Enemy dies (disabled by pool). The damage zone’s overlap list still contains the dead enemy because OnTriggerExit never fired. Now the zone’s logic loops over a phantom enemy forever.
The Symptom
Trigger script holds references to colliders that are no longer active. Iterating the list throws null reference or hits disabled GameObjects. OnTriggerExit count != OnTriggerEnter count over time.
What Causes This
OnTriggerExit fires when an active collider physically leaves the trigger volume. If the collider is disabled or destroyed while still inside the volume, no “leave” event happens because the engine isn’t tracking transitions out of activity.
The Fix Pattern
Step 1: Track overlaps explicitly.
public class DamageZone : MonoBehaviour
{
private readonly HashSet<Collider> _inside = new();
void OnTriggerEnter(Collider other) { _inside.Add(other); }
void OnTriggerExit(Collider other) { _inside.Remove(other); }
void FixedUpdate()
{
// Always check liveness before using
_inside.RemoveWhere(c => c == null || !c.gameObject.activeInHierarchy);
foreach (var c in _inside)
DealDamage(c);
}
}
The defensive RemoveWhere catches dead entries each tick. Slightly wasteful but never wrong.
Step 2: Notify-on-disable, the cleaner pattern.
public class TriggerTracker : MonoBehaviour
{
private readonly List<DamageZone> _zones = new();
void OnTriggerEnter(Collider other) { /* unused, lives on DamageZone */ }
public void RegisterZone(DamageZone z) { _zones.Add(z); }
public void UnregisterZone(DamageZone z) { _zones.Remove(z); }
void OnDisable()
{
foreach (var z in _zones)
z.ForceExit(this);
_zones.Clear();
}
}
Each tracker knows which zones currently see it (registered on Enter, unregistered on Exit). When disabled, it tells every active zone to drop it.
Pool Integration
Pool release callback runs OnDisable. Either pattern above works as-is for pooled objects: when an enemy returns to the pool, OnDisable fires once, and the trigger’s state cleans up.
SyncTransforms Before Disable
If you teleport-then-disable in the same frame, OnTriggerExit can also fail because the engine’s overlap query hasn’t caught up. Call Physics.SyncTransforms() after a teleport that leaves a trigger before disabling, to force the overlap state to update.
Verifying
Add a Debug.Log in OnTriggerEnter and OnTriggerExit. Disable an inside collider. Confirm Exit doesn’t fire (expected). Then verify your tracker’s OnDisable cleanup drops the entry. Final count should match Enter minus disabled count.
“Don’t trust OnTriggerExit alone. Track explicitly. Clean up on disable.”
Related Issues
For pooled trail leaks, see trail leak. For collider not detecting, see collider detection.
HashSet. RemoveWhere. OnDisable cleanup. State stays sane.