Contents

Building a guessing game for visionOS

Create a team-based guessing game for visionOS using Group Activities.

Overview

This sample shows how to build a guessing game for two competing teams. Participants, using an Apple Vision Pro during a FaceTime call, can play as members of the red or blue team or watch from the audience. At each stage of the game, the sample positions participants according to their role as a team member or spectator.

After the initial welcome screen for the game, the participants select categories and divide into two teams. During the team-selection process, all participants start in the audience section. If a participant chooses to join a team, they move to the appropriate team area. During the game itself, the teams take turns guessing a word or phrase in a window that only one team member can see. The team member that sees the word or phrase can use any gestures or phrases to elicit a correct guess, but can’t say the word or phrase itself. If the team correctly guesses the word or phrase, the team scores a point. The team with the most points at the end of the game wins.

Define the group activity

The GroupActivity protocol provides the system with the context and metadata to start an activity-related session. The sample defines a single GroupActivity to represent the game:

struct GuessTogetherActivity: GroupActivity, Transferable, Sendable {
    var metadata: GroupActivityMetadata = {
        var metadata = GroupActivityMetadata()
        metadata.title = "Guess Together"
        return metadata
    }()
}

For more information, see Defining your app’s SharePlay activities.

Encourage people to start a game

The welcome screen displays a custom SharePlayButton; tapping it starts the game. If the player is already in a FaceTime call, the GuessTogetherActivity activates. Otherwise, a GroupActivitySharingController displays a sheet inviting the player to start the FaceTime call. When a recipient accepts the FaceTime call, the system prompts them to join the GuessTogetherActivity:

struct SharePlayButton<ActivityType: GroupActivity & Transferable & Sendable>: View {
    @ObservedObject
    private var groupStateObserver = GroupStateObserver()
    
    @State
    private var isActivitySharingViewPresented = false
    
    @State
    private var isActivationErrorViewPresented = false
    
    private let activitySharingView: ActivitySharingView<ActivityType>
    
    let text: any StringProtocol
    let activity: ActivityType
    
    init(_ text: any StringProtocol, activity: ActivityType) {
        self.text = text
        self.activity = activity
        self.activitySharingView = ActivitySharingView {
            activity
        }
    }
    
    var body: some View {
        ZStack {
            ShareLink(item: activity, preview: SharePreview(text)).hidden()
            
            Button(text, systemImage: "shareplay") {
                if groupStateObserver.isEligibleForGroupSession {
                    Task.detached {
                        do {
                            _ = try await activity.activate()
                        } catch {
                            print("Error activating activity: \(error)")
                            
                            Task { @MainActor in
                                isActivationErrorViewPresented = true
                            }
                        }
                    }
                } else {
                    isActivitySharingViewPresented = true
                }
            }
            .tint(.green)
            .sheet(isPresented: $isActivitySharingViewPresented) {
                activitySharingView
            }
            .alert("Unable to start game", isPresented: $isActivationErrorViewPresented) {
                Button("Ok", role: .cancel) { }
            } message: {
                Text("Please try again later.")
            }
        }
    }
}

struct ActivitySharingView<ActivityType: GroupActivity & Sendable>: UIViewControllerRepresentable {
    let preparationHandler: () async throws -> ActivityType

    func makeUIViewController(context: Context) -> GroupActivitySharingController {
        GroupActivitySharingController(preparationHandler: preparationHandler)
    }

    func updateUIViewController(_: GroupActivitySharingController, context: Context) {}
}

For more information, see Presenting SharePlay activities from your app’s UI.

Join and manage the activity

The system creates a GroupSession when a player activates an activity. Players can activate an activity in several ways, at any time. This sample uses an asynchronous task in MainView to monitor the creation of new GroupSession instances for GuessTogetherActivity. This task calls the observeGroupSessions method, which receives new sessions and creates a SessionController to manage gameplay. A separate task detects when the session ends and cleans up the SessionController.

// MainView

struct MainView: View {
    ...

    var body: some View {
        Group {
            ...
        }
        .task(observeGroupSessions)
    }
    
