Synchronizing group gameplay with TabletopKit
Maintain game state across multiple players in a race to capture all the coins.
Overview
This sample code project demonstrates how to overcome the difficult task of maintaining a multiplayer game’s state in real time. TabletopKit supports automatic game-state synchronization by keeping gameplay items, such as player tokens, dice, coins, and scenery updates — all with positions and actions — in sync during a multiplayer game. TabletopKit also supports many more gameplay styles than the traditional board game layout.
Set up the app
The TabletopKit app object creates an instance of the Game class, which sets up, observes, and renders the TabletopGame state. Game equipment, objects that implement the Equipment protocol, represents all interactive portions of a game. The Game class also handles game start and reset, initiating programmatic interactions, and adding equipment reset actions. The GameSetup class initializes and positions all game equipment.
The GameRenderer class implements TabletopGame.RenderDelegate, which loads assets and communicates when the game needs visual updates. The GameObserver class implements TabletopGame.Observer and indicates confirmed gameplay actions. You can define custom actions to modify the game state to your needs. The CustomAction.swift file in this sample contains custom actions to reset the players, decrement player health, collect coins, reset coins, sink lily pads, and reset lily pads.
class Game {
let tabletopGame: TabletopGame
let renderer: GameRenderer
let observer: GameObserver
let setup: GameSetup
//...
}The GameView structure contains the RealityView, which hosts game content and the toolbar for player interaction, and stores the Game object as an environment property. The tabletopGame(_:parent:automaticUpdate:) modifier connects the TabletopGame object to the RealityView. The modifier returns the appropriate delegate for the game equipment ID that passes into the update closure. A Task creates GroupActivityManager, which connects multiple players and provides the TabletopGame object.
var body: some View {
GeometryReader3D { proxy3D in
RealityView { (content: inout RealityViewContent) in
content.entities.append(volumetricRoot)
// Set the root at the base of the volume.
let frame = content.convert(proxy3D.frame(in: .local), from: .local, to: volumetricRoot)
volumetricRoot.transform.translation.y = frame.min.y
volumetricRoot.addChild(game.renderer.root)
}
}.toolbar() {
GameToolbar(game: game)
}.tabletopGame(game.tabletopGame, parent: game.renderer.root) { value in
var delegate: GameInteraction?
if let _ = game.tabletopGame.equipment(of: Log.self, matching: value.startingEquipmentID) {
delegate = LogInteraction(game: game)
} else if let _ = game.tabletopGame.equipment(of: LilyPad.self, matching: value.startingEquipmentID) {
delegate = LilyPadInteraction(game: game)
} else if let _ = game.tabletopGame.equipment(of: Player.self, matching: value.startingEquipmentID) {
delegate = PlayerInteraction(game: game)
} else {
delegate = GameInteraction(game: game)
}
return delegate!
}.task {
activityManager = .init(tabletopGame: game.tabletopGame)
}
}The player joins other players by tapping the SharePlay button in the volume’s toolbar. This instantiates an Activity object that implements the GroupActivity protocol. Activity initializes an observable GroupActivityManager awaiting Activity sessions that coordinate with the provided TabletopGame. TabletopKit handles all player connections and synchronization. The app only needs to register its equipment and actions, and then handle their updates.
struct Activity: GroupActivity {
var metadata: GroupActivityMetadata {
var metadata = GroupActivityMetadata()
metadata.type = .generic
metadata.title = "TabletopKitSample"
return metadata
}
}
class GroupActivityManager: Observable {
var tabletopGame: TabletopGame
var sessionTask = Task<Void, Never> {}
init(tabletopGame: TabletopGame) {
self.tabletopGame = tabletopGame
sessionTask = Task { @MainActor in
for await session in Activity.sessions() {
tabletopGame.coordinateWithSession(session)
}
}
}
deinit {
tabletopGame.detachNetworkCoordinator()
}
}Define gameplay
The game begins when the player taps the Start Game button in the toolbar. This sets the Game.gameStarted state to true and initiates Log entity interactions. Objects that implement the TabletopInteraction.Delegate protocol handle all gameplay event updates.
class GameInteraction: TabletopInteraction.Delegate {
let game: Game
init(game: Game) {
self.game = game
}
func update(interaction: TabletopKit.TabletopInteraction) {
}
}PlayerInteraction handles player input by implementing TabletopInteraction.Delegate. When the player gazes at a gameplay object and interacts using direct or indirect gestures, the PlayerInteraction receives a TabletopInteraction object in its update(interaction:) method. TabletopKit supplies an active gesture for the interaction. The sample splits this functionality into two parts — updating based on user gestures and updating due to automatic programmatic interactions.
class PlayerInteraction: GameInteraction {
override func update(interaction: TabletopInteraction) {
// A gesture interaction to aim the jump.
if interaction.value.gesture != nil || interaction.value.startingEquipmentID != interaction.value.controlledEquipmentID {
updateGestureInteraction(interaction: interaction)
return
}
// A programmatic interaction for the jump after the player releases the aim.
updateProgrammaticInteraction(interaction: interaction)
}
}PlayerInteraction calls into updateGestureInteraction to handle gesture events. On gesture start, updateGestureInteraction sets the .aimingSightID for the controlled equipment on the interaction. This allows TabletopKit to manipulate the appropriate equipment entity onscreen.
func updateGestureInteraction(interaction: TabletopInteraction) {
guard let gesture = interaction.value.gesture else { return }
if gesture.phase == .started {
guard let player = game.tabletopGame.equipment(matching: interaction.value.startingEquipmentID) as? Player else { return }
interaction.setControlledEquipment(matching: .aimingSightID(for: player.seat))
return
}
//...On gesture update, updateGestureInteraction modifies the local slingshot visuals, matching user input.
//...
if gesture.phase == .update {
// Update the slingshot visuals while the player is still dragging.
game.tabletopGame.withCurrentSnapshot { snapshot in
guard let (playerEquip, _) = snapshot.equipment(of: Player.self, matching: interaction.value.startingEquipmentID) else { return }
let aimX = interaction.value.pose.position.x
let aimZ = interaction.value.pose.position.z
let root = game.renderer.root
Task { @MainActor in
playerEquip.updateAimingVisuals(dragPosition: .init(x: aimX, z: aimZ), root: root)
}
}
return
}
//...On gesture end, updateGestureInteraction calculates the change in gesture position, calls startInteraction(onEquipmentID:) for the interaction’s equipment, and adds it to the programmatic player interaction dictionary of Game. When gesture handling is complete, TabletopKit moves the player’s piece.
//...
if gesture.phase == .ended {
// When the player releases the aim, hide the aiming visuals and start a programmatic interaction for the jump.
game.tabletopGame.withCurrentSnapshot { snapshot in
if let (playerEquip, _) = snapshot.equipment(of: Player.self, matching: interaction.value.startingEquipmentID) {
Task { @MainActor in
playerEquip.hideAimingVisuals()
}
}
guard let interactionIdentifier = game.tabletopGame.startInteraction(onEquipmentID: interaction.value.startingEquipmentID) else {
return
}
guard let (playerEquip, _) = snapshot.equipment(of: Player.self, matching: interaction.value.startingEquipmentID) else { return }
let targetX = interaction.value.pose.position.x
let targetZ = interaction.value.pose.position.z
let root = game.renderer.root
Task { @MainActor in
game.programmaticPlayerInteractions[interactionIdentifier] = playerEquip.calcTargetPose(
dragPosition: .init(x: targetX, z: targetZ),
root: root
)
playerEquip.playJumpAudio()
}
}
return
}
}Next, PlayerInteraction.updateProgrammaticInteraction handles the programmatic interactions of automated equipment. At the beginning of the interaction, it provides the set of available interaction destinations — the stones, lily pads, and logs.
func updateProgrammaticInteraction(interaction: TabletopInteraction) {
if interaction.value.phase == .started {
interaction.setConfiguration(.init(allowedDestinations: .restricted(.allStones + .allLilyPads + .allLogs)))
return
}
//...During the interaction, the app finds a target programmatic player interaction for the provided interaction ID and sets the target’s position.
//...
if interaction.value.phase == .update {
guard let targetPose = game.programmaticPlayerInteractions[interaction.value.id] else { return }
let oldPose = interaction.value.pose
if abs(oldPose.position.x - targetPose.position.x) < 1e-3 && abs(oldPose.position.z - targetPose.position.z) < 1e-3 {
if interaction.value.proposedDestination == nil {
sinkPlayer(interaction: interaction, targetPose: targetPose)
return
}
interaction.setPose(targetPose)
interaction.end()
return
}
movePlayer(interaction: interaction, targetPose: targetPose)
return
}
//...At the end of the interaction, updateProgrammaticInteraction calls into endJump. Adding a MoveEquipmentAction object to the active TabletopInteraction moves the player. If the target lands on a lily pad, the sample initiates the sinking animation by calling startInteraction(onEquipmentID:) with the lily pad’s equipment ID. If the target lands on an allowed destination with a coin, endJump adds a CollectCoin action to the active TabletopInteraction. The app then removes the interaction from the programmaticPlayerInteractions dictionary.
//...
if interaction.value.phase == .ended {
endJump(interaction: interaction)
}
}
func endJump(interaction: TabletopInteraction) {
if let proposedDestination = interaction.value.proposedDestination {
// Move the player to the proposed destination.
interaction.addAction(.moveEquipment(matching: interaction.value.controlledEquipmentID,
childOf: proposedDestination.equipmentID, pose: proposedDestination.pose))
// If the destination is a lily pad, sink it.
if game.tabletopGame.equipment(of: LilyPad.self, matching: proposedDestination.equipmentID) != nil {
_ = game.tabletopGame.startInteraction(onEquipmentID: proposedDestination.equipmentID)
}
// If the destination contains an uncollected coin, collect it.
game.tabletopGame.withCurrentSnapshot { snapshot in
if let childId = snapshot.equipmentIDs(childrenOf: proposedDestination.equipmentID).first,
let coinState = snapshot.state(matching: childId) as? CoinState {
if !coinState.collected {
interaction.addAction(CollectCoin(playerId: interaction.value.controlledEquipmentID, coinId: childId))
}
}
}
game.programmaticPlayerInteractions.removeValue(forKey: interaction.value.id)
return
}
//...Finally, if the landing site isn’t a valid location, endJump returns the player to their starting position and decrements their health.
// If the player doesn't land on a valid destination, return them to their starting position.
let player = game.tabletopGame.equipment(of: Player.self, matching: interaction.value.controlledEquipmentID)!
interaction.addAction(.moveEquipment(matching: player.id, childOf: .bankID(for: player.seat), pose: .identity))
interaction.addAction(DecrementHealth(playerId: interaction.value.controlledEquipmentID))
game.programmaticPlayerInteractions.removeValue(forKey: interaction.value.id)
}The GameObserver class is responsible for reacting to confirmed game actions. The game decrements a player’s health when the player lands in the water, or if they fail to jump again quickly after landing on a lily pad before it sinks. Within actionWasConfirmed(), the game resets the player’s state if the newly provided TableSnapshot matches a ResetPlayer action. The actions can have other possible matches, like DecrementHealth where the game decrements the player’s health, or CollectCoin where the player collects a coin at the player’s new location. The game ends when the players collect all of the coins, or when all of the players run out of lives.
class GameObserver: TabletopGame.Observer {
func actionWasConfirmed(_ action: some TabletopAction, oldSnapshot: TableSnapshot, newSnapshot: TableSnapshot) {
guard let game else {
return
}
if let resetPlayerAction = ResetPlayer(from: action) {
let (equip, state) = newSnapshot.equipment(of: Player.self, matching: [resetPlayerAction.playerId]).first!
game.playerStats[equip.seat].health = state.health
game.playerStats[equip.seat].coinsCount = state.coinsCount
return
}
if let decrementHealthAction = DecrementHealth(from: action) {
let (equip, state) = newSnapshot.equipment(of: Player.self, matching: [decrementHealthAction.playerId]).first!
game.playerStats[equip.seat].health = state.health
// Freeze the player when their health equals `0`.
if action.playerID == game.tabletopGame.localPlayer.id && state.health == 0 {
game.tabletopGame.addAction(FreezePlayer(playerId: equip.id))
}
return
}
if let collectCoinAction = CollectCoin(from: action) {
let (equip, playerState) = newSnapshot.equipment(of: Player.self, matching: [collectCoinAction.playerId]).first!
game.playerStats[equip.seat].coinsCount = playerState.coinsCount
return
}
}
}