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.

  1. The user caught the a capsule
  2. 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

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.

Questions or feedback?