Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
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.
The observer receives callbacks when actions are validated, pending, confirmed,
rolled back, 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 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
}
}let observer = GameObserver()
observer.game = self
game.addObserver(observer)
// Later, when done:
game.removeObserver(observer)Custom actions modify TableState directly and support validation. They are
serialized across the network automatically.
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
}
}
}
}// 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)For actions that modify equipment with CustomEquipmentState:
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
}
}
}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
]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
))
}
}
}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
))
}
}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
}
}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
}
}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 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
)
}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
)
}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))
}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)
)
}// Allow dropping only on specific equipment
interaction.setConfiguration(.init(
allowedDestinations: .restricted([
EquipmentIdentifier(10),
EquipmentIdentifier(11),
EquipmentIdentifier(12)
])
))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
))
}
}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.swiftimport 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()
}
}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 */)
}
}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)// Start hosting
networkSession.start()
// Join an existing session
networkSession.join()
// Leave gracefully
networkSession.leave()
// Terminate the session (arbiter only)
networkSession.terminate()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))
}// Undo last turn
if let lastBookmark = game.bookmarks.last {
game.jumpToBookmark(matching: lastBookmark)
}func stateDidResetToBookmark(_ bookmarkID: StateBookmarkIdentifier) {
// Refresh all UI state from the current snapshot
game.withCurrentSnapshot { snapshot in
refreshUI(from: snapshot)
}
}// During setup: one counter per seat
for seatIndex in 0..<4 {
setup.add(counter: ScoreCounter(id: .init(seatIndex), value: 0))
}// 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
))
}game.withCurrentSnapshot { snapshot in
for counter in snapshot.counters {
print("Counter \(counter.id): \(counter.value)")
}
}// 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: [])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)")
}
}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
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references