CasinoGames/Blackjack/GAME_TEMPLATE.md

19 KiB

Casino Game Development Guide

This guide explains how to build a new casino card game (like Blackjack, Poker, etc.) using CasinoKit, following the patterns established in the Baccarat app.

Project Structure

YourGame/
├── YourGameApp.swift              # App entry point
├── ContentView.swift              # Root view (usually just GameTableView)
├── Engine/
│   ├── YourGameEngine.swift       # Game rules & logic
│   └── GameState.swift            # Game state machine
├── Models/
│   ├── BetType.swift              # Game-specific bet types
│   ├── GameResult.swift           # Win/loss/push outcomes
│   ├── GameSettings.swift         # User settings (can reuse pattern)
│   ├── Hand.swift                 # Hand representation (if needed)
│   └── Shoe.swift                 # Card shoe (if multi-deck)
├── Storage/
│   └── YourGameData.swift         # Persistence model (PersistableGameData)
├── Theme/
│   └── DesignConstants.swift      # Game-specific design tokens
├── Views/
│   ├── GameTableView.swift        # Main game screen
│   ├── YourTableLayoutView.swift  # Game-specific table layout
│   ├── ResultBannerView.swift     # Win/loss display
│   ├── RulesHelpView.swift        # Game rules explanation
│   ├── SettingsView.swift         # Settings screen
│   └── StatisticsSheetView.swift  # Stats display
└── Resources/
    └── Localizable.xcstrings      # Translations

What CasinoKit Provides

Core Components (Import CasinoKit)

Category Components Usage
Cards Card, Suit, Rank, Deck Card models
Card Views CardView, CardPlaceholderView Card display
Chips ChipDenomination, ChipView, ChipStackView, ChipSelectorView Betting chips
Table TableBackgroundView, FeltPatternView Casino felt background
Overlays GameOverView, ConfettiView Game over & celebrations
Top Bar TopBarView Balance, settings, stats buttons
Badges ValueBadge Numeric value display
Settings SettingsToggle, SpeedPicker, VolumePicker, BalancePicker Settings UI
Sheets SheetContainerView, SheetSection Modal sheets
Branding AppIconView, LaunchScreenView App icons & splash
Audio SoundManager, GameSound Sound effects & haptics
Storage CloudSyncManager, PersistableGameData iCloud persistence
Models TableLimits Betting limits presets
Design CasinoDesign Spacing, colors, animations

Using CasinoKit Components

import SwiftUI
import CasinoKit

struct GameTableView: View {
    @State private var settings = GameSettings()
    @State private var gameState: GameState?
    @State private var selectedChip: ChipDenomination = .hundred
    
    var body: some View {
        ZStack {
            // 1. Table Background (from CasinoKit)
            TableBackgroundView()
            
            VStack {
                // 2. Top Bar (from CasinoKit)
                TopBarView(
                    balance: state.balance,
                    secondaryInfo: "\(state.engine.shoe.cardsRemaining)",
                    secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
                    onReset: { state.resetGame() },
                    onSettings: { showSettings = true },
                    onHelp: { showRules = true },
                    onStats: { showStats = true }
                )
                
                // 3. Your Game-Specific Table Layout
                YourTableLayoutView(...)
                
                // 4. Chip Selector (from CasinoKit)
                ChipSelectorView(
                    selectedChip: $selectedChip,
                    availableChips: ChipDenomination.allCases
                )
                
                // 5. Action Buttons (game-specific)
                ActionButtonsView(...)
            }
            
            // 6. Result Banner (game-specific, but follows pattern)
            if state.showResultBanner {
                ResultBannerView(...)
            }
            
            // 7. Confetti for Wins (from CasinoKit)
            if state.lastWinnings > 0 {
                ConfettiView()
            }
            
            // 8. Game Over (from CasinoKit)
            if state.isGameOver {
                GameOverView(
                    roundsPlayed: state.roundsPlayed,
                    onPlayAgain: { state.resetGame() }
                )
            }
        }
        .sheet(isPresented: $showSettings) {
            SettingsView(settings: settings, gameState: state) { ... }
        }
    }
}

Game-Specific Implementation

1. Game Engine (Required)

Create your game's rule engine. This handles:

  • Card dealing logic
  • Hand evaluation
  • Win/loss determination
  • Payout calculations
// Engine/YourGameEngine.swift
import CasinoKit

