CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

tabletopkit-patterns.mdskills/tabletopkit/references/

TabletopKit Extended Patterns

Overflow reference for the tabletopkit skill. Contains advanced patterns for observer implementation, custom actions, dice physics, card layouts, network coordination, and full game architecture examples.

Availability notes:

  • Core TabletopKit APIs are visionOS 2.0+.
  • TabletopInteraction.Configuration is visionOS 2.2+.
  • CustomAction, CustomEquipmentState, TableSetup.register(action:), and direct custom-action dispatch are visionOS 26.0+.
  • Apple's dice, card-overlap, and group-gameplay sample projects referenced by these patterns use visionOS 26.0+ / Xcode 26.0+ APIs.

Contents

  • Observer Patterns
  • Custom Action Patterns
  • Dice Simulation
  • Card and Tile Layouts
  • Interaction Delegate Patterns
  • Full Game Architecture
  • Network and Multiplayer Coordination
  • State Bookmarks and Undo
  • Score Tracking
  • Debugging Techniques

Observer Patterns

Implementing TabletopGame.Observer

The observer receives callbacks when actions are validated, pending, confirmed, rolled back, discarded, or cancelled. Use actionWasConfirmed as the primary hook for updating game-specific state.

import TabletopKit

class GameObserver: TabletopGame.Observer {
    weak var game: Game?

    func validateAction(_ action: some TabletopAction,
                        snapshot: TableSnapshot) -> Bool {
        // Return false to reject the action before it applies.
        // Called on the arbiter (host) device in multiplayer.
        if let moveAction = action as? MoveEquipmentAction {
            // Validate move is legal in current game rules
            return isLegalMove(moveAction, in: snapshot)
        }
        return true
    }

    func actionIsPending(_ action: some TabletopAction,
                         oldSnapshot: TableSnapshot,
                         newSnapshot: TableSnapshot) {
        // Action applied locally but not yet confirmed by arbiter.
        // Use for optimistic UI updates.
    }

    func actionWasConfirmed(_ action: some TabletopAction,
                            oldSnapshot: TableSnapshot,
                            newSnapshot: TableSnapshot) {
        guard let game else { return }

        // Handle built-in actions
        if let setTurn = action as? SetTurnAction {
            game.currentTurnSeats = setTurn.seatIDsInTurn
            return
        }

        if let counterAction = action as? UpdateCounterAction {
            game.scores[counterAction.counterID] = counterAction.newValue
            return
        }

        // Handle custom actions
        if let collect = CollectCoin(from: action) {
            handleCoinCollected(collect, snapshot: newSnapshot)
            return
        }
    }

    func actionWasRolledBack(_ action: some TabletopAction,
                             snapshot: TableSnapshot) {
        // Arbiter rejected the action. Revert optimistic UI.
    }

    func actionWasDiscarded(_ action: some TabletopAction) {
        // visionOS 26.0+: local action could not be enqueued.
        // Reconcile UI that expected the action to become pending.
    }

    func actionWasCancelled(_ action: some TabletopAction,
                            reason: TabletopGame.ActionCancellationReason) {
        switch reason {
        case .actionInvalidated:
            // Action was invalidated by a conflicting action
            break
        case .interactionCancelled:
            // The interaction that queued this action was cancelled
            break
        @unknown default:
            break
        }
    }

    func playerChangedSeats(_ player: Player,
                            oldSeat: (any TableSeat)?,
                            newSeat: (any TableSeat)?,
                            snapshot: TableSnapshot) {
        game?.updatePlayerList(snapshot: snapshot)
    }

    func stateDidResetToBookmark(_ bookmarkID: StateBookmarkIdentifier) {
        game?.handleStateReset()
    }

    private func isLegalMove(_ action: MoveEquipmentAction,
                             in snapshot: TableSnapshot) -> Bool {
        // Game-specific validation
        true
    }

    private func handleCoinCollected(_ action: CollectCoin,
                                     snapshot: TableSnapshot) {
        // Update game-specific state
    }
}

Registering and Removing Observers

let observer = GameObserver()
observer.game = self
game.addObserver(observer)

// Later, when done:
game.removeObserver(observer)

Custom Action Patterns

CustomAction and CustomEquipmentState are visionOS 26.0+. Register each custom action type with TableSetup.register(action:) before dispatching it. Keep validate(snapshot:) and apply(table:) deterministic: they should use only the supplied snapshot/table state and the data stored in the action.

Defining a Custom Action

