Lab 086 – Capsule Catch
A mini-game drop and catch game.
Just a bit of fun today. I decided to create a simple game where capsules are dropped on a random timer. The player has to quickly look and punch to prevent the capsules from reaching floor. We use manipulation for this and listen for the WillBegin event. Once they have intercepted a capsule once, it will count as a point and they are free to drop it. Any missed capsules are faded out. Check out the video demo, then we’ll dive in to the code.
I started with a scene in Reality Composer Pro. I used a simple shape for the ground–we need something to collide with. For the game objects, I used capsules. Each one has Input Target, Collision, and Physics components. Gravity has been set to false–this will be toggled on when the item is dropped.

We run this game as an immersive space with the .coexist behavior.
We load this scene in Lab 086 and save a reference to the capsules. Notice that we used a PhysicsSimulationComponent to reduce the force of gravity just a bit. Full gravity was just a little too fast for me. This would make a fun value adjust as a level of difficulty.
struct Lab086: View {
@State private var gameModel = GameModel()
@State private var menu = Entity()
@State private var willBegin: EventSubscription?
@State private var collisionBegan: EventSubscription?
var body: some View {
RealityView { content in
// Import the scene from RCP and capture the capsules
guard let scene = try? await Entity(named: "CatchingGame", in: realityKitContentBundle) else { return }
// Reducing gravity just a bit
var ps = PhysicsSimulationComponent()
ps.gravity = [0, -9.81 * 0.8, 0]
scene.components.set(ps)
content.add(scene)
guard let floor = scene.findEntity(named: "FloorBounds") else { return }
guard let capsuleGroup = scene.findEntity(named: "Capsules") else { return }
let mc = ManipulationComponent()
for capsule in capsuleGroup.children {
gameModel.capsules.append(capsule)
capsule.components.set(mc)
}
}
}
}The menu is a simple SwiftUI view shown as a ViewAttachmentComponent.
fileprivate struct GameMenu: View {
@Environment(GameModel.self) var gameModel
var body: some View {
VStack(spacing: 12) {
Text("Capsule Catch")
.font(.largeTitle)
switch gameModel.gameState {
case .setup:
VStack {
Text("Use you fingers to pinch and grab as many capsules as you can!")
.font(.caption)
.multilineTextAlignment(.center)
Button(action: {
gameModel.startGame()
}, label: {
Text("Start Game")
})
}
case .active:
VStack {
Text("Temp Mode Only")
.font(.caption)
.multilineTextAlignment(.center)
Button(action: {
gameModel.scheduleCapsuleActivation()
}, label: {
Text("Drop Test")
})
}
case .over:
VStack {
Text("Game Over!")
.font(.caption)
Text("Score: \(gameModel.score) out of 7")
.font(.caption)
Button(action: {
gameModel.resetGame()
}, label: {
Text("Start Over")
})
}
}
}
.padding()
.frame(width: 400, height: 300)
.glassBackgroundEffect()
.opacity(gameModel.gameState == .active ? 0.0 : 1.0)
}
}Game state and data is stored in GameModel. This was stream-of-thought code so it may be a little redundant and sloppy. We have a way to store the capsules, a game state system, a score, and some helpers to keep track of caught and interacted capsules.
When the game starts, we wait for a short time before dropping the first capsule. When this time runs out, we select a random capsule, enable gravity, and remove it from the activeCapsules.
@MainActor
@Observable
fileprivate class GameModel {
enum GameState {
case setup
case active
case over
}
var capsules: [Entity] = []
var activeCapsules: [Entity] = []
var caughtCapsules: [Entity] = []
var gameState: GameState = .setup
var menu = Entity()
var score = 0
var lastInteraction: String?
private var capsuleTimer: Task<Void, Never>?
func startGame() {
score = 1
gameState = .active
activeCapsules.append(contentsOf: capsules)
scheduleCapsuleActivation()
}
func resetGame() {
gameState = .setup
activeCapsules.removeAll()
// Reset each capsule to their default state
let mc = ManipulationComponent()
for capsule in capsules {
capsule.components.set(mc)
capsule.components[PhysicsBodyComponent.self]?.isAffectedByGravity = false
capsule.components.set(PhysicsMotionComponent())
capsule.transform.rotation = .init()
capsule.position.y = 2
capsule.components.remove(OpacityComponent.self)
}
}
func addScore() {
score += 1
}
func activateCapsule() {
// Pick a random element from the capsules array and print the name
guard !activeCapsules.isEmpty else { gameState = .over; return }
let capsule = activeCapsules.randomElement()!
// Remove the capsule from the array
if let index = activeCapsules.firstIndex(of: capsule) {
activeCapsules.remove(at: index)
}
Task {
// TODO: consider playing a spatial sound effect to draw attention
let action = EmphasizeAction(motionType: .pulse,
style: .basic,
isAdditive: false)
let animation = try AnimationResource.makeActionAnimation(for: action,
duration: 0.25,
bindTarget: .transform)
capsule.playAnimation(animation)
// todo: wait for 0.25
try? await Task.sleep(nanoseconds: 250_000_000)
capsule.components[PhysicsBodyComponent.self]?.isAffectedByGravity = true
}
if activeCapsules.isEmpty { gameState = .over }
}
func scheduleCapsuleActivation() {
capsuleTimer?.cancel()
guard !activeCapsules.isEmpty else { gameState = .over; return }
capsuleTimer = Task {
let delay = Double.random(in: 3...6)
try? await Task.sleep(for: .seconds(delay))
await MainActor.run {
self.activateCapsule()
}
}
}
func scheduleCapsuleDrop(after seconds: Double, action: @escaping @MainActor () -> Void) {
capsuleTimer?.cancel()
capsuleTimer = Task {
try? await Task.sleep(for: .seconds(seconds))
action()
}
}
}The next capsule drop is called inside the game itself, based on one of two events.
- The user caught the a capsule
- The user failed to catch the capsule and it reaches the floor
We use Manipulation Events for the first one, and Collision Events for the second. These events form a chain of drops until we run out of capsules.
// We'll use this event to determine success
self.willBegin = content.subscribe(to: ManipulationEvents.WillBegin.self) { event in
if(gameModel.lastInteraction == event.entity.name) {
return
}
if(gameModel.caughtCapsules.contains(where: { $0.name == event.entity.name })) {
return
}
gameModel.addScore()
gameModel.caughtCapsules.append(event.entity)
gameModel.scheduleCapsuleActivation()
gameModel.lastInteraction = event.entity.name
print("Success!")
}
// We'll use this event to determine failure. Anything that touches the ground is taken out of play
self.collisionBegan = content.subscribe(to: CollisionEvents.Began.self, on: floor) { collisionEvent in
if(gameModel.lastInteraction == collisionEvent.entityB.name) {
return
}
if(gameModel.caughtCapsules.contains(where: { $0.name == collisionEvent.entityB.name })) {
return
}
collisionEvent.entityB.components.remove(ManipulationComponent.self)
gameModel.scheduleCapsuleActivation()
gameModel.lastInteraction = collisionEvent.entityB.name
collisionEvent.entityB.components.set(OpacityComponent(opacity: 0.1))
}The gameModel.lastInteraction code is there to prevent very fast repeat events. We might be able to merge this into the gameModel.caughtCapsules logic, but I left it as a separate feature for now.
The game is now playable! Download the repo and try it for yourself.
There are a few ways we might improve this.
- Add Spatial Audio with a sound effect that plays when an capsule drops
- Make the number and formation of the capsules dynamic
- Let the user keep the caught capsules by dropping them somewhere out of the field of play
- Increase the gravity
- Add suspenseful background music
See Also
- How to let immersive spaces coexist with system environments
- Lab 045 – Entity Actions
- RealityKit Basics: Using ViewAttachmentComponent
- Collisions & Physics: Collision Events
- Using events with Manipulation Component
Support our work so we can continue to bring you new examples and articles.
Download the Xcode project with this and many more labs from Step Into Vision.

Follow Step Into Vision