@Observable
@MainActor
final class YourGameEngine {
    var shoe: Shoe
    var playerHand: [Card] = []
    var dealerHand: [Card] = []
    
    init(deckCount: Int = 6) {
        self.shoe = Shoe(deckCount: deckCount)
    }
    
    func dealInitialCards() { ... }
    func evaluateHand(_ cards: [Card]) -> Int { ... }
    func determineWinner() -> GameResult { ... }
    func calculatePayout(bet: Int, result: GameResult) -> Int { ... }
}

2. Game State (Required)

Manages the state machine for your game:

// Engine/GameState.swift
import SwiftUI
import CasinoKit

enum GamePhase {
    case betting
    case dealing
    case playerTurn      // Blackjack-specific
    case dealerTurn      // Blackjack-specific
    case roundComplete
}

@Observable
@MainActor
final class GameState {
    // Core state
    var balance: Int
    var currentBets: [BetType: Int] = [:]
    var currentPhase: GamePhase = .betting
    var showResultBanner = false
    
    // Engine
    let engine: YourGameEngine
    
    // Persistence
    private let persistence: CloudSyncManager<YourGameData>
    
    // Sound
    private let sound = SoundManager.shared
    
    init(settings: GameSettings) {
        self.engine = YourGameEngine(deckCount: settings.deckCount.rawValue)
        self.balance = settings.startingBalance
        self.persistence = CloudSyncManager<YourGameData>()
        loadSavedGame()
    }
    
    func placeBet(type: BetType, amount: Int) {
        currentBets[type, default: 0] += amount
        balance -= amount
        sound.play(.chipPlace)
    }
    
    func deal() async {
        currentPhase = .dealing
        sound.play(.cardDeal)
        // Game-specific dealing logic
    }
    
    func newRound() {
        currentPhase = .betting
        showResultBanner = false
        sound.play(.newRound)
    }
}

3. Bet Types (Game-Specific)

// Models/BetType.swift

enum BetType: String, CaseIterable, Identifiable {
    // Blackjack example:
    case main = "main"
    case insurance = "insurance"
    case doubleDown = "double"
    case split = "split"
    
    // Baccarat example:
    // case player, banker, tie, playerPair, bankerPair, dragonBonusPlayer, dragonBonusBanker
    
    var id: String { rawValue }
    
    var displayName: String { ... }
    var payoutMultiplier: Double { ... }
}

4. Game Result (Game-Specific)

// Models/GameResult.swift

enum GameResult: Equatable {
    // Blackjack example:
    case playerWins
    case dealerWins
    case push
    case blackjack
    case bust
    
    var displayText: String { ... }
    var color: Color { ... }
}

5. Table Layout (Game-Specific)

This is the main visual difference between games:

// Views/YourTableLayoutView.swift

struct BlackjackTableView: View {
    // Shows dealer hand at top, player hand(s) below
    // Hit/Stand/Double/Split buttons
    // Insurance betting zone
}

struct BaccaratTableView: View {
    // Shows Player/Banker/Tie betting zones
    // Side bet zones (pairs, dragon bonus)
}

struct PokerTableView: View {
    // Community cards in center
    // Player positions around table
    // Pot display
}

6. Settings View (Mostly Reusable)

// Views/SettingsView.swift
import CasinoKit

struct SettingsView: View {
    @Bindable var settings: GameSettings
    let gameState: GameState
    
    var body: some View {
        SheetContainerView(title: "Settings") {
            // Table Limits (from CasinoKit pattern)
            SheetSection(title: "TABLE LIMITS", icon: "banknote") {
                // Use TableLimits enum from CasinoKit
            }
            
            // Deck Settings (game-specific)
            SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") {
                // DeckCount options
            }
            
            // Display Settings (reusable)
            SheetSection(title: "DISPLAY", icon: "eye") {
                SettingsToggle(title: "...", subtitle: "...", isOn: $settings.showX)
            }
            
            // Sound (from CasinoKit)
            SheetSection(title: "SOUND & HAPTICS", icon: "speaker.wave.2") {
                SettingsToggle(...)
                VolumePicker(volume: $settings.soundVolume)
            }
            
            // iCloud Sync (pattern from Baccarat)
            SheetSection(title: "CLOUD SYNC", icon: "icloud") { ... }
        }
    }
}

Sound Integration

// In your GameState
let sound = SoundManager.shared