Custom actions modify TableState directly and support validation. TabletopKit syncs the resulting actions across the network.

struct FlipAllCards: CustomAction {
    let targetFaceUp: Bool

    init?(from action: some TabletopAction) {
        // Decode from the generic action's context
        let raw = action.context
        self.targetFaceUp = (raw & 1) == 1
    }

    func validate(snapshot: TableSnapshot) -> Bool {
        // Ensure there are cards to flip
        let cards = snapshot.equipment(of: PlayingCard.self)
        return !cards.isEmpty
    }

    func apply(table: inout TableState) {
        let cardIDs = table.equipment.ids(of: PlayingCard.self)
        for cardID in cardIDs {
            if var cardState = table.equipment.state[of: PlayingCard.self, id: cardID] {
                cardState.faceUp = targetFaceUp
                table.equipment.state[of: PlayingCard.self, id: cardID] = cardState
            }
        }
    }
}

Dispatching Custom Actions

// Register during setup
setup.register(action: FlipAllCards.self)

// Dispatch during gameplay
let flipAction = TabletopAction.customAction(
    FlipAllCards(targetFaceUp: true),
    context: 1  // encode targetFaceUp as context
)
game.addAction(flipAction)

Custom Action with Equipment State Mutation

For actions that modify equipment with CustomEquipmentState (visionOS 26.0+):

struct PlayerState: CustomEquipmentState {
    var base: BaseEquipmentState
    var health: Int
    var coinsCount: Int
}

struct DecrementHealth: CustomAction {
    let playerID: EquipmentIdentifier

    init?(from action: some TabletopAction) {
        guard let moveAction = action as? UpdateEquipmentAction else { return nil }
        self.playerID = moveAction.equipmentID
    }

    func validate(snapshot: TableSnapshot) -> Bool {
        guard let (_, state) = snapshot.equipment(
            of: PlayerPiece.self, matching: playerID
        ) else { return false }
        return state.health > 0
    }

    func apply(table: inout TableState) {
        if var state = table.equipment.state[of: PlayerPiece.self, id: playerID] {
            state.health = max(0, state.health - 1)
            table.equipment.state[of: PlayerPiece.self, id: playerID] = state
        }
    }
}

Dice Simulation

These dice patterns follow Apple's visionOS 26.0+ dice sample. The underlying TossableRepresentation, toss interaction, and RawValueState APIs are part of TabletopKit. onTossStart(interaction:outcomes:), TabletopInteraction.TossOutcome, and TossableRepresentation.face(for:) are visionOS 26.0+; gate them before back-deploying.

Tossable Representations and Face Mapping

Each die shape has a corresponding face type for mapping physical orientations to game values.

// Standard 6-sided die
let d6 = TossableRepresentation.cube(height: 0.02, in: .meters)

// Map cube faces to values
let cubeFaceValues: [TossableRepresentation.CubeFace: Int] = [
    .a: 1, .b: 2, .c: 3,
    .d: 4, .e: 5, .f: 6
]

Implementing Dice Toss in an Interaction Delegate

class DiceInteraction: TabletopInteraction.Delegate {
    let game: Game
    let die: GameDie
    let tossableRep: TossableRepresentation

    init(game: Game, die: GameDie) {
        self.game = game
        self.die = die
        self.tossableRep = .cube(height: 0.02, in: .meters)
    }

    func update(interaction: TabletopInteraction) {
        guard let gesture = interaction.value.gesture else { return }

        switch gesture.phase {
        case .started:
            break
        case .update:
            break
        case .ended:
            // Player released -- initiate toss
            interaction.toss(
                equipmentID: die.id,
                as: tossableRep
            )
        case .cancelled:
            interaction.cancel()
        @unknown default:
            break
        }
    }

    func onTossStart(interaction: TabletopInteraction,
                     outcomes: [TabletopInteraction.TossOutcome]) {
        for outcome in outcomes {
            guard outcome.id == die.id else { continue }

            // Physics simulation determines final face
            let face = outcome.tossableRepresentation.face(
                for: outcome.restingOrientation
            )

            interaction.addAction(.updateEquipment(
                die,
                rawValue: face.rawValue,
                pose: outcome.pose
            ))
        }
    }
}

Predetermined Outcomes

Override the physics result for scripted gameplay:

func onTossStart(interaction: TabletopInteraction,
                 outcomes: [TabletopInteraction.TossOutcome]) {
    for outcome in outcomes {
        // Force the highest-scoring face instead of physics result
        let bestFace = TossableRepresentation.CubeFace.f  // value 6
        interaction.addAction(.updateEquipment(
            die,
            rawValue: bestFace.rawValue,
            pose: outcome.pose
        ))
    }
}