    /// Monitor for new Guess Together group activity sessions.
    @Sendable
    func observeGroupSessions() async {
        for await session in GuessTogetherActivity.sessions() {
            let sessionController = await SessionController(session, appModel: appModel)
            guard let sessionController else {
                continue
            }
            appModel.sessionController = sessionController

            // Create a task to observe the group session state and clear the
            // session controller when the group session invalidates.
            Task {
                for await state in session.$state.values {
                    guard appModel.sessionController?.session.id == session.id else {
                        return
                    }

                    if case .invalidated = state {
                        appModel.sessionController = nil
                        return
                    }
                }
            }
        }
    }
}

For more information, see Joining and managing a shared activity.

Synchronize game state by sending and receiving messages

When a player’s action changes the game state, SessionController uses GroupSessionMessenger to send an update to the other players. For example, when player X joins the red team, TeamSelectionView calls SessionController.joinTeam. This sets SessionController.localPlayer.team, sending a call to SessionController.shareLocalPlayerState.

// SessionController

func joinTeam(_ team: PlayerModel.Team?) {
    localPlayer.team = team
}

...

var localPlayer: PlayerModel {
    get {
        players[session.localParticipant]!
    }
    set {
        if newValue != players[session.localParticipant] {
            players[session.localParticipant] = newValue
            shareLocalPlayerState(newValue)
        }
    }
}

SessionController.shareLocalPlayerState uses GroupSessionMessenger to send player X’s new state to other players.

// SessionController+RemoteParticipantSynchronization

func shareLocalPlayerState(_ newValue: PlayerModel) {
    Task {
        do {
            try await messenger.send(newValue)
        } catch {
            // Failed to send the message.
        }
    }
}

At the same time, SessionController.observeRemotePlayerModelUpdates receives and processes player state updates. Each update modifies SessionController.players to reflect the synchronized player state. For example, when player X joins the red team, other players are notified and update their local representation of player X’s state accordingly.

// SessionController

...

private func observeRemotePlayerModelUpdates() {
    Task {
        for await (player, context) in messenger.messages(of: PlayerModel.self) {
            players[context.source] = player
        }
    }
}

For more information, see Synchronizing data during a SharePlay activity.

Enable spatial personas in an immersive space

To display spatial Personas when an immersive space is open, set the supportsGroupImmersiveSpace property on SystemCoordinator.Configuration to true:

// SessionController

func configureSystemCoordinator() {
    systemCoordinator.configuration.supportsGroupImmersiveSpace = true
    
    ...
}

In an immersive space, the system provides a shared coordinate system for participants and content. This allows you to position entities consistently across all participants’ perspectives. For example, an entity positioned 0.5 meters in front of player X appears 0.5 meters in front of player X from player Y’s perspective.

For more information, see Adding spatial Persona support to an activity.

Specify custom positions for participants

The game has three distinct stages:

  • Category-selection stage, where players choose the words and phrases they want to try to elicit from their teammates.

  • Team-selection stage, where players join one of the teams.

  • Game stage, where the teams take turns playing the game.

Each time the current stage changes, the SessionController object updates the position of the participants in the space. When selecting a category, the participants appear side by side in front of the game window. During team selection and gameplay, the game arranges players using custom spatial templates. The game specifies each arrangement of participants by changing the configuration of the SystemCoordinator object.

// SessionController

func updateSpatialTemplatePreference() {
    switch game.stage {
    case .categorySelection:
        systemCoordinator.configuration.spatialTemplatePreference = .sideBySide
    case .teamSelection:
        systemCoordinator.configuration.spatialTemplatePreference = .custom(TeamSelectionTemplate())
    case .inGame:
        systemCoordinator.configuration.spatialTemplatePreference = .custom(GameTemplate())
    }
}

When the game moves to the team-selection stage, the session rearranges the participants according to which team they choose. All participants start in the audience initially facing the app window, which displays buttons to join either the red team or blue team.

[Image]

The TeamSelectionTemplate specifies the positions of seats during the team-selection process. Participants don’t have an assigned role initially, so the system places them in the audience seats. As participants join a team, the system moves them to the assigned seating area for their chosen team.

