red character 'A' without the horizontal bar Achraf Kassioui

RealityKit Contact Events

Blog / Apple Dev,

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:

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:

In the video, we see a blue cube that intersects a hemisphere (the sensor), which emits an event, and triggers a color change on the red cube.
/**
 
 # 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
    }
    
}