Group Toss (Multiple Dice)

Move extra dice under the controlled die, then toss all together:

func update(interaction: TabletopInteraction) {
    switch interaction.value.phase {
    case .started:
        // Group dice under the controlled die
        for (index, extraDie) in otherDice.enumerated() {
            interaction.addAction(.moveEquipment(
                extraDie,
                childOf: controlledDie,
                pose: hexagonPoses[index]
            ))
        }

    case .update:
        if interaction.value.gesture?.phase == .ended {
            // Toss all dice
            interaction.toss(equipmentID: controlledDie.id,
                             as: controlledDie.tossableRepresentation)
            for die in otherDice {
                interaction.toss(equipmentID: die.id,
                                 as: die.tossableRepresentation)
            }
        }

    case .ended:
        // Calculate total score
        game.updateScore(for: [controlledDie] + otherDice)

    default: break
    }
}

Reading Die Score After Toss

func updateScore(for dice: [GameDie]) {
    tabletopGame.withCurrentSnapshot { snapshot in
        var total = 0
        for die in dice {
            let state: RawValueState = snapshot.state(for: die)
            let face = die.tossableRepresentation.face(for:
                Rotation3D(/* from state */))
            total += die.faceMap[face] ?? 0
        }
        lastRollScore = total
    }
}

Card and Tile Layouts

The planar layout APIs are visionOS 2.0+. Apple's advanced physical card overlap sample uses visionOS 26.0+ / Xcode 26.0+ material.

Stacked Card Layout

Use planarStacked for neat card piles:

func layoutChildren(for snapshot: TableSnapshot,
                    visualState: TableVisualState) -> any EquipmentLayout {
    let childIDs = snapshot.equipmentIDs(childrenOf: id)
    let poses = childIDs.enumerated().map { index, childID in
        EquipmentPose2D(id: childID, pose: .init(
            position: .init(x: 0, z: 0),
            rotation: .zero
        ))
    }
    return EquipmentLayout.planarStacked(
        layout: poses,
        animationDuration: 0.25
    )
}

Fan / Hand Layout

Fan cards in an arc for a player's hand:

func layoutChildren(for snapshot: TableSnapshot,
                    visualState: TableVisualState) -> any EquipmentLayout {
    let childIDs = snapshot.equipmentIDs(childrenOf: id)
    let count = childIDs.count
    let fanAngle = Angle2D(degrees: 30)
    let spacing = 0.04

    let poses = childIDs.enumerated().map { index, childID in
        let fraction = count > 1
            ? Double(index) / Double(count - 1) - 0.5
            : 0
        let angle = Angle2D(radians: fanAngle.radians * fraction)
        let x = spacing * fraction * Double(count)

        return EquipmentPose2D(id: childID, pose: .init(
            position: .init(x: x, z: 0),
            rotation: angle
        ))
    }
    return EquipmentLayout.planarOverlapping(
        layout: poses,
        animationDuration: 0.3
    )
}

Grid Layout for Tiles

func layoutChildren(for snapshot: TableSnapshot,
                    visualState: TableVisualState) -> any EquipmentLayout {
    let childIDs = snapshot.equipmentIDs(childrenOf: id)
    let columns = 4
    let tileSize = 0.06

    let poses = childIDs.enumerated().map { index, childID in
        let row = index / columns
        let col = index % columns
        return EquipmentPose2D(id: childID, pose: .init(
            position: .init(
                x: Double(col) * tileSize,
                z: Double(row) * tileSize
            ),
            rotation: .zero
        ))
    }
    return EquipmentLayout.planarStacked(
        layout: poses,
        animationDuration: 0.2
    )
}

Interaction Delegate Patterns

Use interaction.value.gesture for gesture-specific state. Avoid deprecated gesturePhase. Use interaction.setConfiguration(_:) with TabletopInteraction.Configuration on visionOS 2.2+ instead of deprecated setAllowedDestinations(_:) or value.allowedDestinations.

Accepting and Rejecting Interactions

Control which interactions the game allows:

func shouldAcceptInteraction(
    initialValue: TabletopInteraction.Value,
    handoffValue: TabletopInteraction.Value?
) -> TabletopInteraction.NewInteractionIntent {
    // Only allow interaction if it is this player's turn
    guard isCurrentPlayersTurn(initialValue.playerID) else {
        return .reject
    }
    return .acceptWithConfiguration(.init(allowedDestinations: .any))
}

