How to Sync RealityKit ECS Events with SwiftUI State Updates
In this tutorial, we’ll explore the core system behind the CatchMe game. The concept is simple: you catch falling bricks, and every successful catch updates the SwiftUI score view in real time.
You can find the complete project source code on GitHub.
To achieve this result, we’ll discover:
- How RealityKit’s Entity-Component-System (ECS) architecture helps isolate collision logic from RealityView.
- How to react to collision events using systems.
- A clean communication flow between ECS and SwiftUI without unnecessary boilerplate.
Entity component system (Collision)
In this section, we’ll dive deeper into how to use a System to isolate and detect collision events.
If you find this part challenging or want a quick refresher on the ECS concept, I recommend reading this tutorial: Understanding RealityKit ECS with a Floating Brick
// 1
final class CaughtTriggerSystem: System {
// 2
private let entityQuery = EntityQuery(where: .has(ScoreComponent.self))
// 3
private var subscription: [AnyCancellable] = []
// 4
init(scene: Scene) {
// 5
scene.subscribe(to: CollisionEvents.Began.self) {
// 6
self.handleBrickCaught(event: $0)
}.store(in: &subscription)
}
}
Let’s break it down step by step:
- System definition: Define a custom CaughtTriggerSystem responsible for detecting when a brick is caught.
- Entity query: Use entityQuery to find entities that have ScoreComponent so the score can be updated on collisions.
- Subscription management: Store subscriptions in an array so they can be canceled when no longer needed.
- Scene subscription: Conforming to System gives access to the scene, where you can subscribe to scene events.
- Collision events: Subscribe to CollisionEvents.Began to react to interactions in real time. There are other event types as well; we’ll cover those later.
- Next step: We’ll look at how to handle the logic when a collision is detected.
Learn more about how collisions work in RealityKit with these excellent resources from Step Into Vision:
• Collision Events
• Collision Use Cases
• Generating Collision Shapes
These articles offer deeper insight into RealityKit’s physics and collision system if you’d like to expand on what we’ve covered here.
Reacting to Collision Events Using Systems
To achieve our goal, we’ll use three different components:
- ScoreComponent: Stores the current score and updates whenever a brick is successfully caught inside the bucket.
- BrickComponent: Identifies brick entities that can trigger a collision event.
- CatchComponent: Marks a brick as already caught, preventing duplicate collision events or repeated score updates
Let’s define our components first:
struct ScoreComponent: Component, Sendable {
var score: Int = 0
}
struct BrickComponent: Component {}
struct CatchComponent: Component{}
Each component plays a specific role, allowing the system to detect collisions and update the score cleanly without mixing logic inside the view or entity setup.
When a collision is detected, this method handles the event step by step:
func handleBrickCaught(event: CollisionEvents.Began) {
// 1
guard isCatchableBrick(event.entityA) else { return }
// 2
if isEntity(event.entityA, inside: event.entityB) {
// 3
applyCatchPhysics(to: event.entityA) // Fix collision issue: prevent the object from falling after being caught
// 4
markAsCaught(event.entityA)
// 5
incrementScore(event.entityA)
}
}
- Check if the entity is catchable: Verify that the entity has a
BrickComponentand does not already have aCatchComponent. This ensures we only process bricks that haven’t been caught yet. - Detect if the brick is inside the catcher: Confirm that the brick has entered the catcher’s bounding area.
- Apply catch physics: Disable certain physics effects to prevent the brick from falling once it’s caught.
- Mark as caught: Add the CatchComponent to mark the brick as captured
- Increment the score: Update the score by modifying the ScoreComponent.
Here’s the full implementation of the helper methods used in our collision system:
func isCatchableBrick(_ entity: Entity) -> Bool {
return entity.components.has(BrickComponent.self) &&
entity.components.has(PhysicsBodyComponent.self) &&
!entity.components.has(CatchComponent.self)
}
func isEntity(_ a: Entity, inside other: Entity) -> Bool {
let aBounds = a.visualBounds(relativeTo: nil)
let bBounds = other.visualBounds(relativeTo: nil)
let aMin = aBounds.min
let aMax = aBounds.max
let bMin = bBounds.min
let bMax = bBounds.max
// Check if all corners of A are inside B
return (aMin.x >= bMin.x && aMax.x <= bMax.x) &&
(aMin.y >= bMin.y && aMax.y <= bMax.y) &&
(aMin.z >= bMin.z && aMax.z <= bMax.z)
}
func applyCatchPhysics(to entity: Entity) {
guard var body = entity.components[PhysicsBodyComponent.self] else { return }
body.linearDamping = 0
body.angularDamping = 0
entity.components[PhysicsBodyComponent.self] = body
}
func markAsCaught(_ entity: Entity) {
entity.components.set(CatchComponent())
}
func incrementScore(_ entity: Entity) {
if let rootEntity = entity.scene?.performQuery(entityQuery) {
for entity in rootEntity {
var component = entity.components[ScoreComponent.self]
component?.score += 1
entity.components[ScoreComponent.self] = component
}
}
}
Real-time collision and score update in a visionOS scene using Realitykit ECS and SwiftUI
How SwiftUI Reacts to ECS Score Updates
Apple provides an observable view of an entity that bridges RealityKit changes to SwiftUI via the Observation framework. Reading from entity.observable inside a SwiftUI view makes the view re-render whenever the referenced component changes.
// ScoreView.swift
import SwiftUI
import RealityKit
struct ScoreView: View {
@Environment(AppModel.self) private var appModel
var body: some View {
// Reading through `.observable` establishes a dependency.
let score = appModel.rootEntity
.observable
.components[ScoreComponent.self]?.value ?? 0
VStack(spacing: 4) {
Text("\(score)")
.font(.system(size: 42, weight: .bold, design: .rounded))
Text("Score")
.font(.caption)
.opacity(0.7)
.textCase(.uppercase)
}
.padding(.vertical, 8)
}
}
When your system updates ScoreComponent.value, SwiftUI re-render body, so the score label refreshes automatically.
Next Step
Now that you’ve built a reactive scoring system, try extending the logic to handle a game over condition.
You can create a new system that detects when bricks hit the ground and keeps track of how many have fallen. Once, for example, 10 bricks hit the ground, trigger a game-over event and update the SwiftUI view accordingly.
To implement it:
- Add a ground entity with a collision component.
- Create a GameOverSystem that listens for collisions between bricks and the ground.
- Reuse the same ECS and SwiftUI communication patterns you learned here.
This small challenge will help reinforce everything covered in this tutorial and prepare you for more advanced gameplay logic.
Summary
In this tutorial, we explored how to connect RealityKit’s Entity-Component-System (ECS) with SwiftUI to create a responsive and maintainable game flow.
We learned how to detect collisions using systems, define reusable components to manage entities, and update SwiftUI automatically when the score changes.
By combining RealityKit ECS and SwiftUI’s observation system, we achieved a seamless bridge between the simulation layer and the user interface with minimal boilerplate and clear separation of responsibilities.