TabletopKit
For a while I've been looking for a way to make my BlockStack vision pro game a multiplayer experience. Currently it's just a simple one-player game but it would be more fun if played with others.
To make this happen, Apple has a framework called Shareplay. I've been trying to work with it, but so far haven't been able to work out how to get it to share... or play. As an alternative, I'm looking into a framework specifically designed to wrap Shareplay and enable tabletop
gaming, called TabletopKit.
This is also tricky but having watched a talk from Mikaela Caron on creating a TabletopKit game, I thought I should go back to basics and try to learn this framework.
With that in mind, here's what I've learned so far. I've set up the following code as a Git repo here.
Result #
Blockstack in TabletopKit
In building this demo I have learned that while TabletopKit
has many useful tools, it's not a great fit for a physics-based project such as Blockstack.
The pieces can clip through others. They don't push other pieces over or fall using physics. There may be ways around this but this is as far as I've got so far.
However, the setup below might prove useful when starting new projects.
TabletopKit setup #
A TabletopKit app needs the following in place:
- A volumetric window
- A table
- Seats at the table
- Equipment (the parts of the game people interact with)
- Interaction logic
- Observation logic
- SharePlay
Setting up app with Volumetric window #
The app begins with the App
, which contains a single WindowGroup
set to display out GameView
as a volumetric window:
var body: some Scene {
WindowGroup(id: "game") {
GameView()
.volumeBaseplateVisibility(.hidden)
}
.windowStyle(.volumetric)
.volumeWorldAlignment(.gravityAligned)
.defaultSize(width: 0.7, height: 2, depth: 0.7, in: .meters)
}
This won't work out of the box, as one other setup step is to update Info.plist
and set Preferred Default Scene Session Role
to Volumetric Window Application Session Role
.
Now when the app runs, it'll open a volumetric window as the only window.
GameView #
Our GameView
is where the game is to be displayed. It uses a RealityView
into which the game loads all entities
:
import RealityKit
import RealityKitContent
import SwiftUI
import TabletopKit
@MainActor
struct GameView: View {
@State private var game: Game?
@State private var activityManager: GroupActivityManager?
var body: some View {
ZStack {
if let loadedGame = game {
RealityView { (content: inout RealityViewContent) in
content.entities.append(loadedGame.renderer.root)
}
.tabletopGame(loadedGame.tabletopGame, parent: loadedGame.renderer.root) { _ in
GameInteraction(game: loadedGame)
}
}
}
.task {
self.game = await Game()
self.activityManager = .init(tabletopGame: game!.tabletopGame)
}
}
}
The code above runs a task
that loads the game. It also instantiates the GroupActivityManager
, which handles Shareplay.
Lastly the RealityView
has a tabletopGame
modifier which sets up the game interaction logic.
Game #
The GameView
calls the Game
class. This brings together the setup of the tabletop
and renderer
, adds observers, renderers and sets up the initial seating.
@Observable
class Game {
let tabletopGame: TabletopGame
let renderer: GameRenderer
let observer: GameObserver
let setup: GameSetup
@MainActor
init() async {
renderer = GameRenderer()
setup = GameSetup(root: renderer.root)
tabletopGame = TabletopGame(tableSetup: setup.setup)
observer = GameObserver(tabletop: tabletopGame, renderer: renderer)
tabletopGame.addObserver(observer)
tabletopGame.addRenderDelegate(renderer)
renderer.game = self
tabletopGame.claimAnySeat()
}
deinit {
tabletopGame.removeObserver(observer)
tabletopGame.removeRenderDelegate(renderer)
}
}
GameRenderer #
The game renders through a root entity
. I've adjusted it to rotate the table 45 degrees to better see the result:
@MainActor
class GameRenderer: TabletopGame.RenderDelegate {
let root: Entity
let rootOffset: Vector3D = .init(x: 0, y: GameMetrics.tableThickness-1, z: 0)
weak var game: Game?
@MainActor
init() {
root = Entity()
root.transform.translation = .init(rootOffset)
let rotation = simd_quatf(angle: Float.pi / 4, axis: [0, 1, 0])
root.transform.rotation = rotation
}
}
GameSetup #
The minimum setup requires a table and seating. This is enough to get a tabletop kit app to run. I will also add some testing blocks to illustrate the interactions:
@MainActor
class GameSetup {
let root: Entity
var setup: TableSetup
var seats: [PlayerSeat] = []
init(root: Entity) {
self.root = root
setup = TableSetup(tabletop: Table())
// Adding player seats
for (index, pose) in PlayerSeat.seatPoses.enumerated() {
let seat = PlayerSeat(id: TableSeatIdentifier(index), pose: pose)
seats.append(seat)
setup.add(seat: seat)
}
// Adding some blocks for testing
let blockWidth: Double = 0.04
for index in 0..<30 {
let orientation: Float
if (index / 3) % 2 == 0 {
orientation = 0
} else {
orientation = Float.pi / 2
}
let row = index / 3
let x: Double
let z: Double
if row % 2 == 0 {
x = Double(index % 3) * blockWidth
z = 0
} else {
x = blockWidth
z = (Double(index % 3) * blockWidth) - blockWidth
}
let blockPosition = TableVisualState.Point2D(x: x, z: z)
let block = Block(index: self.idGenerator.newId(), position: blockPosition, orientation: orientation)
setup.add(equipment: block)
}
}
}
GameMetrics #
To make configuration easier, we set up some shared variables:
enum GameMetrics {
static let tableEdge: Float = 1
static let tableThickness: Float = 0.025
static let radius: Float = 0.35
}
Adding game equipment #
With the game view, setup logic and rendering logic, we can add the basic equipment
. In this case it's a simple round table and a seating plan (with 3 seats):
extension EquipmentIdentifier {
static var tableID: Self { .init(0) }
}
struct Table: EntityTabletop {
var shape: TabletopShape
var entity: Entity
var id: EquipmentIdentifier
init() {
let newTableEntity = ModelEntity(
mesh: .generateCylinder(height: GameMetrics.tableThickness, radius: GameMetrics.radius),
materials: [SimpleMaterial(color: .brown, isMetallic: false)]
)
newTableEntity.name = "table"
self.entity = newTableEntity
self.shape = .round(entity: entity)
self.id = .tableID
}
}
struct Block: EntityEquipment {
var id: EquipmentIdentifier
var entity: Entity
var initialState: BaseEquipmentState
@MainActor
init(index: Int = 0, position: TableVisualState.Point2D, orientation: Float = 0) {
id = EquipmentIdentifier(index)
let newEntity = generateBlock()
newEntity.name = "Block-\(index)"
let rotation = Angle2D(radians: orientation)
initialState = State(
parentID: .tableID,
pose: .init(position: position, rotation: rotation),
entity: newEntity
)
entity = newEntity
}
}
struct PlayerSeat: TableSeat {
let id: ID
var initialState: TableSeatState
@MainActor static let seatPoses: [TableVisualState.Pose2D] = [
.init(position: .init(x: 0, z: Double(GameMetrics.tableEdge)), rotation: .degrees(0)),
.init(position: .init(x: -Double(GameMetrics.tableEdge), z: 0), rotation: .degrees(-90)),
.init(position: .init(x: Double(GameMetrics.tableEdge), z: 0), rotation: .degrees(90))
]
init(id: TableSeatIdentifier, pose: TableVisualState.Pose2D) {
self.id = id
let spatialSeatPose: TableVisualState.Pose2D = .init(position: pose.position,
rotation: pose.rotation)
initialState = .init(pose: spatialSeatPose)
}
}
There are different types of equipment. When using entities for custom equipment, EntityEquipment
is ideal. In the example above, I have added a Block
struct so we can add some interactive elements.
TabletopKit
also brings useful tools such as dice.
GameInteraction #
With the equipment in place, we can add some boilerplate for when we want interactions.
struct GameInteraction: TabletopInteraction.Delegate {
let game: Game
mutating func update(interaction: TabletopKit.TabletopInteraction) {
let equipment = interaction.value.controlledEquipmentID
guard let destination = interaction.value.proposedDestination else {
return
}
if interaction.value.phase == .ended {
interaction.addAction(.moveEquipment(matching: equipment, childOf: destination.equipmentID, pose: destination.pose))
}
}
}
This makes use of an update
method that passes an interaction.value.phase
that can be .started
or .ended
along with an interaction.value.gesture?.phase
for more control. In this code we move the equipment to where it is left.
Restrictions on where equipment can be moved can be configured.
GameObserver #
The interactions set up actions
, as above in interaction.addAction
. These can be observed in the GameObserver
class for other actions such as updating game state.
class GameObserver: TabletopGame.Observer {
let tabletop: TabletopGame
let renderer: GameRenderer
init(tabletop: TabletopGame, renderer: GameRenderer) {
self.tabletop = tabletop
self.renderer = renderer
}
func actionIsPending(_ action: some TabletopAction, oldSnapshot: TableSnapshot, newSnapshot: TableSnapshot) {
if let action = action as? MoveEquipmentAction {
print("actionIsPending: \(action)")
}
}
func actionWasConfirmed(_ action: some TabletopAction, oldSnapshot: TableSnapshot, newSnapshot: TableSnapshot) {
print("actionWasConfirmed: \(action)")
}
func playerChangedSeats(_ player: Player, oldSeat: (any TableSeat)?, newSeat: (any TableSeat)?, snapshot: TableSnapshot) {
if player.id == tabletop.localPlayer.id, newSeat == nil {
tabletop.claimAnySeat()
}
}
}
GroupActivityManager #
To introduce multiplayer, we set up a group activity session:
import GroupActivities
import SwiftUI
@preconcurrency import TabletopKit
struct Activity: GroupActivity {
var metadata: GroupActivityMetadata {
var metadata = GroupActivityMetadata()
metadata.type = .generic
metadata.title = "TabletopKitExample"
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()
}
}
Blocks #
Lastly we add in a function to generate those testing blocks above.
func generateBlock() -> ModelEntity {
let pieceWidth: Float = 0.0375
let pieceHeight: Float = 0.0225
let pieceLength: Float = 0.1125
var metallicMaterial = SimpleMaterial(color: .gray, isMetallic: true)
metallicMaterial.metallic = MaterialScalarParameter(floatLiteral: 1.0)
metallicMaterial.roughness = MaterialScalarParameter(floatLiteral: 0.3)
let boxShape: MeshResource = .generateBox(
width: pieceWidth,
height: pieceHeight,
depth: pieceLength,
cornerRadius: 0.0025
)
let piece = ModelEntity(
mesh: boxShape,
materials: [metallicMaterial]
)
// Physics
let physicsMaterial = PhysicsMaterialResource.generate(
staticFriction: 0.8,
dynamicFriction: 0.8,
restitution: 0
)
piece.components[PhysicsBodyComponent.self] = .init(
massProperties: .init(
shape: .generateBox(
width: pieceWidth,
height: pieceHeight,
depth: pieceLength
),
mass: 1
),
material: physicsMaterial,
mode: .static
)
// Shadow
piece.components.set(GroundingShadowComponent(castsShadow: true))
// Input
piece.components.set(InputTargetComponent())
// Sound
piece.spatialAudio = SpatialAudioComponent()
// Collisions
piece.generateCollisionShapes(recursive: false)
return piece
}
This should give us a starting point to build from when building TabletopKit games. You might want to think about next steps such as:
- Introducing game state and turns
- Adding dice
- Setting up rules about who can interact with equipment and when
TabletopKit helps with all these and more.
You can see the full project on Github here.
- Previous: ARCtic iOS conference 2025 - Day 2