Direct vs. Indirect Interaction

func shouldAcceptDirectInteraction(
    initialValue: TabletopInteraction.Value,
    handoffValue: TabletopInteraction.Value?
) -> TabletopInteraction.NewDirectInteractionIntent {
    .accept(
        configuration: .init(allowedDestinations: .any),
        constants: .init(pickupBehavior: .default)
    )
}

func shouldAcceptIndirectInteraction(
    initialValue: TabletopInteraction.Value,
    handoffValue: TabletopInteraction.Value?
) -> TabletopInteraction.NewIndirectInteractionIntent {
    .accept(
        configuration: .init(allowedDestinations: .any),
        constants: .init(rotationAlignment: .automatic)
    )
}

Restricting Destinations

// Allow dropping only on specific equipment
interaction.setConfiguration(.init(
    allowedDestinations: .restricted([
        EquipmentIdentifier(10),
        EquipmentIdentifier(11),
        EquipmentIdentifier(12)
    ])
))

Moving Equipment on Interaction End

func update(interaction: TabletopInteraction) {
    if interaction.value.phase == .ended,
       let destination = interaction.value.proposedDestination {
        interaction.addAction(.moveEquipment(
            matching: interaction.value.controlledEquipmentID,
            childOf: destination.equipmentID,
            pose: destination.pose
        ))
    }
}

Full Game Architecture

Recommended Structure

Game/
  Game.swift              -- TabletopGame owner, game lifecycle
  GameSetup.swift         -- TableSetup configuration, equipment creation
  GameObserver.swift      -- TabletopGame.Observer implementation
  GameRenderer.swift      -- EntityRenderDelegate implementation
  GameView.swift          -- SwiftUI RealityView + .tabletopGame modifier
  Equipment/
    Board.swift           -- EntityTabletop conformance
    Pawn.swift            -- EntityEquipment (BaseEquipmentState)
    Card.swift            -- EntityEquipment (CardState)
    Die.swift             -- EntityEquipment (DieState or RawValueState)
    CardPile.swift        -- Equipment group with layoutChildren
  Interactions/
    PawnInteraction.swift
    CardInteraction.swift
    DieInteraction.swift
  Actions/
    CustomActions.swift   -- CustomAction conformances
  Multiplayer/
    Activity.swift        -- GroupActivity definition
    GroupActivityManager.swift

Game Class Skeleton

import TabletopKit
import RealityKit

@Observable
class Game {
    let tabletopGame: TabletopGame
    let renderer: GameRenderer
    let observer: GameObserver

    init() {
        let table = GameBoard()
        var setup = TableSetup(tabletop: table)

        // Add seats
        for i in 0..<4 {
            setup.add(seat: PlayerSeat(index: i))
        }

        // Add equipment
        setup.add(equipment: GameDie(id: .init(100)))
        for i in 0..<52 {
            setup.add(equipment: PlayingCard(id: .init(200 + i)))
        }

        // Add counters
        for i in 0..<4 {
            setup.add(counter: ScoreCounter(id: .init(i), value: 0))
        }

        // Register custom actions
        setup.register(action: CollectCoin.self)
        setup.register(action: FlipAllCards.self)

        // Create game
        tabletopGame = TabletopGame(tableSetup: setup)

        // Set up rendering
        renderer = GameRenderer()
        tabletopGame.addRenderDelegate(renderer)

        // Set up observation
        observer = GameObserver()
        observer.game = self
        tabletopGame.addObserver(observer)

        // Claim a seat
        tabletopGame.claimAnySeat()
    }
}

Network and Multiplayer Coordination

Group Activities coordination is available through TabletopGame.coordinateWithSession(_:); Apple's group-gameplay sample uses visionOS 26.0+ / Xcode 26.0+ APIs.

Custom Network Coordinator

For non-GroupActivities multiplayer (e.g., local network), implement TabletopNetworkSessionCoordinator:

class LocalNetworkCoordinator: TabletopNetworkSessionCoordinator {
    typealias Peer = NetworkPeer
    typealias NetworkSession = TabletopNetworkSession<LocalNetworkCoordinator>

    var networkSession: NetworkSession?

    func coordinateWithSession(_ session: NetworkSession) {
        self.networkSession = session
    }