The spatial template specifies all seat positions up front, including seats for the blue and red teams, and the audience. Seat positions reflect the distance in meters from the app’s main window along the x and z axes. Seat roles reflect the role that a participant must have to occupy that seat. When a participant asks to join a team, the sample app assigns them the corresponding role. If a seat with that role is available, the participant receives the role and their spatial Persona moves to the next available seat.

struct TeamSelectionTemplate: SpatialTemplate {
    enum Role: String, SpatialTemplateRole {
        case blueTeam
        case redTeam
    }
    
    /// An array of seating positions the game uses to position spatial Personas during the team-selection stage.
    ///
    /// The game fills the seats with participants based on the order of the array's elements.
    let elements: [any SpatialTemplateElement] = [
        // Blue team:
        .seat(position: .app.offsetBy(x: -2.5, z: 3.5), role: Role.blueTeam),
        .seat(position: .app.offsetBy(x: -3.0, z: 3.0), role: Role.blueTeam),
        .seat(position: .app.offsetBy(x: -3.5, z: 2.5), role: Role.blueTeam),
        
        // Starting positions:
        .seat(position: .app.offsetBy(x: 0, z: 4)),
        .seat(position: .app.offsetBy(x: 1, z: 4)),
        .seat(position: .app.offsetBy(x: -1, z: 4)),
        .seat(position: .app.offsetBy(x: 2, z: 4)),
        .seat(position: .app.offsetBy(x: -2, z: 4)),
        
        // Red team:
        .seat(position: .app.offsetBy(x: 2.5, z: 3.5), role: Role.redTeam),
        .seat(position: .app.offsetBy(x: 3.0, z: 3.0), role: Role.redTeam),
        .seat(position: .app.offsetBy(x: 3.5, z: 2.5), role: Role.redTeam)
    ]
}

During gameplay, teams take turns playing the game while the audience watches. Audience members face the main window while the active team sits on either side of that window. The team member giving clues sits on one side of the window, while their teammates sit opposite. The template orients the active team members so that they face each other at the start of the game, with audience members facing the main window.

[Image]

The GameTemplate structure defines separate roles for the current player and the active team members. Members of the opposing team don’t receive a role until it’s their turn to play, so they initially sit in the audience positions. Because the active team members face each other, and not the app window, their seat positions include a direction parameter to specify where they look initially.

struct GameTemplate: SpatialTemplate {
    enum Role: String, SpatialTemplateRole {
        case player
        case activeTeam
    }
    
    static let playerPosition = Point3D(x: -2, z: 3)
    
    /// An array that represents the order in which the game adds participants to spatial template positions.
    var elements: [any SpatialTemplateElement] {
        let activeTeamCenterPosition = SpatialTemplateElementPosition.app.offsetBy(x: 2, z: 3)

        let playerSeat = SpatialTemplateSeatElement(
            position: .app.offsetBy(x: Self.playerPosition.x, z: Self.playerPosition.z),
            direction: .lookingAt(activeTeamCenterPosition),
            role: Role.player
        )
        
        let activeTeamSeats: [any SpatialTemplateElement] = [
            .seat(
                position: activeTeamCenterPosition.offsetBy(x: 0, z: -0.5),
                direction: .lookingAt(playerSeat),
                role: Role.activeTeam
            ),
            .seat(
                position: activeTeamCenterPosition.offsetBy(x: 0, z: 0.5),
                direction: .lookingAt(playerSeat),
                role: Role.activeTeam
            )
        ]
        
        let audienceSeats: [any SpatialTemplateElement] = [
            .seat(position: .app.offsetBy(x: 0, z: 5)),
            .seat(position: .app.offsetBy(x: 1, z: 5)),
            .seat(position: .app.offsetBy(x: -1, z: 5)),
            .seat(position: .app.offsetBy(x: 2, z: 5)),
            .seat(position: .app.offsetBy(x: -2, z: 5))
        ]
        
        return audienceSeats + [playerSeat] + activeTeamSeats
    }
}

For more information about building custom spatial templates, see SpatialTemplate.

See Also

Custom spatial templates