How to detect contact between overlapping physics bodies.
RealityKit has many event-based APIs. Contact events are events emitted by the physics engine when two collision shapes intersect. Contact events are of different kinds, such as contact began and contact ended.
I'm interested in two aspects:
-
How do I emit contact events between entities that have physics bodies without them actually colliding (pushing each other apart)?
-
What is the order of contact event delivery, so I can organize logic within the update loop?
Sensor Entities
In RealityKit, two entities can generate contact events when each entity's collision mask contains the other entity's collision group. For example:
let redEntity = Entity()
let blueEntity = Entity()
/// Bitmasks
let redGroup = CollisionGroup(rawValue: 1 << 0)
let blueGroup = CollisionGroup(rawValue: 1 << 1)
/// Collision shape
let shape = ShapeResource.generateBox(size: SIMD3(repeating: 0.2))
/// The red entity belongs to the red group and accepts contacts from the blue group.
redEntity.components.set(
CollisionComponent(
shapes: [shape],
filter: CollisionFilter(group: redGroup, mask: blueGroup)
)
)
/// The blue entity belongs to the blue group and accepts contacts from the red group.
blueEntity.components.set(
CollisionComponent(
shapes: [shape],
filter: CollisionFilter(group: blueGroup, mask: redGroup)
)
)
Contact events require a CollisionComponent. They do not require a PhysicsBodyComponent. In my projects, I have entities with physics bodies that are set to intersect with each other. So they wouldn't emit contact events. Yet I still need to detect when they intersect, in order to trigger some behavior. How to implement that?
The code below demonstrates a full working solution:
-
An invisible sensor child entity is added to a non colliding entity.
-
The sensor entity has a collision component but no physics body component.
-
The sensor entity collision filter is configured as needed.
/**
# Contact Detection
How to detect contact between entities that can intersect?
In technical terms: if two entities have collision filters that exclude each other, do they still emit collision events when they overlap?
Answer: no.
## Sensor Entities
A solution is to make special trigger entities.
- Create a sensor entity parented to the entity with the physics body.
- The sensor entity has a collision component but no physics body component.
- Configure its collision filter as needed.
## Trigger Volume
RealityKit also has a TriggerVolume entity dedicated to this use case:
https://developer.apple.com/documentation/realitykit/triggervolume
Note: Trigger volumes do not contact each other.
## Setup
In the experiment below:
- Entities A and B do not collide with each other.
- Entity A (red) has a child sensor entity, which is configured to detect collisions with the group of entity B.
- Entity A changes color when its sensor detects contact with entity B.
- The sensor entity can have any collision shape needed.
Achraf Kassioui
Created 3 Feb 2026
Updated 4 Aug 2026
*/
import SwiftUI
import RealityKit
import Combine
// MARK: View
struct ContactDetectionView: View {
var body: some View {
ZStack {
ContactDetectionRepresentable()
.ignoresSafeArea()
VStack {
Text("Contact Detection")
.font(.headline)
.padding()
.background(.ultraThinMaterial)
.cornerRadius(8)
Spacer()
Text("Watch Xcode console for collision events")
.font(.caption)
.padding()
.background(.ultraThinMaterial)
.cornerRadius(8)
}
.padding()
}
}
}
#Preview {
ContactDetectionView()
}
// MARK: Representable
struct ContactDetectionRepresentable: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
return ContactDetectionARView(frame: .zero)
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
// MARK: ARView
class ContactDetectionARView: ARView {
struct CollisionGroups {
static let groupA = CollisionGroup(rawValue: 1 << 0)
static let groupB = CollisionGroup(rawValue: 1 << 1)
static let sensor = CollisionGroup(rawValue: 1 << 2)
static let trigger = CollisionGroup(rawValue: 1 << 3)
}
private var anchor: AnchorEntity!
private var entityA: ModelEntity!
private var entityB: ModelEntity!
private var normalMaterial: SimpleMaterial!
private var glowMaterial: PhysicallyBasedMaterial!
private let oscillationSpeed: Float = 0.5
private let oscillationBound: Float = 0.6
private var movingRight = true
private var collisionBegan: Cancellable?
private var collisionEnded: Cancellable?
private var updateLoop: Cancellable?
// MARK: Init
required init(frame: CGRect) {
super.init(frame: frame)
//debugOptions = [.showPhysics]
setupMaterials()
setupScene()
setupCollisionEvents()
setupAnimation()
createTriggerVolumesTest()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Scene
private func setupMaterials() {
/// Normal material (red)
normalMaterial = SimpleMaterial()
normalMaterial.color = .init(tint: .systemRed)
/// Glow material (emissive)
glowMaterial = PhysicallyBasedMaterial()
glowMaterial.baseColor = .init(tint: .systemRed)
glowMaterial.emissiveColor = .init(color: .systemOrange)
glowMaterial.emissiveIntensity = 2.0
}
private func setupScene() {
environment.background = .color(.lightGray)
/// Anchor
anchor = AnchorEntity()
anchor.name = "Anchor"
scene.addAnchor(anchor)
var simulation = PhysicsSimulationComponent()
simulation.gravity = [0, 0, 0]
anchor.components.set(simulation)
/// Camera
cameraMode = .nonAR
let camera = PerspectiveCamera()
camera.look(at: [0, 0, 0], from: [0, 0, 2], relativeTo: nil)
anchor.addChild(camera)
// MARK: Entity A
let meshA = MeshResource.generateBox(size: 0.3, cornerRadius: 0.02)
entityA = ModelEntity(mesh: meshA, materials: [normalMaterial])
entityA.name = "EntityA"
let shapeA = ShapeResource.generateBox(size: [0.3, 0.3, 0.3])
let collisionA = CollisionComponent(
shapes: [shapeA],
filter: CollisionFilter(
group: CollisionGroups.groupA,
mask: []
)
)
entityA.components.set(collisionA)
var bodyA = PhysicsBodyComponent(shapes: [shapeA], mass: 1, mode: .dynamic)
bodyA.isAffectedByGravity = false
entityA.components.set(bodyA)
entityA.components.set(PhysicsMotionComponent())
anchor.addChild(entityA)
entityA.position = [-0.5, 0, 0]
// MARK: Sensor
let sensorMesh = MeshResource.generateSphere(radius: 0.05)
let sensorMaterial = SimpleMaterial(color: .white.withAlphaComponent(0.1), isMetallic: false)
let sensorShape = ShapeResource.generateSphere(radius: 0.05)
//sensorShape = ShapeResource.generateConvex(from: sensorMesh)
let sensorA = ModelEntity(mesh: sensorMesh, materials: [sensorMaterial])
sensorA.name = "SensorA"
let sensorACollision = CollisionComponent(
shapes: [sensorShape],
filter: .init(
group: CollisionGroups.sensor,
mask: [CollisionGroups.groupA, CollisionGroups.groupB]
),
)
sensorA.components.set(sensorACollision)
entityA.addChild(sensorA)
sensorA.position = [0, 0.15, 0]
// MARK: Entity B
let meshB = MeshResource.generateBox(size: 0.3, cornerRadius: 0.02)
var materialB = SimpleMaterial()
materialB.color = .init(tint: .systemBlue)
entityB = ModelEntity(mesh: meshB, materials: [materialB])
entityB.name = "EntityB"
var shapeB = ShapeResource.generateBox(size: [0.3, 0.3, 0.3])
shapeB = ShapeResource.generateConvex(from: meshB)
let collisionB = CollisionComponent(
shapes: [shapeB],
filter: CollisionFilter(
group: CollisionGroups.groupB,
mask: [CollisionGroups.sensor]
)
)
entityB.components.set(collisionB)
var bodyB = PhysicsBodyComponent(shapes: [shapeB], mass: 1, mode: .dynamic)
bodyB.isAffectedByGravity = false
entityB.components.set(bodyB)
entityB.components.set(PhysicsMotionComponent())
anchor.addChild(entityB)
entityB.position = [0.5, 0.325, 0]
}
// MARK: Trigger Volumes
/**
Test to see if trigger volumes emit collision events between each other.
They are visually invisible, but they are animated and set to intersect.
If RealityKit reports contact between them, the collision subscription prints a message in the console.
*/
private func createTriggerVolumesTest() {
let trigger1 = TriggerVolume(
shape: ShapeResource.generateSphere(radius: 0.1),
filter: CollisionFilter(
group: [Self.CollisionGroups.trigger],
mask: [Self.CollisionGroups.trigger]
)
)
trigger1.name = "Trigger1"
anchor.addChild(trigger1)
trigger1.position = [-0.5, -0.5, 0]
trigger1.move(to: Transform(translation: [-0.05, -0.5, 0]), relativeTo: nil, duration: 1)
let trigger2 = TriggerVolume(
shape: ShapeResource.generateSphere(radius: 0.1),
filter: CollisionFilter(
group: [Self.CollisionGroups.trigger],
mask: [Self.CollisionGroups.trigger]
)
)
trigger2.name = "Trigger2"
anchor.addChild(trigger2)
trigger2.position = [0.5, -0.5, 0]
trigger2.move(to: Transform(translation: [0.05, -0.5, 0]), relativeTo: nil, duration: 1)
}
// MARK: Events
private func setupCollisionEvents() {
collisionBegan = scene.subscribe(to: CollisionEvents.Began.self) { [weak self] event in
guard let self else { return }
print("🟢 CONTACT BEGAN: \(event.entityA.name) <-> \(event.entityB.name)")
/// Check if sensor is involved
if event.entityA.name == "SensorA" || event.entityB.name == "SensorA" {
setGlow(true)
}
}
collisionEnded = scene.subscribe(to: CollisionEvents.Ended.self) { [weak self] event in
guard let self else { return }
print("🔴 CONTACT ENDED: \(event.entityA.name) <-> \(event.entityB.name)")
/// Check if sensor is involved
if event.entityA.name == "SensorA" || event.entityB.name == "SensorA" {
setGlow(false)
}
}
}
// MARK: Glow Effect
private func setGlow(_ enabled: Bool) {
if enabled {
entityA.model?.materials = [glowMaterial]
} else {
entityA.model?.materials = []
entityA.model?.materials = [normalMaterial]
}
}
// MARK: Animation
private func setupAnimation() {
/// Left and right motion
updateLoop = scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in
guard let self else { return }
let posA = entityA.position.x
let posB = entityB.position.x
/// Change direction at limit
if posA > oscillationBound || posB < -oscillationBound {
setVelocities(aMovesRight: false)
} else if posA < -oscillationBound || posB > oscillationBound {
setVelocities(aMovesRight: true)
}
}
/// Start moving
setVelocities(aMovesRight: true)
}
private func setVelocities(aMovesRight: Bool) {
let velocityA: SIMD3<Float> = aMovesRight ? [oscillationSpeed, 0, 0] : [-oscillationSpeed, 0, 0]
let velocityB: SIMD3<Float> = aMovesRight ? [-oscillationSpeed, 0, 0] : [oscillationSpeed, 0, 0]
if var motionA = entityA.components[PhysicsMotionComponent.self] {
motionA.linearVelocity = velocityA
entityA.components.set(motionA)
}
if var motionB = entityB.components[PhysicsMotionComponent.self] {
motionB.linearVelocity = velocityB
entityB.components.set(motionB)
}
movingRight = aMovesRight
}
}
Contact Events Order
In the context of game development and interactive apps that are built around the update loop, it's crucial to understand the order of events. For example, some code must run strictly before or after the physics engine simulate one step.
In which order are contact events delivered? We can find out empirically by subscribing to both contact and physics simulation events and logging them:
/// Event emitted when two bodies begin colliding
contactBegan = scene.subscribe(to: CollisionEvents.Began.self) { event in
print("🔴 CollisionEvents.Began")
}
/// Event emitted before the physics engine computes a new step
willSimulate = scene.subscribe(to: PhysicsSimulationEvents.WillSimulate.self) { event in
print("🟢 PhysicsSimulationEvents.WillSimulate")
}
/// Event emitted after the physics engine has completed one step
didSimulate = scene.subscribe(to: PhysicsSimulationEvents.DidSimulate.self) { event in
print("🟡 PhysicsSimulationEvents.DidSimulate")
}
The console prints this:
🟢 PhysicsSimulationEvents.WillSimulate
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🟡 PhysicsSimulationEvents.DidSimulate
🟢 PhysicsSimulationEvents.WillSimulate
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🟡 PhysicsSimulationEvents.DidSimulate
🟢 PhysicsSimulationEvents.WillSimulate
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🟡 PhysicsSimulationEvents.DidSimulate
🟢 PhysicsSimulationEvents.WillSimulate
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🔴 CollisionEvents.Began
🟡 PhysicsSimulationEvents.DidSimulate
In this test, CollisionEvents.Began callbacks were delivered after WillSimulate and before DidSimulate. They occurred within the simulation interval.
To process all contacts delivered during one step, the callbacks can accumulate the events in a collection. That collection can then be processed in DidSimulate, after the current step, or in the following WillSimulate, immediately before the next step.
Update Order
When logic must run at a specific moment relative to the physics simulation, use PhysicsSimulationEvents rather than a rendering update callback such as System.update(context:).
RealityKit has a SystemUpdateCondition to use with system update like this:
import RealityKit
class UpdateSystem: System {
private static let query = EntityQuery(where: .has(MyComponent.self))
required init(scene: RealityKit.Scene) {
}
func update(context: SceneUpdateContext) {
// Note the `updatingSystemWhen`
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
// Update entity before rendering
}
}
}
The condition specifies what causes the system to update. But as of August 2026, the only available condition is .rendering. There is no other condition such as .willSimulate. Therefore the default system updates are rendering based.
In my physics-based projects, I create static fixed update functions, which are called by a PhysicsSimulationEvents:
import RealityKit
import Combine
class StartupSystem: System {
/// Store the subscription
private let willSimulate: Cancellable
required init(scene: Scene) {
// Subscribe to physics events
willSimulate = scene.subscribe(to: PhysicsSimulationEvents.WillSimulate.self){ event in
Self.fixedUpdate(deltaTime: event.deltaTime, in: scene)
}
}
static func fixedUpdate(deltaTime: TimeInterval, in scene: Scene) {
// Logic before each physics tick
}
}