    func sendMessage(_ data: Data,
                     to peers: Set<NetworkPeer>,
                     completion: (TabletopSendMessageResult) -> Void) {
        // Send via your transport layer
        completion(.success)
    }

    func sendMessageUnreliably(_ data: Data,
                                to peers: Set<NetworkPeer>,
                                completion: (TabletopSendMessageResult) -> Void) {
        // Send without delivery guarantee
        completion(.success)
    }

    func peerJoinedGame(_ peerID: NetworkPeer.ID) {
        networkSession?.addPeer(/* peer */)
    }

    func peerLeftGame(_ peerID: NetworkPeer.ID) {
        networkSession?.removePeer(/* peer */)
    }
}

Arbiter Role

In multiplayer, one device acts as the arbiter (source of truth). The arbiter validates actions and resolves conflicts.

// Become the arbiter
networkSession.becomeArbiter()

// Or follow another peer as arbiter
networkSession.followArbiter(hostPeer)

Handling Network Lifecycle

// Start hosting
networkSession.start()

// Join an existing session
networkSession.join()

// Leave gracefully
networkSession.leave()

// Terminate the session (arbiter only)
networkSession.terminate()

State Bookmarks and Undo

Creating Bookmarks at Key Points

Save state at the start of each turn for undo support:

func startNewTurn(seatID: TableSeatIdentifier) {
    let bookmarkID = StateBookmarkIdentifier(turnNumber)
    game.addAction(.createBookmark(id: bookmarkID))
    game.addAction(.setTurn(matching: seatID))
}

Restoring to a Bookmark

// Undo last turn
if let lastBookmark = game.bookmarks.last {
    game.jumpToBookmark(matching: lastBookmark)
}

Observer Notification

func stateDidResetToBookmark(_ bookmarkID: StateBookmarkIdentifier) {
    // This callback is the completion/reconciliation point for the jump.
    game.withCurrentSnapshot { snapshot in
        refreshUI(from: snapshot)
        validateRestoredTurnInvariant(snapshot)
    }
}

Do not inspect the snapshot immediately after jumpToBookmark or blindly issue another jump when local UI disagrees. Wait for this ordered callback, then rebuild local UI from its snapshot. Keep confirm, rollback, and discard handling in the main Observer Patterns implementation.

Score Tracking

Setting Up Counters per Player

// During setup: one counter per seat
for seatIndex in 0..<4 {
    setup.add(counter: ScoreCounter(id: .init(seatIndex), value: 0))
}

Updating Scores

// Increment a player's score
game.withCurrentSnapshot { snapshot in
    let currentScore = snapshot.counter(matching: .init(seatIndex))?.value ?? 0
    game.addAction(.updateCounter(
        matching: .init(seatIndex),
        value: currentScore + points
    ))
}

Reading Scores from Snapshot

game.withCurrentSnapshot { snapshot in
    for counter in snapshot.counters {
        print("Counter \(counter.id): \(counter.value)")
    }
}

Debugging Techniques

Debug Draw Options

// Draw all debug visuals
game.debugDraw(options: [.drawTable, .drawSeats, .drawEquipment])

// Draw only table boundaries
game.debugDraw(options: [.drawTable])

// Disable all debug visuals
game.debugDraw(options: [])

Inspecting Snapshots

game.withCurrentSnapshot { snapshot in
    // List all equipment
    for id in snapshot.equipmentIDs() {
        let state = snapshot.state(matching: id)
        print("Equipment \(id.rawValue): \(String(describing: state))")
    }

    // List seat assignments
    for seat in snapshot.seats {
        print("Seat: \(seat)")
    }

    // Check whose turn it is
    print("Turn: \(snapshot.turn)")

    // List active cursors (interactions in progress)
    for cursor in snapshot.cursors {
        print("Cursor: player=\(cursor.playerID), "
              + "equipment=\(cursor.controlledEquipmentPose.id)")
    }
}

Logging Observer Events

Wrap observer methods with logging during development:

func actionWasConfirmed(_ action: some TabletopAction,
                        oldSnapshot: TableSnapshot,
                        newSnapshot: TableSnapshot) {
    #if DEBUG
    print("[Observer] Confirmed: \(type(of: action)), "
          + "player=\(String(describing: action.playerID))")
    #endif
    // Normal handling...
}

func actionWasRolledBack(_ action: some TabletopAction,
                          snapshot: TableSnapshot) {
    #if DEBUG
    print("[Observer] Rolled back: \(type(of: action))")
    #endif
}

skills

tabletopkit

.mcp.json

README.md

tile.json