// Play sounds at appropriate moments:
sound.play(.chipPlace)    // When placing a bet
sound.play(.cardDeal)     // When dealing cards
sound.play(.cardFlip)     // When flipping cards
sound.play(.win)          // On player win
sound.play(.lose)         // On player loss
sound.play(.push)         // On tie/push
sound.play(.newRound)     // Starting new round
sound.play(.gameOver)     // When out of chips

Persistence

// Storage/YourGameData.swift
import CasinoKit

struct BlackjackGameData: PersistableGameData {
    static let gameIdentifier = "blackjack"
    
    var roundsPlayed: Int { roundHistory.count }
    var lastModified: Date
    
    static var empty: BlackjackGameData {
        BlackjackGameData(
            lastModified: Date(),
            balance: 10_000,
            roundHistory: [],
            totalWinnings: 0,
            blackjackCount: 0,  // Game-specific stat
            bustCount: 0        // Game-specific stat
        )
    }
    
    var balance: Int
    var roundHistory: [SavedRoundResult]
    var totalWinnings: Int
    var blackjackCount: Int
    var bustCount: Int
}

Design Constants

Extend CasinoDesign for game-specific values:

// Theme/DesignConstants.swift

enum Design {
    // Reuse CasinoDesign values
    typealias Spacing = CasinoDesign.Spacing
    typealias CornerRadius = CasinoDesign.CornerRadius
    typealias Animation = CasinoDesign.Animation
    
    // Game-specific sizes
    enum Size {
        static let playerCardWidth: CGFloat = 55
        static let dealerCardWidth: CGFloat = 50
        // ... game-specific dimensions
    }
    
    // Game-specific colors (extend Color)
}

extension Color {
    enum BettingZone {
        static let main = Color.blue.opacity(0.3)
        static let insurance = Color.yellow.opacity(0.3)
        // ... game-specific colors
    }
}

Localization

Use String Catalogs (.xcstrings):

// Game-specific strings
Text(String(localized: "Hit"))
Text(String(localized: "Stand"))
Text(String(localized: "Double Down"))
Text(String(localized: "Split"))
Text(String(localized: "Blackjack!"))
Text(String(localized: "Bust!"))

Responsive Layout (iPhone vs iPad)

The Problem

On iPad, content that looks great on iPhone will stretch to fill the screen, making:

  • Betting areas look awkward and disproportionate
  • Cards appear too spread out
  • Buttons become oversized
  • Overlays cover the entire screen unnecessarily

The Solution: Constrained Width Containers

Use horizontalSizeClass to detect iPad and constrain content width:

struct GameTableView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    
    /// Whether we're on iPad (regular horizontal size class)
    private var isIPad: Bool {
        horizontalSizeClass == .regular
    }
    
    /// Maximum content width based on device and orientation
    private var maxContentWidth: CGFloat {
        if isIPad {
            // Landscape on iPad gets more width
            return verticalSizeClass == .compact 
                ? CasinoDesign.Size.maxContentWidthLandscape   // 800pt
                : CasinoDesign.Size.maxContentWidthPortrait    // 500pt
        }
        return .infinity  // iPhone uses full width
    }
    
    var body: some View {
        ZStack {
            TableBackgroundView()
            
            VStack {
                TopBarView(...)
                    .frame(maxWidth: maxContentWidth)
                
                // Game table - constrained on iPad
                YourTableLayoutView(...)
                    .frame(maxWidth: maxContentWidth)
                
                // Chip selector - constrained
                ChipSelectorView(...)
                    .frame(maxWidth: maxContentWidth)
                
                // Action buttons - constrained
                ActionButtonsView(...)
                    .frame(maxWidth: maxContentWidth)
            }
            .frame(maxWidth: .infinity) // Centers constrained content
            
            // Overlays - full screen background, constrained content
            if showResultBanner {
                ResultBannerView(...)
            }
        }
    }
}

Overlay Pattern (Result Banner, Game Over)

Overlays need special handling: full-screen dim background with constrained content card:

struct ResultBannerView: View {
    var body: some View {
        ZStack {
            // 1. Full-screen dark background
            Color.black.opacity(0.7)
                .ignoresSafeArea()
            
            // 2. Constrained content card
            VStack {
                // Your content
            }
            .padding()
            .background(RoundedRectangle(cornerRadius: 24).fill(...))
            .frame(maxWidth: CasinoDesign.Size.maxModalWidth)  // 450pt
            // Centered automatically by ZStack
        }
    }
}

