Loading...
Loading...
Create multiplayer spatial board games using TabletopKit on visionOS. Use when building tabletop game experiences with boards, pieces, cards, and dice, managing player seats and turns, synchronizing game state over FaceTime with Group Activities, rendering game elements with RealityKit, or implementing piece snapping and physics on a virtual table surface.
npx skill4agent add dpearson2699/swift-ios-skills tabletopkitimport TabletopKitimport RealityKit| Type | Role |
|---|---|
| Central game manager; owns setup, actions, observers, rendering |
| Configuration object passed to |
| Protocol for the table surface |
| Protocol for interactive game pieces |
| Protocol for player seat positions |
| Commands that modify game state |
| Gesture-driven player interactions with equipment |
| Callback protocol for reacting to confirmed actions |
| Callback protocol for visual updates |
| RealityKit-specific render delegate |
TabletopGameimport TabletopKit
import RealityKit
let table = GameTable()
var setup = TableSetup(tabletop: table)
setup.add(seat: PlayerSeat(index: 0, pose: seatPose0))
setup.add(seat: PlayerSeat(index: 1, pose: seatPose1))
setup.add(equipment: GamePawn(id: .init(1)))
setup.add(equipment: GameDie(id: .init(2)))
setup.register(action: MyCustomAction.self)
let game = TabletopGame(tableSetup: setup)
game.claimAnySeat()update(deltaTime:).tabletopGame(_:parent:automaticUpdate:)withCurrentSnapshot(_:)EntityTabletopshapeEntitystruct GameTable: EntityTabletop {
var shape: TabletopShape
var entity: Entity
var id: EquipmentIdentifier
init() {
entity = try! Entity.load(named: "table/game_table", in: contentBundle)
shape = .round(entity: entity)
id = .init(0)
}
}TabletopShape// Round table from dimensions
let round = TabletopShape.round(
center: .init(x: 0, y: 0, z: 0),
radius: 0.5,
thickness: 0.05,
in: .meters
)
// Rectangular table from entity
let rect = TabletopShape.rectangular(entity: tableEntity)EquipmentEntityEquipmentidEquipmentIdentifierinitialState| State Type | Use Case |
|---|---|
| Generic pieces, pawns, tokens |
| Playing cards (tracks |
| Dice with an integer |
| Custom data encoded as |
// Pawn -- uses BaseEquipmentState
struct GamePawn: EntityEquipment {
var id: EquipmentIdentifier
var initialState: BaseEquipmentState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "pieces/pawn", in: contentBundle)
self.initialState = BaseEquipmentState(
parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}
// Card -- uses CardState (tracks faceUp)
struct PlayingCard: EntityEquipment {
var id: EquipmentIdentifier
var initialState: CardState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "cards/card", in: contentBundle)
self.initialState = .faceDown(
parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}
// Die -- uses DieState (tracks integer value)
struct GameDie: EntityEquipment {
var id: EquipmentIdentifier
var initialState: DieState
var entity: Entity
init(id: EquipmentIdentifier) {
self.id = id
self.entity = try! Entity.load(named: "dice/d6", in: contentBundle)
self.initialState = DieState(
value: 1, parentID: .init(0), seatControl: .any,
pose: .identity, entity: entity
)
}
}seatControl.any.restricted([seatID1, seatID2]).current.inheritedlayoutChildren(for:visualState:).planarStacked(layout:animationDuration:).planarOverlapping(layout:animationDuration:).volumetric(layout:animationDuration:)EntityTableSeatstruct PlayerSeat: EntityTableSeat {
var id: TableSeatIdentifier
var initialState: TableSeatState
var entity: Entity
init(index: Int, pose: TableVisualState.Pose2D) {
self.id = TableSeatIdentifier(index)
self.entity = Entity()
self.initialState = TableSeatState(pose: pose, context: 0)
}
}game.claimAnySeat()game.claimSeat(matching:)game.releaseSeat()TabletopGame.Observer.playerChangedSeatsTabletopAction// Move equipment to a new parent
game.addAction(.moveEquipment(matching: pieceID, childOf: targetID, pose: newPose))
// Flip a card face-up
game.addAction(.updateEquipment(card, faceUp: true))
// Update die value
game.addAction(.updateEquipment(die, value: 6))
// Set whose turn it is
game.addAction(.setTurn(matching: TableSeatIdentifier(1)))
// Update a score counter
game.addAction(.updateCounter(matching: counterID, value: 100))
// Create a state bookmark (for undo/reset)
game.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))CustomActionstruct CollectCoin: CustomAction {
let coinID: EquipmentIdentifier
let playerID: EquipmentIdentifier
init?(from action: some TabletopAction) {
// Decode from generic action
}
func validate(snapshot: TableSnapshot) -> Bool {
// Return true if action is legal
true
}
func apply(table: inout TableState) {
// Mutate state directly
}
}setup.register(action: CollectCoin.self)setup.add(counter: ScoreCounter(id: .init(0), value: 0))
// Update: game.addAction(.updateCounter(matching: .init(0), value: 42))
// Read: snapshot.counter(matching: .init(0))?.valuegame.addAction(.createBookmark(id: StateBookmarkIdentifier(1)))
game.jumpToBookmark(matching: StateBookmarkIdentifier(1)).tabletopGame.tabletopGame(game.tabletopGame, parent: game.renderer.root) { value in
if game.tabletopGame.equipment(of: GameDie.self, matching: value.startingEquipmentID) != nil {
return DieInteraction(game: game)
}
return DefaultInteraction(game: game)
}class DieInteraction: TabletopInteraction.Delegate {
let game: Game
func update(interaction: TabletopInteraction) {
switch interaction.value.phase {
case .started:
interaction.setConfiguration(.init(allowedDestinations: .any))
case .update:
if interaction.value.gesture?.phase == .ended {
interaction.toss(
equipmentID: interaction.value.controlledEquipmentID,
as: .cube(height: 0.02, in: .meters)
)
}
case .ended, .cancelled:
break
}
}
func onTossStart(interaction: TabletopInteraction,
outcomes: [TabletopInteraction.TossOutcome]) {
for outcome in outcomes {
let face = outcome.tossableRepresentation.face(for: outcome.restingOrientation)
interaction.addAction(.updateEquipment(
die, rawValue: face.rawValue, pose: outcome.pose
))
}
}
}.cube.tetrahedron.octahedron.decahedron.dodecahedron.icosahedron.sphereheight:in:radius:in:restitution:game.startInteraction(onEquipmentID: pieceID)EntityRenderDelegaterootEntityEquipmentclass GameRenderer: EntityRenderDelegate {
let root = Entity()
func onUpdate(timeInterval: Double, snapshot: TableSnapshot,
visualState: TableVisualState) {
// Custom visual updates beyond automatic positioning
}
}.tabletopGame(_:parent:automaticUpdate:)RealityViewstruct GameView: View {
let game: Game
var body: some View {
RealityView { content in
content.entities.append(game.renderer.root)
}
.tabletopGame(game.tabletopGame, parent: game.renderer.root) { value in
GameInteraction(game: game)
}
}
}game.tabletopGame.debugDraw(options: [.drawTable, .drawSeats, .drawEquipment])GroupActivitycoordinateWithSession(_:)import GroupActivities
struct BoardGameActivity: GroupActivity {
var metadata: GroupActivityMetadata {
var meta = GroupActivityMetadata()
meta.type = .generic
meta.title = "Board Game"
return meta
}
}
@Observable
class GroupActivityManager {
let tabletopGame: TabletopGame
private var sessionTask: Task<Void, Never>?
init(tabletopGame: TabletopGame) {
self.tabletopGame = tabletopGame
sessionTask = Task { @MainActor in
for await session in BoardGameActivity.sessions() {
tabletopGame.coordinateWithSession(session)
}
}
}
deinit { tabletopGame.detachNetworkCoordinator() }
}TabletopGame.MultiplayerDelegatejoinAccepted()playerJoined(_:)didRejectPlayer(_:reason:)multiplayerSessionFailed(reason:)claimAnySeat()claimSeat(_:)TabletopActionCustomActionsetup.register(action:)TabletopGameactionWasRolledBack(_:snapshot:)parentIDoutcome.tossableRepresentation.face(for: outcome.restingOrientation)import TabletopKitTableSetupTabletopEntityTabletopEquipmentEntityEquipmentclaimAnySeat()claimSeat(_:)setup.register(action:)TabletopGame.ObserverEntityRenderDelegateRenderDelegate.tabletopGame(_:parent:automaticUpdate:)RealityViewGroupActivitycoordinateWithSession(_:)debugDraw