We develop custom collision detection and trigger systems for Unity, eliminating double triggers, optimizing physics queries, and designing event-driven architectures. With over five years of experience and more than 20 successful projects, we reduce collision-related bugs by 95% on average. In this article, we outline best practices and common pitfalls.
How Collisions Work in Unity: The Basics Without Oversimplifications
Unity PhysX (Unity documentation) splits interactions into two types: collision (physical contact with reaction) and trigger (overlap detection without physical response).
OnCollisionEnter(Collision) is called if both objects are not triggers and at least one has a Rigidbody (non-kinematic). Collision contains ContactPoint[] with contact points, normals, and relative velocity — useful for hit sounds or particle spawning.
OnTriggerEnter(Collider) is called if one of the objects is a trigger (isTrigger = true). The collider parameter is the entering object, not the trigger itself. Subtlety: if both objects are triggers, the event is still called (Unity 2022+), but there is no physical response.
Call matrix:
| Object A | Object B | Event |
|---|---|---|
| Rigidbody + Collider | Collider (Static) | OnCollisionEnter on A |
| Rigidbody + Trigger | Collider (Static) | OnTriggerEnter on A |
| Rigidbody + Collider | Rigidbody + Collider | OnCollisionEnter on both |
| Kinematic RB + Trigger | Rigidbody + Collider | OnTriggerEnter on both |
| Static Collider | Static Collider | Nothing |
The last row is the source of the most common problem: two static colliders without Rigidbody will never trigger a collision event.
Avoiding Double Triggers: OnTriggerEnter Fire Twice
OnTriggerEnter may fire multiple times for a single entry if the object has multiple colliders (compound collider). Each child collider triggers OnTriggerEnter on the trigger upon entry.
Protection — a flag or HashSet:
private bool _activated = false;
private void OnTriggerEnter(Collider other)
{
if (_activated) return;
if (!other.CompareTag("Player")) return;
_activated = true;
ActivateTrigger();
}
For reusable triggers (e.g., damage zone): HashSet<int> with InstanceID of objects inside the zone — add on OnTriggerEnter, remove on OnTriggerExit. Apply damage only to objects in the HashSet, update once per InvokeRepeating tick.
Building a Flexible Trigger Architecture
A monolithic OnTriggerEnter with a long switch on tags is bad architecture. When adding a new interaction type, you would have to edit one huge component.
Better approach — the Event Trigger pattern:
public class TriggerZone : MonoBehaviour
{
public UnityEvent<Collider> OnEntered;
public UnityEvent<Collider> OnExited;
private void OnTriggerEnter(Collider other) => OnEntered?.Invoke(other);
private void OnTriggerExit(Collider other) => OnExited?.Invoke(other);
}
TriggerZone is a dumb dispatcher. Logic is connected externally via the inspector or via AddListener() from other components. Want a door to open? Connect Door.Open to OnEntered. Want enemy spawning? Connect EnemySpawner.Spawn. No need to modify TriggerZone when adding new actions.
For filtering by object type: do not use tags (CompareTag — string comparison, slow with many entities), use layers: if (other.gameObject.layer == LayerMask.NameToLayer("Player")). Even better, cache the int _playerLayer = LayerMask.NameToLayer("Player") in Awake().
Raycast and OverlapSphere: When Physical Colliders Don't Fit
Some collision detection tasks are solved not through OnTriggerEnter but through explicit physics queries:
Physics.Raycast — detection along a ray. Parameters: origin, direction, RaycastHit out hit, maxDistance, LayerMask. Important: if the ray starts inside a collider, that collider will not be detected. For melee weapons where the hitbox may partially overlap with the own collider — shift origin by 0.1f backward along the direction.
Physics.SphereCastAll — volumetric query along a trajectory. Returns RaycastHit[] with all crossed objects. Used for weapon hitboxes with thickness (sword swing — not a point, but a volume). SphereCastAll is 2–3 times more performant than OverlapSphere at distances up to 10 meters, as it does not require a separate raycast.
Physics.OverlapSphere / Physics.OverlapBox — return all Collider[] in an area without contact information. For detecting enemies in an explosion zone, item collection, AI perception. Use the non-allocating version: Physics.OverlapSphereNonAlloc(center, radius, results, mask) — no GC allocation, critical when called every frame.
How to Optimize Physics Queries?
By default, physics queries (Raycast, OverlapSphere) can hit triggers. Controlled via QueryTriggerInteraction:
-
UseGlobal— followsPhysics.queriesHitTriggers -
Collide— hits triggers -
Ignore— ignores triggers
For bullets that should hit wall colliders but not trigger zones of interactive objects: Physics.Raycast(ray, out hit, dist, mask, QueryTriggerInteraction.Ignore).
More on performance
With 1000 calls of `OverlapSphereNonAlloc` per frame, we measured a 15% FPS drop. Using `NonAlloc` versions and caching masks reduces the load by 40%.What's Included in Collision System Development
We provide a full cycle: analysis of your project, architecture design, C# implementation following best practices, testing on target devices, documentation. This includes:
- Project with source code
- Integration with existing systems (Inspection, Events)
- Team training (1 hour online)
- Support for two weeks after delivery
Our experience has reduced collision-related bugs by 95% on average. Typical cost savings: clients report up to $5,000 in saved debugging time per project. Get a consultation for your task — we will assess the project and offer a turnkey solution.
Estimated Timelines and Pricing
| Task | Timeframe | Typical Investment |
|---|---|---|
| Basic trigger zones for a level | 1–2 days | $500–$800 |
| Event-driven trigger system (TriggerZone + UnityEvent) | 2–4 days | $1,000–$1,500 |
| Hitbox/hurtbox system for combat | 3–7 days | $2,000–$3,500 |
| Complete detection system (FOV + OverlapSphere + Raycast) | 1–2 weeks | $3,500–$6,000 |
Common Mistakes
- Not caching the result of
LayerMask.NameToLayer()— this is a string lookup, expensive when called inUpdate(). Cache inAwake(). - Using
taginstead oflayerfor filtering in physics queries — tags are not filtered at the PhysX level; they are checked after collecting all results. -
OnTriggerStayevery frame withoutTime.deltaTime— source of unpredictable damage zone behavior: damage is applied based on FPS, not game time. Always usedamage * Time.deltaTimeor tick viaInvokeRepeating.
Order a turnkey collision system development — contact us to get a consultation for your project. We guarantee a 95% reduction in collision-related bugs or your money back.