Fixed-Size Containers (Prevent Layout Shifts)

When content changes (cards dealt, buttons appear/disappear), prevent jarring layout shifts:

// ❌ BAD: Container resizes as cards are added
HStack {
    ForEach(cards) { card in
        CardView(card: card)
    }
}

// ✅ GOOD: Fixed container based on max possible content
ZStack {
    // Reserve space for max cards (e.g., 3 cards with overlap)
    Color.clear
        .frame(width: calculateMaxWidth(), height: cardHeight)
    
    // Actual cards centered within
    HStack(spacing: cardSpacing) {
        ForEach(cards) { card in
            CardView(card: card)
        }
    }
}

Same for buttons:

// ✅ GOOD: Fixed height container for buttons
ZStack {
    Color.clear
        .frame(height: 60)  // Fixed height
    
    // Buttons animate in/out within fixed space
    if showDealButton {
        ActionButton("Deal", ...)
            .transition(.scale.combined(with: .opacity))
    }
}
.animation(.spring(duration: 0.3), value: currentPhase)

Confetti Full-Screen Fix

Confetti must cover the entire screen on iPad:

struct ConfettiView: View {
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                ForEach(0..<50, id: \.self) { _ in
                    ConfettiPiece(containerSize: geometry.size)
                }
            }
        }
        .ignoresSafeArea()  // Critical!
        .allowsHitTesting(false)
    }
}

Design Constants for Responsive Layout

// In CasinoDesign.swift
enum Size {
    // Max widths for iPad constraint
    static let maxContentWidthPortrait: CGFloat = 500
    static let maxContentWidthLandscape: CGFloat = 800
    static let maxModalWidth: CGFloat = 450
}

Common Pitfalls

Issue Symptom Fix
Stretched table Betting zones look huge on iPad Add .frame(maxWidth: maxContentWidth)
Overlay too wide Result banner covers entire iPad screen Use full-screen bg + constrained content card
Layout shifts Cards/buttons cause content to jump Use fixed-size ZStack containers
Confetti cut off Only shows in center portion Use GeometryReader + .ignoresSafeArea()
Settings rows cramped Title/subtitle too close to divider Add .padding(.vertical, ...)

Testing Checklist

  • iPhone SE (smallest)
  • iPhone Pro Max (largest iPhone)
  • iPad Portrait
  • iPad Landscape
  • iPad Split View
  • Dynamic Type at maximum accessibility size

Checklist for New Game

Setup

  • Create new target in Xcode
  • Add CasinoKit as dependency
  • Copy DesignConstants.swift and customize
  • Create Localizable.xcstrings

Models

  • Define BetType enum
  • Define GameResult enum
  • Create YourGameData for persistence
  • Create GameSettings (or reuse pattern)

Engine

  • Implement game rules in YourGameEngine
  • Implement GameState with phases

Views

  • Create GameTableView (main container)
  • Create game-specific table layout
  • Create ResultBannerView (follow pattern)
  • Create RulesHelpView (game rules)
  • Customize SettingsView
  • Create StatisticsSheetView

Integration

  • Wire up SoundManager for game events
  • Implement CloudSyncManager for persistence
  • Add accessibility labels
  • Add localization for all strings

Polish

  • Test Dynamic Type scaling (all sizes including accessibility)
  • Test VoiceOver navigation
  • Create app icon using AppIconView

Responsive Layout (iPad)

  • Add maxContentWidth constraint to main views
  • Test iPad Portrait - content centered, not stretched
  • Test iPad Landscape - wider constraint works well
  • Verify overlays: full-screen bg, constrained content
  • Fixed-size containers prevent layout shifts
  • Confetti covers full screen
  • Settings rows have proper padding

What Could Be Added to CasinoKit

The following patterns from Baccarat could be abstracted:

  1. Generic ResultBannerView - Win/loss display with bet breakdown
  2. BettingZone protocol - Common betting zone behavior
  3. GameStateProtocol - Common state machine patterns
  4. HandDisplayView - Generic card hand display
  5. ActionButtonsView - Deal/Clear/New Round pattern
  6. StatisticsView - Generic stats display

This guide is based on the Baccarat implementation. For reference, see the Baccarat app structure and CasinoKit source code.